auth-timestamp-invalidation
When a refresh token is reused (theft signal) or a user changes their password,
outstanding access tokens must be invalidated. The mechanism is a per-user
password_changed_at (or refresh_reuse_detected_at) epoch column:
- Token issuance:
iat = password_changed_at(or now) - Token validation:
if iat < password_changed_at: reject
The pattern requires that the token's iat (issued-at) is checked against the
user's stored epoch. This is the same mechanism used by Stripe and many other
auth systems.
The pattern (this repo's FR-007)
-- Add the column
ALTER TABLE users ADD COLUMN password_changed_at REAL NOT NULL DEFAULT 0;
# On password change
def on_password_change(user_id: int, db: sqlite3.Connection) -> None:
new_epoch = datetime.now(timezone.utc).timestamp()
db.execute(
"UPDATE users SET password_changed_at = ? WHERE id = ?",
(new_epoch, user_id),
)
# Also invalidate refresh-token sessions
db.execute("DELETE FROM user_sessions WHERE user_id = ?", (user_id,))
# Bust the active-user cache so the next request sees the new epoch
invalidate_active_user_cache(user_id)
# In token validation
def validate_access_token(token: str, db: sqlite3.Connection) -> dict:
payload = decode_access_token(token)
user = get_user_by_id(payload["sub"], db)
pwd_epoch = user.get("password_changed_at", 0) or 0
if pwd_epoch > 0 and payload.get("iat", 0) < pwd_epoch:
# Bust cache, return 401
invalidate_active_user_cache(user["id"])
raise HTTPException(
status_code=401,
detail="Token invalidated by password change",
)
return payload
When to apply
- After implementing password change endpoint (
POST /auth/change-password). - After implementing admin password reset (
POST /users/{id}/reset-password). - After implementing refresh token reuse detection (
get_rotate_refresh_token_block). - After any user-event that should invalidate outstanding access tokens.
Anti-patterns to avoid
- Storing the epoch in a separate table — joins are slow. Use a column on
the
userstable directly. - Using a separate token for the epoch — defeats the purpose. The
iatclaim in the existing JWT is the right place. - Forgetting to invalidate the active-user cache — a stale cached user bypasses the new epoch.
- Catching
BaseExceptionto "be safe" — swallowsKeyboardInterruptandSystemExit. Only catchException.
Diagnostic commands
# Find places where the epoch is set
grep -rn "password_changed_at" backend/app/ --include="*.py"
# Find places where tokens are validated
grep -rn "decode_access_token" backend/app/ --include="*.py"
Acceptance criteria
- The
password_changed_atcolumn is added to theuserstable via a migration. tracked_by: backend/tests/schema_constants.py - The migration is idempotent (uses
PRAGMA table_info). tracked_by: backend/tests/test_password_epoch_invalidates_tokens.py - After password change, outstanding access tokens issued before
password_changed_atare rejected with 401. tracked_by: backend/tests/test_password_epoch_invalidates_tokens.py - The active-user cache is invalidated on password change and on the
epoch-check rejection.
tracked_by: backend/tests/test_password_epoch_invalidates_tokens.py:271
(password-change path; the cache-mechanism unit test at
backend/tests/test_active_user_cache.py:167provesinvalidate_active_user_cacheworks in isolation but does not exercise the password-change wiring. The epoch-check-rejection half of this AC is not directly tested — see known gap below.) known_gap: no test currently asserts that an epoch-check 401 triggersinvalidate_active_user_cache. Only the password-change trigger is behaviorally tested. - A test that issues a token, changes the password, then presents the old token, gets 401. tracked_by: backend/tests/test_password_epoch_invalidates_tokens.py
Connection to other skills
authz-bridging-exceptions— the override branch in the auth dep must not mask the 401 from the epoch check.test-isolation-patterns— the test patch on the dep override must not interfere with the epoch check.module-test-isolation— the dep override is module-level state.