Decisions and roadmap
ADR 0014: Exo Secure loads secrets from 1Password at boot; no secrets in platform env vars
- Status: proposed (draft PR; owner decision pending on the open items below)
- Date: 2026-10-21
- Decided by: Samir (owner), standing rule set 2026-10-21
- Relates to: ADR 0004 (Exo ID issuer and JWKS), ADR 0005 (EXO Verify), roadmap item "Exo Secure"
Context
On 2026-10-21 Railway disclosed that an unauthorized party accessed systems that store customer environment variables, obtained the decrypted contents of some of them, and appears to have been hunting for AI provider keys; Railway recommends rolling every variable and paused all deployments (Railway disclosure).
Every EXO secret that lives in a platform env store (Railway, Vercel, Convex) is exposed to a breach
of that platform, and an env var also leaks through crash reports, child processes, env dumps and
build logs. Separately, staging.exo.now proves the gateway to five upstream Apps (Exo, Moonshot,
Hadron, Observatory, Universe) with one shared STAGING_GATEWAY_SECRET in the
x-exo-gateway-secret header, so one leaked App env lets anyone impersonate the gateway to all of
them (security review item M1).
Standing rule from the owner: Exo Secure must never store secrets as platform env vars. The only exception is a single bootstrap token per app.
Decision
1. One read-only 1Password service account per app environment
- Each app environment gets its own vault and one read-only service account scoped to only that
vault:
Exo StagingandExo Productionfor EXO itself; every managed app follows the same pattern (Moonshot Staging, …). - The platform holds only
OP_SERVICE_ACCOUNT_TOKEN(the bootstrap token) plus non-secret config:EXO_SECURE_SOURCE=1passwordandEXO_SECURE_VAULT=<vault name>. - Secrets are referenced as
op://<vault>/<item>/<field>. References are not secret; they live in code (src/lib/exo-secure/manifest.ts), so the list of what an app needs is reviewed like code.
2. Resolve at boot into memory, never back into process.env
src/instrumentation.tsregister()callsloadSecrets(EXO_SECRET_MANIFEST)once, before the server takes traffic. It uses the official @1password/sdk (MIT, v0.5.0) with one batchedsecrets.resolveAllcall.- Values go into an in-memory store on
globalThis(a registered symbol, so the Next proxy bundle and route bundles share it). Server code reads them withsecret(name)/requireSecret(name). Nothing is written toprocess.env, so env dumps and child processes never carry them. - Fail closed in production. With
EXO_SECURE_SOURCE=1passwordin production, a missing token, an unreachable vault, or any missing required secret throws inregister(): the new deploy never becomes healthy and the previous one stays live. Staging and development log a warning instead. - After loading, the server warns (by name, never value) about any managed secret that is still also set as a platform variable, so leftovers get deleted.
- Migration mode.
EXO_SECURE_SOURCEdefaults toenv, which readsprocess.envexactly as today and warns in staging and production. Merging this ADR therefore changes no behaviour until an environment is switched to1password. - Local development uses either
op run --env-file=.env.op -- pnpm dev(resolved values exist only in that child process; defaultenvsource) orEXO_SECURE_SOURCE=filewith a gitignored.env.secrets.local(the.env.*rule already ignores it).fileis refused in production.
3. Bird and other Convex-only secrets: Convex env as the interim home
Bird SMS already runs only in Convex (convex/lib/bird.ts, convex/communications.ts
sendSms internal mutation and deliverSms internal action). No Next code reads BIRD_* or calls
the Bird API; tests/int/exo-secure-boundaries.int.spec.ts now fails if that ever changes. Both
functions are registered internal, so only Convex code (phone sign-in in convex/phoneAuth.ts, or
a deploy-key caller) can trigger a send; they need no entry in the public-write allowlist of
tests/int/convex-auth-guards.int.spec.ts. When Bird email arrives it follows the same shape.
Convex env is also a platform env store. Options:
| Option | What it means | Verdict |
|---|---|---|
| A. Convex env (interim) | Bird, Exo ID signing keys, OAuth client secrets, AI keys stay in the Convex deployment env | Recommended now |
| B. Convex pulls from 1Password | A "use node" internal action resolves from an Exo Convex vault and caches per isolate | Later, for the highest-value keys |
Why A now: the breach was Railway's, and Convex is a separate vendor and blast radius; Convex
functions run inside Convex, so these secrets never touch Railway or the Next server; and B still
needs an OP_SERVICE_ACCOUNT_TOKEN in Convex env (the bootstrap exception moves, it does not
disappear), adds a 1Password round trip to Node-action cold starts, cannot serve the default
(V8) runtime that most EXO functions use, and spends service-account rate limit (Teams: 1,000 reads
per hour per token; Business: 10,000;
rate limits). Revisit B for
EXO_ID_PRIVATE_KEY and AUTH_PRIVATE_KEY when Convex supports a secret-manager integration or
if Convex's posture changes. Run the new platform-env check against npx convex env list output
with an explicit allowlist to keep that store reviewed.
4. Per-app Exo Key gateway tokens replace STAGING_GATEWAY_SECRET (M1)
- The gateway asks Exo Key for a token per upstream App:
POST <exo key site>/exo-key/v1/gateway-token {"app": "<slug>"}withAuthorization: Bearer <EXO_GATEWAY_CLIENT_SECRET>(in theExo Stagingvault). Convex stores onlyEXO_GATEWAY_CLIENT_SECRET_SHA256, which is not a secret. The route answers 404 until that hash is set, so it ships dark. - The token is an RS256 JWT signed by the existing Exo ID issuer key (
convex/lib/gatewayToken.ts, samekidas access tokens):iss= the Exo ID issuer,aud=exo-gateway:<app>,sub=exo-gateway,token_use=gateway, randomjti, 5-minute lifetime. EXO caches one per App until a minute before expiry and forwards it inx-exo-gateway-token. - Each App verifies against the public JWKS (
<issuer origin>/exo-key/.well-known/jwks.json) withverifyGatewayToken(src/platform/delivery/gatewayTokens.ts). A token minted for one App fails at every other App, and an ordinary Exo ID access token is never accepted (notoken_use). Apps hold no gateway secret at all. - Flag
EXO_GATEWAY_TOKENSon the EXO service:off(default; shared header only, today's behaviour),dual(both headers),only(token only; a missing token fails closed with 503). Caller-supplied copies of either header are always stripped.
Per-app migration (each step is reversible by flipping the flag back):
- Generate the gateway client secret (48+ random bytes) into
Exo Staging/staging-gateway/exo-key-client-secret; set its SHA-256 (base64url) asEXO_GATEWAY_CLIENT_SECRET_SHA256in the staging Convex deployment. - Move the current shared value into
Exo Staging/staging-gateway/legacy-shared-secret, switch the EXO staging service toEXO_SECURE_SOURCE=1password, deleteSTAGING_GATEWAY_SECRETfrom it. - Set
EXO_GATEWAY_TOKENS=dualon EXO staging. Verify with a request through staging.exo.now/{app} that both headers reach the upstream. - For each App (Moonshot, Hadron, Observatory, Universe; then Construct, Rampart, Cosmograph,
Vector, Dropship as they adopt the gateway): ship
verifyGatewayRequestwithacceptLegacy: true, deploy, confirm requests verify astoken; then setacceptLegacy: falseand deleteSTAGING_GATEWAY_SECRETfrom that App's service. - When every App is on tokens only, set
EXO_GATEWAY_TOKENS=only, delete the legacy item from the vault, and remove the legacy code path.
5. EXO Verify enforces it
exo-verify security gains a built-in platform-env check
(packages/exo-verify/lib/level1/platform-env.mjs, plain Node, no git, no network). Given a
variables export (railway variables --json, a [{name,value}] list, or dotenv; file path or
stdin) it fails on values that look like secrets: known prefixes (sk-, sk_live_, ghp_,
github_pat_, xox[bpas]-, re_, bk_, AKIA, ops_ other than OP_SERVICE_ACCOUNT_TOKEN,
Stripe whsec_, Google AIza, Convex deploy keys), JWT and PEM shapes, JSON carrying private JWK
members, high-entropy strings, and names ending _KEY, _SECRET, _TOKEN, _PASSWORD. An
allowlist (OP_SERVICE_ACCOUNT_TOKEN plus explicit non-secret config) is the only escape; op://
references and Railway ${{ }} references pass. Findings name the variable and the reason, never
the value. No export provided means a skip (advisory), so builds without one stay green. Standalone:
pnpm verify:platform-env <file|->.
Threat model
| Threat | Before | After |
|---|---|---|
| Platform env store breach (Railway, 2026-10-21) | Every secret on the service leaks | Only OP_SERVICE_ACCOUNT_TOKEN leaks: read-only, one vault, revocable in one click |
| Env dump, crash report, child process inherits env | Secrets included | Secrets not in process.env |
| One App's env leaks the gateway secret | Impersonate the gateway to all five Apps | No App holds a gateway secret; a token is good for one App for at most 5 minutes |
| Stolen gateway token | n/a | Valid only for its App, expires in 5 minutes |
| Stolen bootstrap token | n/a | Attacker can read that one vault until revoked; it cannot write or see other vaults. Detect via 1Password activity log and the service account's usage report |
| 1Password outage at boot | n/a | Production deploy fails closed; the running deploy keeps serving from memory |
| Code exfiltrates memory (RCE in the server) | Secrets readable | Still readable: Exo Secure does not defend against code execution in the app |
Rotation
- Rotate a secret by editing its 1Password item, then restarting (redeploying) the app; no platform change is needed. Restart is required because values are read once at boot.
- Rotate the bootstrap token by creating a new service-account token, setting it on the platform, redeploying, then revoking the old one.
- Gateway tokens rotate themselves every 5 minutes. Rotating the signing key follows the existing
Exo ID JWKS procedure (publish the new key first; Apps fetch JWKS by
kid). - The 2026-10-21 Railway advice to roll every variable is a separate, owner-approved action; this ADR does not rotate or revoke anything.
Blast radius
One service account per app environment, read-only, one vault. A leak of Exo Staging's token
exposes only EXO staging's few secrets, never production, never another app. Today a Railway leak
exposes every variable on every service in the project.
Cost and unit economics
- 1Password service accounts come with the 1Password plan; there is no per-service-account fee in the plan Exo already uses for people (owner brief, 2026-10-21; the roadmap's 2026-10-11 survey lists Business at $8.99 per user per month). Not re-verified today: 1Password's Business pricing page did not render for the check.
- Rate limits are the real constraint (1Password rate limits): Teams 1,000 reads per hour per token and 5,000 requests per day per account; Business 10,000 reads per hour per token and 50,000 per day per account. Exo Secure makes one batched read per boot, so even 100 deploys and restarts a day across every app is far inside Teams limits.
- Cost per unit: $0 marginal per app, per deploy and per request. Gateway tokens cost one Convex HTTP action per App every ~4 minutes while traffic flows (about 360 calls per App per day), well inside the Convex plan's included function calls. The platform-env check adds well under a second to the gate.
- Margin note: Exo Secure is not a cost driver, so it is not a pricing metric; include it in every plan, unmetered. If Exo later hosts vaults for customers, cost scales per human seat on 1Password, so price per seat, never per app or per secret.
Migration table
Every secret-shaped variable the exo repo reads today (grep of process.env and app.env across
src/, convex/, packages/, scripts/, plus .env.example), with its destination. Owner is
Samir (Interspace) for every row unless noted; the provider column is who issues the value.
| Variable | Read by (today) | Current location | Destination | Owner / issuer |
|---|---|---|---|---|
TWENTY_API_KEY | src/platform/crm/twenty.ts (Next) | Railway env (EXO service) | 1Password app vault twenty-crm/credential; read via secret() | Samir / Twenty |
STAGING_GATEWAY_SECRET | src/platform/delivery/stagingGateway.ts (Next proxy); each upstream App | Railway env on EXO staging and on Moonshot, Hadron, Observatory, Universe | 1Password Exo Staging/staging-gateway/legacy-shared-secret, then retired (section 4) | Samir |
EXO_GATEWAY_CLIENT_SECRET (new) | src/platform/delivery/gatewayTokens.ts | none | 1Password Exo Staging/staging-gateway/exo-key-client-secret | Samir |
EXO_GATEWAY_CLIENT_SECRET_SHA256 (new) | convex/lib/gatewayToken.ts | none | Convex env (not a secret: a hash) | Samir |
OP_SERVICE_ACCOUNT_TOKEN (new) | src/lib/exo-secure | none | Railway env: the one bootstrap exception | Samir / 1Password |
BIRD_API_KEY | convex/lib/bird.ts | Convex env | Convex env (interim, section 3); never Railway | Samir / Bird |
EXO_ID_PRIVATE_KEY | convex/lib/exoIdCrypto.ts | Convex env | Convex env (interim; candidate for option B) | Samir |
AUTH_PRIVATE_KEY | convex/convex.config.ts (Convex Auth) | Convex env | Convex env (interim; candidate for option B) | Samir |
AUTH_GOOGLE_CLIENT_SECRET | convex/convex.config.ts | Convex env | Convex env (interim) | Samir / Google Cloud |
AUTH_GITHUB_CLIENT_SECRET | convex/convex.config.ts | Convex env | Convex env (interim) | Samir / GitHub |
ARCH_ROUTER_KEY | convex/ai/assessors.ts | Convex env | Convex env (interim) | Samir |
ARTIFICIAL_ANALYSIS_API_KEY | convex/ai/benchmarks.ts | Convex env | Convex env (interim) | Samir / Artificial Analysis |
EXO_AI_BROKER_TOKEN | convex/aiOpenAICompat.ts, convex/lib/callerAuth.ts (legacy path); packages/exo-verify review | Convex env; CI env for review | Convex env (interim); retire the legacy shared token in favour of Exo Key tokens | Samir |
EXO_INTAKE_SECRET_UNIVERSE / _COSMOGRAPH / _OBSERVATORY | convex/lib/intakeProtocol.ts | Convex env; each App's own env | Convex env (interim); App side moves to that App's 1Password vault | Samir |
EXO_VERIFY_TOKEN | convex/verifyHttp.ts; packages/exo-verify report posting; Dockerfile build ARG | Convex env; Railway build variable | Convex env (verifier side). Build side: open item (a build-time token cannot come from a runtime vault); replace with a short-lived Exo Key token | Samir |
EXO_STATE_MIGRATION_TOKEN | scripts/*-exo-state-staging.ts (operator) | operator shell | op run from the operator's 1Password; never a platform variable | Samir |
RESEND_API_KEY, CRON_SECRET, ENTIRE_API_KEY, ENTIRE_WEBHOOK_SECRET, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, GITHUB_TOKEN, RAILWAY_API_TOKEN, TYPESAFE_API_KEY | not read by code today (listed in .env.example only) | possibly Railway env (unverified) | Delete from Railway if present; any future use goes in the 1Password app vault via the manifest | Samir |
AUTH_GOOGLE_CLIENT_ID, AUTH_GITHUB_CLIENT_ID, AUTH_JWKS, EXO_ID_JWKS | Convex | Convex env | not a secret (public); keep EXO_ID_JWKS public-only (the check flags private JWK members) | Samir |
EXO_CONVEX_URL, NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_SERVER_URL, EXO_CONVEX_SITE_URL, CONVEX_SITE_URL, EXO_KEY_SITE_URL, EXO_ID_ISSUER, EXO_ID_AUTHORIZE_URL, EXO_KEY_DEVICE_URL, EXO_DOCS_URL, ARCH_ROUTER_URL, TWENTY_API_URL, BIRD_API_HOST | various | platform env | not a secret (URLs) | n/a |
*_STAGING_ORIGIN (9), EXO_STAGING_HOSTS, EXO_CONTROL_PLANE_HOSTS, EXO_DEPLOYMENT_ENVIRONMENT, EXO_DELIVERY_STAGE, EXO_IDENTITY_PLANE, EXO_SERVICE_PROVISIONING_ENABLED, EXO_WORK_REGISTER_ENVIRONMENT, EXO_MCP_ALLOWED_ORIGINS, EXO_PROTECTED_RESOURCES, EXO_AI_MODEL, EXO_AI_ASSESSOR, TWENTY_INTERSPACE_OPPORTUNITY_STAGE, BIRD_SMS_FROM, EMAIL_*, DROPSHIP_RAILWAY_*, EXO_SECURE_SOURCE, EXO_SECURE_VAULT, EXO_GATEWAY_TOKENS | various | platform env | not a secret (config and flags) | n/a |
EXO_STAGING_GRANTS | src/proxy.ts | Railway env | not a credential (user ids and scopes); keep as config, review as access policy | Samir |
Consequences
- EXO's Railway service ends with one secret-shaped variable. Every other managed app adopts the same
module (copy
src/lib/exo-secure) and the same gate check. - A boot now depends on 1Password being reachable; a failed deploy stays on the previous version.
- Secrets change only on restart. Hot reload of secrets is out of scope.
- Open items for the owner: create the vaults and service accounts; choose Teams or Business (rate
limits); approve option A for Convex; decide how the build-time
EXO_VERIFY_TOKENis replaced.