Skip to content

Enforce API token revocation on the auth path#153

Merged
martsokha merged 1 commit into
mainfrom
fix/token-revocation
Jul 19, 2026
Merged

Enforce API token revocation on the auth path#153
martsokha merged 1 commit into
mainfrom
fix/token-revocation

Conversation

@martsokha

Copy link
Copy Markdown
Member

Summary

Fixes a security gap surfaced while investigating the deferred API-token identity question: revoking a token didn't actually revoke it.

The bearer credential is a self-contained, signed JWT — there is no separate opaque secret, and the JWT's jti claim is the account_api_tokens row id. The auth path (AuthState::from_unverified_header) validated the signature, exp, and account status, but never re-checked the backing token row. So DELETE /api-tokens/{id}/ soft-deleted the row and the management UI stopped listing it, yet the JWT kept authenticating until its exp — up to a year. A leaked or compromised token could not be killed.

Fix

  • New query account_api_token_is_active(token_id, account_id) — a cheap SELECT exists(…) scoped to the token's account, returning false once the row is soft-deleted.
  • New auth step verify_token_active runs on every authenticated request (alongside the existing account-status and privilege-consistency checks), rejecting a revoked token with 401.

Revocation is now immediate (no cache, so no staleness window — the right default for a security control). It costs one indexed PK lookup per request, on top of the account lookup already performed.

Verification

  • Full gate green: check / clippy -D warnings / nightly fmt / test --all-features (13 suites).
  • Live E2E: create token → use (200) → DELETE (204) → reuse (401, was 200 before) → caller's other session still works (200). Confirmed jti == returned token id.

Note on the identity question (no code change)

The investigation also resolves the parked "should the token id be a hash?" decision: keep the UUID. The id is load-bearing — it's the JWT jti, the management-endpoint address, and (now) the revocation join key — but never human-facing. A hash/short-id would add no value and would break jti == id symmetry.

🤖 Generated with Claude Code

The bearer credential is a self-contained JWT whose jti is the account_api_tokens
row id, so a revoked (soft-deleted) token kept authenticating until its exp (up
to a year). Add account_api_token_is_active(jti, account) and check it on every
authenticated request, so DELETE /api-tokens/{id}/ takes effect immediately.

Verified live: a token returns 200, is revoked (204), then returns 401 on reuse
while the caller's other sessions keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@martsokha martsokha added bug something isn't working as intended server API handlers, middleware, auth postgres ORM, models, queries, migrations labels Jul 19, 2026
@martsokha martsokha self-assigned this Jul 19, 2026
@martsokha
martsokha merged commit 0c35d30 into main Jul 19, 2026
7 checks passed
@martsokha
martsokha deleted the fix/token-revocation branch July 19, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug something isn't working as intended postgres ORM, models, queries, migrations server API handlers, middleware, auth

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant