accelerando.wiki ↗ app ↗ github

Per-tenant API keys

The first version of accelerando used one shared ACCELERANDO_API_KEY as a god-key. Anyone who got it could read or wipe any tenant. That's the demo-day version. Closing the gap was the next slice.

Two identities

type Identity =
  | { kind: "admin" }
  | { kind: "tenant"; tenantId: string; keyLabel: string | null };

The split means the admin secret is suitable for the deployment provisioner. The tenant tokens are suitable for shipping to apps — including this Worker's own UI.

The D1 table

CREATE TABLE IF NOT EXISTS tenant_keys (
  key TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  label TEXT,
  created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tenant_keys_tenant
  ON tenant_keys(tenant_id);

Idempotent — runs at every request. Cheap with IF NOT EXISTS. The table bootstraps itself the first time the Worker handles a request after deploy.

Minting

POST /admin/tenants  { "tenant_id": "acme", "label": "playground" }

→ 201 { "key": "ldLia1i1sefsZmTRuft0XdxDfF0lTH5UZAQ53Gtz4lg",
        "tenant_id": "acme", "label": "playground",
        "created_at": "2026-06-25T17:54:39.165Z" }

The key is 32 bytes of crypto.getRandomValues base64url-encoded. Returned ONCE, in the create response. After that it's just another row in the table and the same response shape — there is no convenience endpoint that re-emits a forgotten key. Lose it, rotate it.

Tenant binding

The tool layer requires args.tenant_id to match identity.tenantId. If they don't, the Worker returns 403:

{
  "error": "tenant_id in body does not match the key's tenant",
  "key_tenant": "acme",
  "body_tenant": "globex"
}

If the body omits tenant_id entirely, the Worker INJECTS the key's tenant — the agent literally cannot accidentally target another tenant by mistyping. The right move is "make the wrong thing impossible," not "trust the agent to not do the wrong thing."

Constant-time admin compare

Admin token comparison is constant-time:

function constantTimeEq(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

Tenant key lookups go through D1's prepared statement WHERE key = ?, which is one indexed lookup — leaking timing information about the key contents through a SQL engine that's also serving other tenants is sufficiently hard. The admin compare gets the explicit constant-time treatment because the value never enters D1.

The auth flow

incoming request
  ↓
  OPTIONS? → 204 (CORS preflight bypasses everything)
  ↓
  ACCELERANDO_API_KEY set? → no → 503 (fail-closed: never serve without auth)
  ↓
  parse "Authorization: Bearer <token>"
  ↓
  token === ACCELERANDO_API_KEY? → yes → identity = admin
                                 → no  → look up in tenant_keys
  ↓
  no row? → 401
  ↓
  identity now known. Route dispatch:
    /admin/*       → require identity.kind === "admin"
    /tools/:name   → require tenant, enforce tenant binding on args
    /tools         → any authed identity (catalog is metadata)
    /whoami        → any authed identity
    /activity      → tenant only

What this gives you

The migration

When this lockdown shipped, the existing single-key deploy broke. The fix was three live curls: POST /admin/tenants with the admin key to mint a tenant key for the existing tenant; paste that into the UI's Keys modal; verify the old key now returns 403 on tool routes. Migration was about ninety seconds of operator work. The blast radius was visible. That's what you trade for the lockdown.