Keys & encryption
Spanlens uses two kinds of keys. Your Spanlens key (sl_live_…) goes in your app's env. Your provider keys (the real OpenAI / Anthropic / Gemini keys) are registered separately on the dashboard and stored encrypted with AES-256-GCM. The proxy decrypts a provider key in memory for the duration of a single fetch(), then drops it. The real key is never logged, never returned by any API, never displayed again after registration.
Why this layer exists
Your client code authenticates to Spanlens with the Spanlens key only. The actual provider key that OpenAI / Anthropic / Gemini expect is swapped in server-side from our encrypted vault, scoped to the Spanlens key the client presented.
This buys you two things:
- Your real keys never ship to the client. Frontend code, mobile apps, anywhere, none of them need the sensitive provider key. They only need your revocable Spanlens key.
- Centralized rotation. Replace the underlying provider key with the pencil icon on its card in /projects, all your services pick it up next request. Your
sl_live_…stays the same. No redeploys.
The two-key model
Each Spanlens keyowns its own pool of provider keys. So two Spanlens keys in the same project can carry different OpenAI / Anthropic / Gemini credentials, useful for dev/prod splits or per-team accounting where the same provider key shouldn't be shared.
- A Spanlens key with no provider keys registered will accept calls but return
400 No active provider key registered for this Spanlens keyat the proxy layer. - A Spanlens key with provider keys for OpenAI + Anthropic but not Gemini will route OpenAI and Anthropic calls correctly and reject Gemini calls with the same 400.
- Provider keys are uniquely active per
(spanlens_key, provider), only one OpenAI key per Spanlens key can be active at a time. Add a second OpenAI key when you want to rotate, then deactivate the old one.
How the encryption works
Storage flow
- You add a provider key to a Spanlens key in /projects, click + Add provider key next to the Spanlens key, pick the provider, paste your real
sk-…/sk-ant-…/AIza…key - Server reads
ENCRYPTION_KEYfrom env (32 bytes, base64-encoded) - Generates a fresh 12-byte IV (nonce) per key
- AES-256-GCM encrypts the plaintext under the master key with that IV
- Stores
iv || ciphertext || auth_tag(concatenated) in theprovider_keystable as base64, with aapi_key_idFK pointing at the parent Spanlens key - Plaintext is discarded from memory
Decryption flow (on every proxy request)
- Your request arrives at
/proxy/{openai|anthropic|gemini|azure}/…carrying the Spanlens key in whichever transport the SDK uses (see Direct proxy for the per-SDK mapping) - Server hashes the Spanlens key with SHA-256 and looks it up in
api_keys→ resolvesapiKeyId - Provider is inferred from the URL path:
/proxy/openai/…→ OpenAI,/proxy/azure/…→ Azure OpenAI, etc. Azure rows additionally carry aprovider_metadata.resource_urlso the proxy knows which Azure endpoint to forward to. - Loads the active
provider_keysrow for(apiKeyId, provider) WHERE is_active = true - Decrypts with
aes256Decrypt(ENCRYPTION_KEY, iv, ciphertext, authTag) - Sets the upstream auth header (
Authorization: Bearerfor OpenAI,x-api-keyfor Anthropic,?key=for Gemini) on the forwarded request - Plaintext lives in a local
constfor the duration of thefetch()call, then goes out of scope
Why AES-256-GCM, not just AES-256-CBC
- Authenticated.GCM produces a 16-byte tag that verifies the ciphertext wasn't tampered with. CBC has no built-in integrity check.
- Nonce-misuse awareness.One fresh IV per key ensures no two ciphertexts share a keystream. (Reusing an IV with GCM is catastrophic, we don't.)
- Industry-standard for “encrypt at rest”. NIST, OWASP, and every major provider converge on this.
Where ENCRYPTION_KEY lives
- Cloud (spanlens.io): in Vercel environment variables, generated at org setup, never displayed, never logged, never shipped to the web bundle
- Self-host: you generate it yourself (
openssl rand -base64 32) and set it on the container. Back it up.Losing the encryption key makes every stored provider key unrecoverable, you'd need to re-register them all.
Using it
Dashboard
Open /projects. The flow is two steps, issue a Spanlens key, then attach provider keys to it.
- On the project card click + New Spanlens key → enter a name (e.g. “prod-backend”) → the dialog returns the
sl_live_…value once. Copy it immediately. - The new Spanlens key now appears as a section. Click + Add provider key on that section to attach an OpenAI / Anthropic / Gemini key. Repeat per provider.
- After saving, the dialog flips to a success view showing the exact integration snippet for that provider,
createOpenAI()/createAnthropic()/createGemini(). Copy it into your codebase. No CLI re-run needed; the sameSPANLENS_API_KEYalready covers the new provider.
Rotating a provider key
Each provider key row has a pencil icon. Click it, paste the new sk-… / sk-ant-… / AIza… value, save. Your sl_live_… Spanlens key and all deployed code stay unchanged, Spanlens silently swaps the underlying credential on the next request.
Deactivating a provider key
The trash icon next to a provider key flips is_active = false right away and queues a hard delete for 72 hours later. Subsequent requests for that provider on that Spanlens key return 400 No active provider key until you add a new one. Restore the row from Pending deletions if you mis-clicked — see Restoring an accidental deletion.
Deleting a Spanlens key
The trash icon next to a Spanlens key flips is_active = false immediately (so apps using that key start failing with 401 right away) and queues the hard delete for 72 hours later. Provider keys attached to the key stay around for that window too. Cron runs every six hours and finalises any expired rows; restore is possible until then.
API
# ── Spanlens keys (provider-agnostic, project-scoped) ──────────
GET /api/v1/api-keys?projectId=<uuid>
POST /api/v1/api-keys/issue { "name": "prod-backend", "projectId": "<uuid>" }
# → { "id": ..., "key": "sl_live_..." } ← shown ONCE
PATCH /api/v1/api-keys/:id { "is_active": false } # toggle
DELETE /api/v1/api-keys/:id # is_active=false + 72h delete queue
# ── Provider keys (under a specific Spanlens key) ──────────────
GET /api/v1/provider-keys?apiKeyId=<spanlens-key-uuid>
POST /api/v1/provider-keys { "api_key_id": "<uuid>", "provider": "openai",
"key": "sk-...", "name": "prod-openai" }
PATCH /api/v1/provider-keys/:id { "key": "sk-rotated..." } # rotate
PATCH /api/v1/provider-keys/:id { "name": "renamed" } # rename
DELETE /api/v1/provider-keys/:id # is_active=false + 72h delete queue
# ── Pending deletions queue (restore within 72h) ───────────────
GET /api/v1/pending-deletions # active queue
GET /api/v1/pending-deletions/history # terminal rows
POST /api/v1/pending-deletions/:id/restore # cancel + reactivatebashSecurity guarantees
- Not in logs. Provider keys are never
console.log()'d, never stored in therequeststable, never returned from any API. - Not in the web bundle. The dashboard talks to the API server; it never receives provider key plaintext.
- Database compromise alone is insufficient. Without
ENCRYPTION_KEY, theprovider_keysciphertext is useless.ENCRYPTION_KEYlives outside the DB (env var). - Audit trail. Every decrypt-and-forward operation is logged (rate, timestamp, org, which Spanlens key) without the plaintext for forensics.
Limitations
- No envelope encryption with per-org DEK yet. All orgs share the same master
ENCRYPTION_KEY. Per-org data encryption keys (envelope encryption) + KMS integration is on the Enterprise roadmap. - No HSM support. Keys live in process memory during decryption. HSM offload is an Enterprise path (Phase 5+).
- No automatic rotation. Provider key rotation is manual (pencil icon or the
PATCH /api/v1/provider-keys/:idendpoint). Scheduled rotation is deferred.
Related: Projects & API keys, Self-hosting (ENCRYPTION_KEY setup), /projects dashboard. Source: apps/server/src/lib/crypto.ts, apps/server/src/api/providerKeys.ts.