Forge is built to run inside your own perimeter with bring-your-own-keys. This guide covers the operator's responsibilities: secrets, credential rotation, network policy, and the platform's built-in guardrails. It complements the deployment hardening described in docker-compose.md.
All secrets are supplied through .env (see
../../.env.example) and, for per-workspace provider keys,
the encrypted BYOK vault in Postgres.
-
Generate strong values.
SECRET_KEY,AUTH_SECRET,POSTGRES_PASSWORD, andMINIO_ROOT_PASSWORDmust be long and random:openssl rand -hex 32
-
SECRET_KEYis the master key. The BYOK vault is encrypted at rest with it. If it leaks, rotate it and re-encrypt the vault. If it is lost, the vault is unrecoverable — keep an off-host copy with your backups' twin (never in the same archive; see backup.md). -
Never commit
.env. It is git-ignored by default. Restrict it on disk:chmod 600 .env. -
Secrets are redacted everywhere. Forge redacts secret values from logs, traces, and retrieval results by design. Do not defeat this by echoing secrets into application logs or by lowering
LOG_LEVELto debug in production.
The F37 envelope vault (forge_auth) uses AES-256-GCM with a versioned
32-byte master key (KEK) and an HKDF-derived per-workspace data key; the
workspace id is bound into the GCM AAD, so a ciphertext copied across tenants
fails authentication. Generate KEKs and peppers with:
python -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())"FORGE_VAULT_KEYS— versioned KEK map, e.g.1:<base64-32B>. To rotate, append a new version (1:<old>,2:<new>), setFORGE_VAULT_ACTIVE_KEY_VERSION=2, and re-encrypt existing blobs (SecretVault.rotate); old versions keep decrypting until removed (crypto-shred by deleting a retired version).API_KEY_PEPPER— HMAC pepper for platform API-key hashing. Platform keys (forge_pat_*/forge_svc_*/forge_agt_*) are one-way hashed and shown once at mint; a database dump cannot recover a usable token.- Startup is fail-closed: missing/malformed key material raises immediately rather than falling back to an insecure default.
The BYOK vault (the api_key table) is protected by two-tier envelope
encryption: every secret is encrypted under a fresh per-secret data key
(DEK), and the DEK is wrapped under a versioned key-encryption key (KEK).
The KEK is FORGE_SECRET_KEY; the wrapped DEK and its KEK version travel inside
the stored blob. This bounds blast radius (one leaked DEK ≠ all secrets) and
makes KEK rotation an O(rows) re-wrap of small DEKs — no BYOK plaintext is ever
decrypted during rotation.
Canonical config (12-factor; any key may instead be supplied as <KEY>_FILE):
| Env var | Meaning |
|---|---|
FORGE_ENVIRONMENT |
canonical env; production requires a real key (replaces FORGE_ENV). |
FORGE_SECRET_KEY |
current master KEK, ≥ 16 bytes (replaces SECRET_KEY). |
FORGE_SECRET_KEY_FILE |
path to a mounted Docker/K8s secret holding the KEK. |
FORGE_SECRET_KEY_VERSION |
current KEK version (int). |
FORGE_SECRET_KEY_V<n> |
previous KEK version, kept only during a rotation window. |
FORGE_ENVELOPE_ENCRYPTION |
true in prod — envelope vs single-tier cipher. |
FORGE_DEV_INSECURE |
dev-only explicit opt-in to an ephemeral key. Never in prod. |
Preflight your config before every deploy (blocking CI secrets-config gate):
python -m forge_api.cli_secrets check-config # exits non-zero on any violationKEK rotation drill (zero-downtime). Back up the database first.
- Generate a new key:
python -c 'import secrets; print(secrets.token_urlsafe(32))'. - Set
FORGE_SECRET_KEY= the new key,FORGE_SECRET_KEY_V1= the old key, andFORGE_SECRET_KEY_VERSION=2. - Re-wrap every stored DEK under the new KEK:
Reads during the window transparently unwrap under whichever KEK version each row carries, so there is no downtime.
python -m forge_api.cli_secrets rotate-kek # prints {"rewrapped": N, "skipped": M} - Verify a known BYOK secret still decrypts (
POST /auth/secrets/{id}/checkreturns{"ok": true}), then removeFORGE_SECRET_KEY_V1from the env and recreateapi+worker.
Downgrade note: the additive migration
0023_envelope_key_versionis reversible, but rows written by the envelope cipher only remain decryptable while the code understands the envelope blob format — pair any schema downgrade with a matching code rollback.
Value rotation vs KEK rotation. Rotating a credential value (an operator
supplies a new secret) is POST /auth/secrets/{id}/rotate; rotating the KEK
(re-wrapping DEKs, no plaintext) is secrets rotate-kek. They are distinct and
separately audited (secret.rotated vs the rotated_at/key_version trail).
Automatic expiry. Stored BYOK secrets honour expires_at at read time (an
expired credential yields HTTP 409 "rotate this credential", not stale
plaintext), and minted agent-runner tokens auto-expire after
FORGE_AGENT_TOKEN_TTL (default 24h). A 15-minute sweep flags/purges expired
rows (secrets sweep-expired).
Rotate on a schedule and immediately after any suspected exposure or staff departure.
| Credential | How to rotate |
|---|---|
POSTGRES_PASSWORD |
ALTER ROLE forge WITH PASSWORD '...', update .env, recreate api/worker/mcp-gateway. |
MINIO_ROOT_PASSWORD |
Update .env, recreate minio, re-point clients. |
AUTH_SECRET |
Update .env, recreate api/web; existing sessions are invalidated. |
SECRET_KEY |
Rotate, then re-encrypt the BYOK vault with the new key; recreate api/worker. |
| Provider / BYOK keys | Replace in the per-workspace vault through the UI/API; old key stops being used immediately. |
| GitHub App / Slack secrets | Rotate at the provider, update .env, recreate dependent services. |
After any rotation, recreate the affected services so they pick up the new values:
docker compose -f deploy/docker-compose.yml up -d --force-recreate api workerThe order matters for SECRET_KEY (it keys the BYOK vault): rotate the key
and re-encrypt the vault in one maintenance window, or existing secrets become
undecryptable.
SECRET_KEY(FORGE_SECRET_KEY) — the vault master key.- Generate:
python -c 'import secrets; print(secrets.token_urlsafe(32))'. - Prefer the envelope path (F37): add the new key as a new version in
FORGE_VAULT_KEYS(e.g.2:<new-b64>,1:<old-b64>), set the new version as active, runSecretVault.rotateto re-wrap DEKs, then drop the old version oncerotatecompletes. Old versions keep decrypting until removed, so there is no downtime. - Recreate
api+worker. Verify a known BYOK secret still decrypts through the API before removing the old key version. - Never run production without
FORGE_SECRET_KEY— the API refuses to boot outsidedevelopmentrather than fall back to an ephemeral key.
- Generate:
AUTH_SECRET— recreateapi+web; all sessions are invalidated (users re-log-in). API keys are unaffected.- API keys / BYOK provider keys — rotate in the per-workspace vault via the UI/API; the old value stops being used immediately and the change is audited.
POSTGRES_PASSWORD/MINIO_ROOT_PASSWORD— change at the service, then.env, then recreate the dependent app containers.- Webhook signing secrets (GitHub/Slack/PM/alert providers) — rotate at the
provider, update
.env, recreate dependents. Until the new secret is set the webhook route fails closed (rejects deliveries) rather than trusting an unsigned payload.
After any rotation, confirm the health of the enforcement controls with
uv run pytest -m security (offline) against the deployed revision.
These operator knobs default to the safe value; tune only with intent.
| Env var | Default | Meaning |
|---|---|---|
FORGE_RATELIMIT_ENABLED |
true |
per-caller request rate limiting (429 + Retry-After) |
FORGE_RATELIMIT_RPM |
120 |
requests/minute per principal (or client IP) |
FORGE_RATELIMIT_BURST |
60 |
token-bucket burst allowance |
FORGE_MAX_BODY_BYTES |
1048576 |
max request body (1 MiB) before 413 |
FORGE_SSRF_ALLOW_PRIVATE |
false |
allow private/RFC1918 outbound targets (self-hosted embedder/reranker on an internal network) — loopback and the cloud metadata endpoint stay blocked even when true |
FORGE_OUTBOUND_ALLOWLIST |
[] |
JSON list of exact hostnames always permitted outbound |
FORGE_DOCS_ENABLED |
true |
OpenAPI docs; forced off when FORGE_ENVIRONMENT=production unless explicitly set |
Notes:
- The rate limiter is per-process (in-memory token bucket). In a
multi-replica deployment the effective limit is
RPM × replicas; treat it as a coarse per-instance guard and put a shared limiter / WAF at the edge for hard global limits./healthis always exempt so liveness probes are unaffected. - The SSRF guard is defense-in-depth, not a replacement for the
data/mcpnetwork segmentation below. If your embedder/reranker/MCP endpoints live on a private network, add their hostnames toFORGE_OUTBOUND_ALLOWLIST(narrow) or setFORGE_SSRF_ALLOW_PRIVATE=true(broad) — do not disable the guard. - Security headers (HSTS,
X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy, a deny-all CSP on JSON) are added at the API edge and mirrored for the web app inapps/web/next.config.
Every security control Forge claims is asserted on the wired request path by
a regression suite (uv run pytest -m security) and re-run on every CI push by
a blocking security job (SAST via bandit + custom semgrep rules, dependency
CVE audit via pip-audit/osv, secret scan via gitleaks, and a CycloneDX SBOM).
The full control list and the test that proves each is in
../security/evidence/enforcement-matrix.md;
the threat model is in ../security/threat-model.md.
Run the whole local audit with make security.
Accepted-risk waivers are dated, owned, and expire (an expired waiver fails
the gate closed) in ../../security/waivers.yaml;
the triage of every automated finding is in
../../SECURITY_FINDINGS.md.
Forge holds the API key you paste into the Connect dialog in browser storage.
Browser storage is scoped to an origin — scheme, host and port — and
http://localhost:8080 is not a private origin. It is the same origin as every
other local application that has ever been served from that port on this
machine, and those applications share one storage bucket.
We saw this concretely during a self-host evaluation: Forge's localStorage
already held app-a-state, app-b-lock and app-c-store keys
from unrelated local development. Nothing had gone wrong — that is simply what a
shared origin means. Anything Forge persists client-side is readable by those
apps, and theirs by Forge.
What Forge does about it:
- The key is held per-tab by default. The Connect dialog uses
sessionStorage, so the credential dies when the tab closes. "Remember on this browser" opts intolocalStorage; the dialog says what that means. - Every key Forge writes is namespaced under
forge.(forge.api.token,forge.board.savedViews, …), so Forge cannot silently collide with another app's state on a shared origin. Namespacing prevents collisions; it does not and cannot prevent reads.
What you should do about it:
- Prefer a real hostname over
localhost, even locally. Point a hostname at your machine (forge.localhost, an/etc/hostsentry, or a LAN name), setDOMAIN, and browse that. A distinct origin is a distinct storage bucket. - Do not tick "remember" for an admin key on a machine that runs other local web apps. A tab-scoped token is a much smaller window.
- Mint per-person, least-privilege keys for anything beyond evaluation, and
revoke the shared bootstrap key (
DELETE /auth/api-keys/{id}).
This is a property of browsers, not of Forge's deployment posture: the same
reasoning applies to any app you run on localhost.
The production compose file segments traffic into edge, backend, data, and
mcp networks, with data marked internal so Postgres, Redis, and MinIO are
unreachable from the edge. Preserve this posture:
- Only
caddyshould publish ports to the host (80/443). Do not add host port mappings todb,redis, orminio. - Terminate TLS at Caddy (automatic HTTPS via ../../deploy/caddy/Caddyfile).
Set a real
DOMAINso certificates are issued. - Put the host behind a firewall; expose only 80/443 (and your SSH port) to the internet.
- Keep app containers non-root (
user: "1000:1000", already set).
Forge ships safe-by-default controls. Operators should understand them rather than weaken them:
- MCP is read-only by default. Connections set
allow_write: false; enabling writes is an explicit, audited per-connection decision. The example connectors in ../../examples/mcp-connectors all keep writes disabled. - MCP token binding (RFC 8707). Tokens are bound to the specific MCP resource they were issued for, so a token cannot be replayed against another server.
- Repo policy is deny-by-default.
.forge/policy.yamlgoverns what an agent may touch; adenyglob beats anallowglob and unknown actions are denied. See the examples in ../../examples/policies. - RBAC. Roles are
admin,member,viewer, andagent-runner; viewers cannot perform writes and agent-runners are scoped to automated execution. - Per-workspace isolation. Tenant data is workspace-scoped; one workspace cannot read another's data, secrets, or retrieval index.
- Immutable audit log. Every agent action, tool call, MCP call, and approval is appended to an append-only audit log for after-the-fact review.
By default Forge executes a task's verification/build commands as host
subprocesses inside a git worktree (FORGE_SANDBOX_KIND=worktree). Worktrees
are an isolation convenience, not a security boundary: untrusted repo code
(tests, build scripts, transitive deps) runs with the worker's privileges. For
multi-tenant deployments, opt in to container isolation
(FORGE_SANDBOX_KIND=container) to run those commands inside a per-task,
locked-down Docker container.
- No raw Docker socket in the worker. The worker reaches the daemon only
through a
tecnativa/docker-socket-proxy(DOCKER_HOST=tcp://docker-proxy:2375) restricted to the minimal verbs (CONTAINERS/IMAGES/POST/EXEC/INFO) — no network create, no volume delete, no swarm, no privileged. The socket-mounting proxy is the single privileged surface (read-only, on theinternalsandbox_ctlnetwork), a documented exception alongsideautoheal. - Least privilege by default. Each sandbox runs with
cap_drop: ALL,no-new-privileges, a non-root uid/gid (10001), a read-only root fs + tmpfs scratch, and CPU/memory/PID limits. - No cross-task filesystem access. A container mounts only its run's
worktree subpath at
/workspace(volume-subpath, Docker Engine >= 26.1 —preflight.shgates this). No other task's worktree or host path is mounted. - No network by default.
FORGE_SANDBOX_NETWORK=none.egressmode routes the container's only path out through an allow-listing forward proxy (sandbox-proxy) on theinternalsandbox_egressnetwork, with a domain allowlist (FORGE_SANDBOX_EGRESS_ALLOWLIST). - Curated image allowlist. The sandbox image must be on
FORGE_SANDBOX_ALLOWED_IMAGES; a repo policy cannot point the sandbox at an arbitrary image. - Secrets never enter the sandbox. The BYOK model key and GitHub App
credentials are never placed in the sandbox environment or any persisted
sandbox_instancerow. - No silent downgrade. If
containeris requested but the daemon/proxy is unreachable, the run fails loudly — Forge never quietly executes untrusted code on the host when the operator asked for containment. - Orphan reaping. A
sandbox.reap_orphansbeat task and a worker-boot pass remove anyforge.sandbox=truecontainer whose run is terminal or older thanFORGE_SANDBOX_MAX_TTL_SECONDS.
Future hardening (V3): Sysbox/gVisor runtimes and Firecracker microVMs plug in behind the same
SandboxProviderseam — shipped as F34, below.
A Docker container (runc) still shares the host kernel: its isolation
rests on namespaces, cgroups and seccomp, so one kernel-level exploit escapes
every sandbox on the node. For genuinely untrusted, multi-tenant workloads F34
adds two kernel-boundary tiers behind the same SandboxProvider seam:
FORGE_SANDBOX_KIND |
Runtime | Isolation class | Boundary |
|---|---|---|---|
worktree |
— | host_process |
none (V1 default) |
container |
runc |
namespace |
shared host kernel |
gvisor |
runsc |
userspace_kernel |
gVisor application kernel services the guest's syscalls in userspace |
microvm |
kata-fc |
microvm |
Firecracker microVM with its own guest kernel (Kata Containers) |
- Monotonic isolation lattice.
worktree < container < gvisor < microvm.FORGE_SANDBOX_KINDis the workspace minimum; a repo's.forge/policy.yamlsandbox.isolationmay strengthen it but never weaken it. An operator mandate ofmicrovmcannot be undercut by a repo policy. - No silent downgrade — ever. If the resolved kind's runtime is not
registered (or
/dev/kvmis missing formicrovm), worker boot and the run fail loudly withSandboxRuntimeUnavailable;deploy/scripts/preflight.shgates the same conditions beforeup. Forge never quietly executes untrusted code under a weaker runtime than the operator asked for. - Enabling gVisor (no special hardware). Run
deploy/scripts/install-runtimes.sh --gvisoron the daemon host (installsrunsc, mergesruntimes.runscinto/etc/docker/daemon.json, restarts the daemon), then setFORGE_SANDBOX_KIND=gvisor. The gVisor platform is selectable viaFORGE_SANDBOX_GVISOR_PLATFORM(systrapdefault;kvmfastest but needs/dev/kvm;ptracemost compatible). - Enabling Firecracker microVMs (KVM required). On a bare-metal or
nested-virt-enabled host, run
install-runtimes.sh --firecracker(Kata Containers + Firecracker, registerskata-fc), verify/dev/kvm, then setFORGE_SANDBOX_KIND=microvm. Guest sizing defaults derive from the sandbox CPU/memory limits and can be overridden per repo (vm_vcpus,vm_memory) or workspace-wide (FORGE_SANDBOX_MICROVM_VCPUS/_MEMORY_MB). - All F19 controls still apply inside the stronger boundary — single
worktree mount,
cap_drop: ALL, non-root uid, read-only rootfs, limits, no-network default, egress allowlist proxy, curated images, output capping, orphan reaping. The kernel boundary is defense-in-depth on top, not a replacement. Runtime selection is part ofPOST /containers/create, so the socket-proxy verb set is unchanged and the worker still never touches the raw socket. - Auditable trust tier. Every run's
sandbox_instancerow records theruntime,isolation_class, and (for microVM) theguest_kernel_version, vCPU/RAM sizing and boot latency — auditors can query exactly which boundary contained each piece of untrusted execution. - Residual trust.
microvmshifts trust to KVM + the Firecracker VMM; on cloud VMs nested virtualization must be enabled. Prefer dedicated bare-metal sandbox nodes for the highest-untrust tenants, and gVisor as the no-KVM tier. On Kubernetes, isolate runtime-capable node pools via the chart'sRuntimeClassscheduling (see kubernetes.md). - VM artifact hygiene. The reaper sweeps any orphaned jailer chroot under
FORGE_SANDBOX_JAILER_ROOTonce a run's sandbox container is gone.
- SAML 2.0 (Forge as SP). Per-workspace federation with a corporate IdP
(Okta / Entra ID / Google Workspace / generic). Responses are validated with
mandatory XML-DSig signature checks (toolkit-delegated to
signxml— pure Python, no nativexmlsec1needed), audience restriction, bounded clock skew (FORGE_SAML_CLOCK_SKEW_SECONDS), one-timeInResponseToconsumption, and assertion-id replay caching. XML parsing is XXE-hardened (entities, DTD loading, and network access disabled; any DOCTYPE is rejected). - SCIM 2.0 provisioning.
/scim/v2/*authenticates with a per-workspace bearer token: CSPRNG-generated, stored only as a SHA-256 hash, constant-time compared, revocable, expirable, and revealed exactly once at mint time.active=false/DELETEdeprovisions the user and revokes their sessions and agent tokens immediately. - HTTPS is mandatory in production.
FORGE_PUBLIC_URLmust be the externally reachable HTTPS URL — the SP entity id, ACS URL, and SCIM base URL derive from it, and SAML requires TLS on the ACS. Caddy must proxyPOST /auth/saml/*/acsand/scim/v2/*with bodies untouched. - Break-glass. Disabling SSO (or deprovisioning the last active local admin) is refused while no non-SSO admin remains; keep at least one local admin so a misconfigured IdP can never lock the workspace out.
- No privilege escalation via IdP. Roles are honored only through the
admin-configured group→role map; arbitrary asserted
role=adminattributes are ignored. Every SSO/SCIM action lands in the immutable audit log with secrets redacted.
The public evaluation leaderboard (/public/leaderboard/...) is disabled by
default: every /public/* route returns 404 until you explicitly set
FORGE_PUBLIC_LEADERBOARD_ENABLED=true, so a fresh self-hosted instance never
accidentally publishes internal benchmark scores. Before opting in:
- Only submissions an admin explicitly published appear (moderation gate); flagged entries are removed from the board immediately.
- Responses are structurally payload-free: score breakdowns, model labels,
and submitter display names only.
submitter_contact, raw run configuration, and BYOK keys are not representable in the public response models, and submission configs are secret-redacted at ingest before they are ever stored. - The router is read-only (GET only), per-IP rate-limited
(
FORGE_LEADERBOARD_PUBLIC_RATE_LIMIT, default 60/min), and cache-fronted (FORGE_LEADERBOARD_CACHE_TTL_SECONDS). - Only verified entries carry the badge: verification deterministically
replays each submission's content-hashed bundles offline and rejects any
claimed score that does not reproduce within
FORGE_BENCHMARK_VERIFY_EPSILON.
- All four core secrets are unique 32-byte random values.
-
.envischmod 600and excluded from version control and backups. - An off-host copy of
SECRET_KEYexists, stored apart from data backups. - Only
caddypublishes host ports;datanetwork isinternal. -
DOMAINis set and Caddy has issued a valid certificate. - A credential rotation schedule is documented and owned.
- MCP connections that need writes are explicitly reviewed and audited.
- Backups are encrypted in transit and at rest off-host.
- The seed's bootstrap admin key is revoked once per-person keys exist.
- Operators browse a real hostname, not
localhost(see browser storage).
- docker-compose.md — deployment-level hardening.
- backup.md — protecting backup credentials.
- troubleshooting.md — auth and certificate failures.