Projects, Spanlens keys & provider keys
A Spanlens organization contains one or more projects. Each project owns one or more Spanlens keys (sl_live_…), the value you put in your app's SPANLENS_API_KEY env. Each Spanlens key in turn owns its own pool of provider keys (the real OpenAI / Anthropic / Gemini credentials), so two Spanlens keys in the same project can carry different underlying credentials. Projects let you separate dev/staging/prod, per-service, or per-team, whatever scoping makes sense.
Why projects
Without projects, every request from every service you own shows up in one giant stream. That's fine for a solo side project; painful for anything beyond. Projects let you:
- Filter /requests by source service
- Compute cost per team for chargeback
- Apply different alert rules to prod vs staging
- Revoke one service's keys without affecting others
Why two key types
Spanlens keys are what your app ships with. Provider keys are what the LLM provider bills against. Splitting them gives you:
- Real provider keys never ship to clients. Frontend bundles, mobile apps, anywhere, they only ever see the revocable
sl_live_…. - Centralized rotation. Replace the OpenAI key on the dashboard (pencil icon on the provider key row) → next request flows through the new key. No redeploys, no env changes.
- Per-environment isolation without cross-env leakage. Issue two Spanlens keys (
prod,staging), give each its own OpenAI/Anthropic/Gemini credentials, and a leaked staging key can never touch prod billing.
How keys work
Spanlens key format
Keys are sl_live_ + 48 random hex chars. At creation time the plaintext is shown once in the dashboard, copy it immediately. On the server we compute SHA-256 over the key and store only the hash in api_keys.key_hash.
Incoming proxy requests present the key in whichever transport the upstream SDK uses (see Direct proxy for the full table). The authApiKey middleware extracts the key, hashes it, and looks it up. No plaintext comparison, no plaintext storage.
Provider key encryption
Provider keys (the real sk-… / sk-ant-… / AIza… values) are stored encrypted with AES-256-GCM. See Keys & encryption for the cryptographic details, fresh IV per key, authenticated, decrypted only in memory for the duration of one upstream fetch().
Per-key metadata tracked
name, human label (e.g. “prod-backend”)key_prefix, first 15 chars (Spanlens key only), shown in UI for IDlast_used_at, updated on every successful auth (throttled to one write per key per 5 minutes so the proxy hot path stays cheap)is_active, revoke flag; inactive keys return 401
Stale key surfacing
The dashboard classifies each active key by how long it has been idle and surfaces forgotten keys before they leak:
- 30–89 days idle→ neutral “Stale” badge on the key row in /projects.
- 90+ days idle→ accent “Consider revoking” badge, surfaced in two extra places so it's hard to miss:
- The sidebar Projects & Keys entry carries a red count of stale + revoke-tier keys.
- The dashboard Needs Attention strip shows a warning card with a sample key name and a deep link to /projects.
Keys that have never authenticated fall back to created_atas the idleness floor — a brand-new key isn't flagged for the first 30 days. Revoked keys (is_active=false) are excluded from staleness reporting entirely: once you've already disabled a key, nagging about it is noise.
Using it
Dashboard
Go to /projects. The flow is two steps:
- Issue a Spanlens key. On the project card click + New Spanlens key → enter a name → the dialog returns the
sl_live_…value once. Copy it immediately and put it in your app'sSPANLENS_API_KEYenv. - Attach provider keys. The new Spanlens key now appears as a section. Click + Add provider key on it → pick OpenAI / Anthropic / Gemini → paste your real provider credential. Repeat per provider you need.
- After you save a provider key the dialog flips to a success view showing the one-line integration snippet for that provider (
createOpenAI(),createAnthropic(),createGemini()). Drop it into your code, no CLI re-run needed.
Existing keys can be:
- Toggled active/inactive via the switch on the Spanlens key row. Inactive keys return 401 immediately, no cache to invalidate.
- Provider keys rotated via the pencil icon on each provider key row. The
sl_live_…stays the same; only the underlying credential changes. - Deleted immediately and permanently.The trash icon on a Spanlens key or a provider key hard-deletes the row right away, the same way key deletion works at OpenAI, Anthropic, or Stripe. The proxy stops accepting the key within seconds (the auth cache is evicted on delete), and deleting a Spanlens key also deletes every provider key nested under it. Deletion cannot be undone; if it was a mistake, issue a fresh key and re-add the provider credential from the provider's dashboard.
Every delete is recorded as an audit event (api_key.delete / provider_key.delete) with the key's name and prefix, so the trail of who deleted what stays intact. Existing request logs also stay intact — rows from a deleted key render its name as (deleted).
The page also surfaces a wizard hint: npx @spanlens/cli init can bootstrap an existing OpenAI/Anthropic/Gemini codebase by rewriting new OpenAI(...) → createOpenAI() in one pass. For newcode, you don't need the CLI, copy the snippet from the dashboard and you're done.
API
# ── Projects ──────────────────────────────────────────────────
GET /api/v1/projects
POST /api/v1/projects { "name": "backend-prod" }
DELETE /api/v1/projects/:id
# ── 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 # immediate, permanent (cascades to provider keys)
# ── 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 # immediate, permanentbashHow requests get their project
A request's project is determined by which Spanlens key was used. One key = one project. To send traffic to a different project, issue a key under that project and use it for those calls. There is no per-request project override header.
Security design
- No recovery.Lose a plaintext Spanlens key → create a new one. We can't retrieve it from the SHA-256 hash. Same for the underlying provider key plaintext after registration.
- Revocation is instantaneous. Flipping
is_activeto false blocks all subsequent traffic. No key cache to invalidate. - Rate limits and quotas are enforced per-org.A leaked Spanlens key can be revoked without breaking your other keys, but while active it shares the org's quota. Rotate regularly for defense-in-depth.
- Provider keys are scoped to one Spanlens key.A stolen Spanlens key only unlocks the provider keys you registered under it, not the org's other Spanlens keys' provider keys. (UNIQUE INDEX
(api_key_id, provider) WHERE is_active = trueenforces 1 active per Spanlens key per provider.)
Limitations
- No per-key rate limits yet. Enterprise ask, on roadmap.
- No automatic rotation. Provider key rotation is manual (pencil icon or
PATCH /api/v1/provider-keys/:id). Scheduled rotation is deferred to Phase 5. - No IP allowlisting.Keys work from anywhere that presents them. Network-level allowlisting is an Enterprise request we're tracking.
- Spanlens key plaintext shown once.No “reveal existing key” escape hatch, by design. If CI lost the value, delete the key, create a new one, and update the secret in your CI settings.
Related: Keys & encryption (AES-256-GCM), Quick start, Direct proxy & auth transports, /projects dashboard.