# Exo Secure

https://exo.now/docs/platform/exo-secure

> Secrets load from each app's own 1Password vault at boot; platform env vars hold only one bootstrap token.



Source: [ADR 0014](https://exo.now/docs/adr/0014-exo-secure-secrets-from-1password), proposed on 2026-10-21 after
Railway's [security disclosure](https://blog.railway.com/p/security-disclosure-wednesday-october-21st).

## The rule [#the-rule]

Exo Secure never stores secrets as platform env vars. Railway, Vercel and other hosts hold one
bootstrap token per app (`OP_SERVICE_ACCOUNT_TOKEN`) and non-secret config. Everything else comes
from the app's own 1Password vault when the server starts.

## How it works [#how-it-works]

* **One vault per app environment.** EXO uses `Exo Staging` and `Exo Production`. Each has one
  read-only 1Password service account that can see only that vault.
* **References, not values.** Code lists what it needs as `op://<vault>/<item>/<field>` in
  `src/lib/exo-secure/manifest.ts`. References are safe to commit.
* **Loaded once, kept in memory.** At boot, `loadSecrets()` resolves every reference with the
  official 1Password SDK and keeps the values in memory. They are never written back to the
  environment. Server code reads them with `secret(name)`.
* **Fails closed in Production.** If the token, the vault or a required secret is missing, the new
  deploy refuses to start and the previous one keeps serving.
* **Local development** uses `op run -- pnpm dev` or a gitignored `.env.secrets.local` with
  `EXO_SECURE_SOURCE=file`.

## Turning it on for an app [#turning-it-on-for-an-app]

1. Create the vault and a read-only service account scoped to it.
2. Copy each secret into the vault under the item and field the manifest names.
3. On the host, set `OP_SERVICE_ACCOUNT_TOKEN`, `EXO_SECURE_SOURCE=1password` and
   `EXO_SECURE_VAULT=<vault name>`.
4. Redeploy, confirm the boot log says the secrets loaded from 1Password, then delete the old
   variables. The server warns by name about any that are still set.

## Exo Key gateway tokens [#exo-key-gateway-tokens]

Apps behind `staging.exo.now` used to trust one shared header secret. Exo Key now signs a short-lived
token for each app (`aud = exo-gateway:<app>`, five minutes), and each app checks it against the
public Exo Key keys. Apps hold no gateway secret, and a token for one app does nothing at another.
The `EXO_GATEWAY_TOKENS` flag (`off`, `dual`, `only`) runs old and new side by side while each app
moves over.

## Checked on every build [#checked-on-every-build]

EXO Verify's `security` command includes a `platform-env` check. Give it an export of the host's
variables (for example `railway variables --json`) and it fails when any value looks like a raw
secret, naming the variable but never printing the value. Without an export it skips.

```bash
railway variables --json | pnpm verify:platform-env -
```

## Where secrets live [#where-secrets-live]

| Kind                                                                            | Home                                                 |
| ------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Next server secrets (Twenty, gateway)                                           | The app's 1Password vault                            |
| Convex-only secrets (Bird, Exo Key signing keys, OAuth client secrets, AI keys) | Convex deployment env, for now (ADR 0014, section 3) |
| URLs, hosts, flags                                                              | Plain env; not secrets                               |

## Cost [#cost]

1Password service accounts come with the plan. Exo Secure makes one batched read per boot, so it
costs nothing per app, deploy or request and stays well inside 1Password's
[rate limits](https://developer.1password.com/docs/service-accounts/rate-limits/). It is included in
every plan, unmetered.
