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

Organizationacme-corpProject: productionProject: stagingSpanlens keysl_live_… (issued)OpenAIencryptedAnthropicencryptedGeminiencryptedOne sl_live_… covers every provider key under its project.
One Organization owns many Projects. Each Project has one or more Spanlens keys (issued to your code) and one Provider key per upstream (encrypted at rest).

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 ID
  • last_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:

  1. 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's SPANLENS_API_KEY env.
  2. 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.
  3. 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, permanent
bash

How 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_active to 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 = true enforces 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.