Auth operations runbook
Day-to-day operations for the CV + jobcache auth stack. Pairs with PRIVACY-POSTURE (the design) and auth-pick (the why).
Token catalogue
Two completely separate mechanisms, both surface as Authorization: Bearer …:
Mode A — Static service tokens (@cv/auth-bearer)
Stateless. SHA-256-hashed registry in a wrangler / Render env var. No DB.
| Where it's used | Env var |
|---|---|
careervector-qa-api, careervector-qa-mcp |
QA_TOKENS_JSON, QA_MCP_TOKENS_JSON |
careervector-ops-api, careervector-ops-mcp |
OPS_TOKENS_JSON |
careervector-mcp |
CV_MCP_TOKENS_JSON |
jobcache-qa-*, jobcache-ops-* |
same QA_* / OPS_* names (or jobcache-specific) |
jobcache-interface, jobcache-ingest (control endpoints) |
JOBCACHE_TOKENS_JSON (+ legacy JOBCACHE_CONTROL_TOKEN until cutover) |
Mode B — User-mintable API keys (Better Auth apiKey plugin)
DB-backed. Per-user, scoped, revocable. Sent as x-api-key: <key>.
| Where | Table |
|---|---|
| jobcache-interface (jobcache user keys) | apikey (Cockroach) |
Common operations
Mint a static service token
# 1. Generate a random opaque token
TOKEN=$(node -e 'console.log("cv_" + require("crypto").randomBytes(20).toString("base64url"))')
echo "Token (give to caller, never to git):"
echo "$TOKEN"
# 2. Hash it (this is what goes into the env var)
HASH=$(node -e 'console.log(require("crypto").createHash("sha256").update(process.argv[1]).digest("hex"))' "$TOKEN")
# 3. Build the registry JSON (or append to existing)
cat <<EOF
{
"$HASH": {
"label": "ci-ingest@circleci",
"scopes": ["ci-ingest"]
}
}
EOF
# 4. Push to the worker
wrangler secret put QA_TOKENS_JSON --env production
# (paste the entire JSON object — Cloudflare overwrites, so include any
# existing entries you want to keep)
Give $TOKEN to the caller (CI runner, agent, CLI). They send Authorization: Bearer $TOKEN.
Mint a user API key (jobcache)
User goes to their settings page in jobcache and clicks "Create API key" — under the hood:
# Equivalent direct call (for testing):
curl -X POST https://api.jobcache.corbet.ch/api/auth/api-key/create \
-H "Cookie: <user-session-cookie>" \
-H "Content-Type: application/json" \
-d '{ "name": "my-claude-agent" }'
# Response: { "key": "<plaintext-once>", "id": "...", "name": "my-claude-agent" }
The plaintext key is shown ONCE. User stores it in their agent's config. Future requests send x-api-key: <plaintext>.
Revoke a key
Static service token:
# Remove the hash entry from the registry JSON, then redeploy the secret.
wrangler secret put QA_TOKENS_JSON --env production
# (paste the JSON with the revoked entry removed)
The revocation is instant because the per-process cache keys on the env-var string — when wrangler pushes a new secret, the worker re-reads it on next cold start. To force-revoke immediately: also bump a worker version (wrangler versions deploy).
User API key (jobcache):
# User clicks "Revoke" in settings, or:
curl -X POST https://api.jobcache.corbet.ch/api/auth/api-key/delete \
-H "Cookie: <user-session-cookie>" \
-H "Content-Type: application/json" \
-d '{ "keyId": "<api-key-id>" }'
Revoke updates apikey.revoked_at and all subsequent verifications fail.
Rotate all tokens (suspected leak)
- Static service: Generate new token, push registry with NEW hash, give caller the new token. Then push registry without the old hash. (Two-step lets the caller cut over without downtime.)
- User API keys: Better Auth doesn't have a bulk rotate. Either (a) admin queries the DB and revokes all
apikeyrows for a user (UPDATE apikey SET revoked_at = NOW() WHERE user_id = ?), or (b) tell the user to rotate themselves.
Add a new admin
For CV admin (passkey-only on auth.careervector.corbet.ch):
- Provision the admin's
userrow withrole='admin':INSERT INTO user (id, role, created_at) VALUES (gen_random_uuid(), 'admin', NOW()) RETURNING id; - Generate an enrollment link with a one-time token (deferred —
careervector-authworker doesn't have this UI yet, see spike state). - Admin visits the link in their browser, enrolls one or more passkeys.
- They sign in via the same passkey on
qa.careervector.corbet.ch/ops.careervector.corbet.ch(cookie crosses.careervector.corbet.ch).
Debug a 401
- Check the request actually sent the header.
Confirmcurl -v https://api.qa.careervector.corbet.ch/v1/runs/foo 2>&1 | grep -i "^> "Authorization: Bearer …appears in the outgoing headers. The #1 cause of 401s in this stack is missing headers, not bad tokens. - Check the token is in the registry.
echo -n "$TOKEN" | sha256sum # Compare with the registry JSON - Check the token has the required scope. A
ci-ingest-scoped token can't callmcp-triggerendpoints. - Check the env var is on the right env.
wrangler secret list --env productionshould showQA_TOKENS_JSON. Easy footgun: pushed to default env instead ofproduction. - Check the worker has been deployed since the secret was set. Wrangler secrets take effect on the next deploy / version push.
wrangler versions list --env production.
Wipe a compromised account
User reports their account is taken over:
# 1. Admin runs (in jobcache Cockroach):
UPDATE apikey SET revoked_at = NOW() WHERE user_id = '<user-id>';
DELETE FROM session WHERE userId = '<user-id>';
DELETE FROM account WHERE userId = '<user-id>';
DELETE FROM passkey WHERE userId = '<user-id>';
# 2. Tell the user to enrol new passkeys via the sign-up flow. Their
# user_id stays the same; only the credentials are wiped.
We don't have a "force-logout" button in the UI yet — the cascade above is the manual version.
Where things live
| Concern | File / Worker |
|---|---|
| Static bearer middleware (lib) | lib/auth-bearer/src/* |
| Bearer registry env vars | wrangler secrets (CF Workers), Render env (Node) |
| Better Auth issuer (jobcache) | jobcache/interface/src/auth/index.ts |
| Better Auth issuer (CV admin) | careervector-auth/src/auth.ts |
| SK hooks (jobcache UI) | jobcache/interface/src/ui/hooks.server.ts |
| Hono session-middleware | jobcache/interface/src/auth/session-middleware.ts |
| /v1/me/export + /v1/me/delete | jobcache/interface/src/auth/me-endpoints.ts |
| Privacy policy template | TODO — not yet drafted |
| DPA template (B2B customers) | TODO — not yet drafted |
Quick health checks
# Is the careervector-mcp gate working?
curl -s -o /dev/null -w "%{http_code}\n" https://mcp.careervector.corbet.ch/
# Expect: 200 (GET is public)
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
https://mcp.careervector.corbet.ch/mcp
# Expect: 401 without a Bearer (good — gate is closed)
# Is the qa-api gate working?
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://api.qa.careervector.corbet.ch/v1/tests/affected-by \
-H "Content-Type: application/json" \
-d '{"paths":["foo.ts"]}'
# Expect: 401 without a Bearer
Known footguns
- The
cf-access-jwt-assertionpath is GONE in ops/api. If you migrate the worker behind Cloudflare Access in the future, the JWT signature must be properly validated (use@cloudflare/access-jwt-validatoror equivalent). Header presence is NOT proof of auth. - Memory adapter table-init. When testing Better Auth, pre-create the tables in the in-memory DB:
db.user = [],db.session = [],db.account = [],db.verification = [],db.passkey = [],db.apikey = [].findOnethrows for missing tables; onlycreatelazily inits. - Passkey register-options is GET, not POST. Easy to get wrong.
- api-key plugin has no
/verifyendpoint. Sendx-api-keyon protected routes; the plugin's middleware validates. - The api-key plugin's model name is
apikey(lowercase). NotapiKey. Better Auth's naming is inconsistent across plugins. - Token rotation: push the NEW hash before removing the OLD one. Otherwise CI calling with the old token gets 401 during the gap.
- Auto-commit timer eats uncommitted work. If
dotkeeperor similar runs in this repo, commit aggressively. The reflog will showreset: moving to HEAD+checkout: moving from <branch> to main— that's the smoking gun. (Learned the hard way during this spike — hours 4-5 had to be re-applied from the chat history once.)