Secrets Management
1. No secret in code, ever. All secrets via env vars or secret stores.
Where every secret lives, who has access, how it gets there, and how it gets rotated.
Principles
- No secret in code, ever. All secrets via env vars or secret stores.
- No secret in logs. Sentry breadcrumbs, console output, error messages — scrubbed.
- Service-role keys never leave the server. Browser code only ever sees the anon Supabase key.
- User exchange keys are encrypted at rest with an envelope key the user does not hold.
- Decryption happens in one place (the live runner process), not on the web edge.
- Rotate-able — every secret can be rotated without code changes.
Inventory
| Secret | Type | Stored in | Used by | Rotation |
|---|---|---|---|---|
| Supabase anon key | Public | Vercel env (NEXT_PUBLIC_*) | Browser, Next.js client | n/a |
| Supabase service role key | Server-only | Vercel env, Fly secret | apps/web server, apps/live-runner | 90 days |
| Supabase Vault: credential encryption key | Server-only | Supabase Vault | apps/live-runner (decrypt only) | Yearly + on incident |
| AI Gateway key (platform) | Server-only | Vercel env, Fly secret | Anywhere generateText is called | 90 days |
| AI Gateway BYOK (user-provided) | Per-user, server-only | DB (encrypted) | Forwarded to gateway per-request | User-controlled |
| Fly Machines API token | Server-only | Vercel env | apps/web provisioning code | 180 days |
| User exchange API keys | Per-user | DB exchange_credentials (encrypted) | apps/live-runner only (decrypted on machine boot) | User-controlled |
| News provider API key (CryptoPanic etc) | Server-only | Vercel env | packages/data-ingest | 180 days |
| Sentry DSN | Mostly-public | Vercel env | All processes | n/a |
| GitHub Actions secrets | CI-only | GH repo secrets | CI workflows | 180 days |
| Supabase DB password (direct conn) | Server-only | Vercel env, Fly secret | Migration runner only | 90 days |
Where things live
Vercel environment variables
Configured per Vercel environment (Development / Preview / Production) via vercel env CLI or the dashboard. Auto-injected at build + runtime for Vercel Functions.
Naming:
NEXT_PUBLIC_*→ safe to expose to browser- Everything else → server-only; never imported in client components
When linked, Supabase via Marketplace auto-provisions:
SUPABASE_URLSUPABASE_ANON_KEY(→ public)SUPABASE_SERVICE_ROLE_KEY- DB connection string
Fly secrets
Set per Fly app via fly secrets set KEY=value. Encrypted at rest by Fly, exposed as env vars to the running process.
For the live runner, set:
SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEYAI_GATEWAY_API_KEYVAULT_KEY_ID(the ID of the Supabase Vault key for credential decryption)
The DEPLOYMENT_ID is per-machine and passed via the Fly Machines API request, not a secret but a parameter.
Supabase Vault
Postgres-native secret store. We use it for the symmetric key that encrypts user exchange credentials.
-- One-time setup
select vault.create_secret('<base64-encoded-256-bit-key>', 'credential_envelope_key');Access from the runner via the vault.decrypted_secrets view (service-role only). The Vault key itself is never logged.
User-supplied secrets in the DB
exchange_credentials.ciphertext and .nonce columns store AES-256-GCM ciphertext of the user's exchange API key. The key never appears in plaintext in the DB; we never log it; we never return it to the browser.
The user can:
- Add a credential (the UI POSTs the plaintext to a server action, which encrypts and stores)
- Delete a credential
- See metadata (label, wallet address, created_at, last_used_at)
The user cannot:
- Retrieve the plaintext after saving (must rotate to change)
Encryption / decryption flow for exchange credentials
Encryption (when user adds credential)
- UI form collects plaintext API key over HTTPS
- POST to
/api/credentialsserver action - Server fetches
credential_envelope_keyfrom Vault - Server generates random 12-byte nonce
- Encrypts with
AES-256-GCM(key, nonce, plaintext) - Inserts
exchange_credentialsrow with ciphertext + nonce - Discards plaintext from memory
Decryption (when live runner boots)
- Live runner boots, reads
DEPLOYMENT_ID - Loads
deploymentsrow → findsuser_idand target exchange/network - Loads matching
exchange_credentialsrow (ciphertext + nonce) - Fetches
credential_envelope_keyfrom Vault - Decrypts:
AES-256-GCM-decrypt(key, nonce, ciphertext) - Passes plaintext into broker adapter constructor
- Plaintext is held in the broker's closure; never written to DB, logs, or returned over network
- On process exit, garbage collected with the process
If the live runner machine is compromised, the attacker gets access to one user's exchange key (the one this machine was provisioned for). Per-machine isolation means a compromise is bounded.
Rotation procedures
Supabase service role key
- In Supabase dashboard, generate new service role key
- Update Vercel env via
vercel env add SUPABASE_SERVICE_ROLE_KEY production - Trigger Vercel redeploy
- Update Fly secret via
fly secrets set SUPABASE_SERVICE_ROLE_KEY=... -a agentic-live-runner - Fly auto-restarts machines with new secret
- Confirm old key revoked in Supabase dashboard
AI Gateway key
Same pattern — generate in Vercel dashboard, propagate to Vercel + Fly, restart, revoke.
Vault envelope key (re-encryption)
The hard one. To rotate:
- Generate new key, store in Vault under a new ID (e.g.,
credential_envelope_key_v2) - Run re-encryption job:
- For each
exchange_credentialsrow: decrypt with old key, encrypt with new key, update ciphertext + nonce + key_version column
- For each
- Once 100% migrated, retire old key in Vault
This is operationally heavy; we only do it on a confirmed compromise or annually. Plan: add a key_version column from the start to make this possible without schema migration.
User exchange API keys
User-driven via the UI:
- User generates new key on Hyperliquid
- User adds new credential in the platform
- User updates affected deployments to point at the new credential
- User deletes old credential
- User revokes old key on Hyperliquid
We surface a "last used" timestamp so users can confirm a credential isn't in use before deleting.
What goes in .env.example
Every secret has a placeholder in .env.example so a new dev can see what they need. Format:
# Supabase (link via Vercel Marketplace, or copy from Supabase dashboard)
SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
SUPABASE_ANON_KEY=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY= # NEVER commit; NEVER expose to client
# AI Gateway
AI_GATEWAY_API_KEY= # from Vercel dashboard → AI Gateway → API Keys
# Vault key id for credential decryption
VAULT_KEY_ID=credential_envelope_key
# Fly (for provisioning live deployments from web app)
FLY_API_TOKEN=
FLY_APP_NAME=agentic-live-runner
# Hyperliquid mainnet (platform-level; user trading keys are stored encrypted in DB)
# none — users supply their own. Testnet deprecated (ADR-0015).
# News provider
CRYPTOPANIC_API_KEY=
# Sentry (Phase 3)
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=.env files are gitignored; .env.example is committed.
Logging hygiene
The logger (one shared instance across packages) has a redaction list:
const REDACT_KEYS = [
'api_key', 'apiKey', 'apiSecret', 'private_key', 'wallet_private_key',
'password', 'authorization', 'cookie', 'secret', 'token', 'envelope_key',
];Any object logged at any level has these keys recursively redacted. Tests in packages/logger/__tests__/redaction.test.ts cover the common cases.
Additionally: never log full request/response bodies for endpoints that handle credentials. Use field-level extraction.
Sentry / error monitoring scrubbing
When we add Sentry (Phase 3), configure:
beforeSendhook to drop events containing any redact-list keys- Disable breadcrumbs for the credentials-handling routes
- Set
sendDefaultPii: false
What to do if a secret leaks
- Rotate immediately (procedure above for the affected secret)
- Audit — git history check for accidental commits, log grep for accidental logs
- Investigate scope — what data could have been accessed with the leaked secret?
- Notify — if user data was at risk, contact affected users
- Postmortem — document in
docs/incidents/with root cause and prevention
For user exchange keys specifically: the user must rotate on the exchange side; we can only invalidate our copy.