Provider keys

Your actual OpenAI / Anthropic / Gemini keys live in /settings. We store them encrypted with AES-256-GCM and only decrypt them in memory, for a fraction of a second, when forwarding your request to the upstream provider. They are never logged, never exposed through an API, never displayed back to you after creation.

Why this layer exists

Your client code sends requests to Spanlens using a Spanlens API key (sl_live_...). The actual provider key that OpenAI / Anthropic / Gemini expect is swapped in server-side from our encrypted vault.

This buys you two things:

  1. Your real keys never ship to the client. Frontend code, mobile apps, anywhere — none of them need the sensitive key. They only need your revocable Spanlens key.
  2. Centralized rotation. Replace a provider key in one place, all your services pick it up next request.

How the encryption works

Registration flow

  1. You paste your provider key into /settings
  2. Server reads ENCRYPTION_KEY from env (32 bytes, base64-encoded)
  3. Generates a fresh 12-byte IV (nonce) per key
  4. AES-256-GCM encrypts the plaintext under the master key with that IV
  5. Stores iv || ciphertext || auth_tag (concatenated) in the provider_keys table as base64
  6. Plaintext is discarded from memory

Decryption flow (on every proxy request)

  1. Your request arrives at /proxy/openai/v1/... with Spanlens API key
  2. Server authenticates the Spanlens API key → resolves org
  3. Loads the org's encrypted provider key for this provider
  4. Decrypts with aes256Decrypt(ENCRYPTION_KEY, iv, ciphertext, authTag)
  5. Sets Authorization: Bearer <plaintext> on the forwarded request
  6. Plaintext lives in a local const for the duration of the fetch()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

Go to /settings. For each provider you want to use:

  1. Click “Add key” under the provider
  2. Paste your actual sk-... / sk-ant-... / AIza... key
  3. Save

The UI confirms the key is registered (you'll see masked prefix like sk-...a1b2) but never shows it in full again.

Rotation

To rotate: add the new key first (it becomes active immediately), then delete the old one. No downtime, no code change.

API

# Register
POST /api/v1/provider-keys
{ "provider": "openai", "key": "sk-..." }

# List (returns masked prefixes only, never plaintext)
GET /api/v1/provider-keys

# Delete
DELETE /api/v1/provider-keys/:id
bash

Security guarantees

  • Not in logs. Provider keys are never console.log()'d, never stored in the requests table, never exposed via an API. Static scan in CI enforces no string matching sk- in log output.
  • Not in the web bundle. The dashboard talks to the API server; it never receives provider keys.
  • Database compromise alone is insufficient. Without ENCRYPTION_KEY, the provider_keys ciphertext is useless.ENCRYPTION_KEY lives outside the DB (env var).
  • Audit trail. Every decrypt-and-forward operation is logged (rate, timestamp, org) without the plaintext for forensics.

Overage billing controls (Pattern C)

Paid plans (Starter / Team) show an Overage billing card below Organization with two controls:

  • Allow overage charges — when on (default), requests past your monthly quota keep flowing and are billed on your next invoice at the plan's overage rate. When off, requests past the quota return HTTP 429 immediately (Pattern A / legacy behavior).
  • Max overage multiplier (1–100, default 5) — defines a hard cap. Even with overage enabled, requests past limit × multiplier are rejected. Protects against runaway usage spikes. Example: Starter 100K × 5 = 500K hard cap; past that, requests return 429 regardless of overage setting.

Free plan hides these controls (quota is always a hard block). Enterprise is unlimited so the whole section is hidden.

Each change takes effect on the next proxy request — no restart or cache-bust needed. The hourly quota-warning email picks up the current setting too: at 100% with overage enabled, the email tells you overage billing is active instead of reporting a block.

See Billing & quotas for the complete pricing picture (overage rates per plan, invoice format, FAQ).

Limitations

  • One active key per (org, provider). If your OpenAI account has multiple keys for separate billing, Spanlens uses whichever is registered last. Multi-key routing is a future feature.
  • 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+).

Related: Projects & API keys, Self-hosting (ENCRYPTION_KEY setup), /settings dashboard. Source: apps/server/src/lib/crypto.ts.