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 };
adminis the Bearer token that matchesenv.ACCELERANDO_API_KEY. It can ONLY hit/admin/*routes (mint keys, force sync, list tenants). It CANNOT execute tool calls or read tenant data.tenantis any Bearer token registered in the D1tenant_keystable. It identifies exactly one tenant. Every tool call carrying that token is scoped to that tenant.
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 admin secret is for ops. It's set once via
wrangler secret put ACCELERANDO_API_KEYand rotated only when ops reasons require it. - Tenant keys are for apps. The UI in this Worker, the BabyAI Playground integration, a future iOS client — each gets its own labeled key. Revoke individual keys without rotating others.
- A compromised tenant key only compromises its tenant. The blast radius is bounded by design.
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.