Skip to content

Tokens: introspection, revocation, JTI allowlist

FastAuth issues JWT access + refresh token pairs. When token_adapter is configured, the JTI of every refresh token is recorded in an allowlist; refresh consumes the JTI atomically and issues a fresh pair, so reuse of an already-consumed refresh token revokes the entire family.

The JTI allowlist

On every token issuance in _issue_tracked_tokens, FastAuth:

  1. Decodes the freshly minted refresh token to read jti and exp.
  2. Writes a TokenAdapter row of type "refresh_jti" with expires_at = exp.

On every refresh:

  1. Decodes the refresh token. type must be "refresh".
  2. Loads the user via get_user_by_id. Inactive users get 401.
  3. Calls token_adapter.consume_token(jti, "refresh_jti") atomically. If it returns None the token has already been used or never existed → revoke the entire family (delete_user_tokens(user_id, "refresh_jti")) and return 401 "Refresh token has already been used".

So a leaked-then-replayed refresh token causes the legitimate holder's next refresh to fail — both sides are forced to re-authenticate.

Endpoints

# Introspection (RFC 7662-inspired; never raises 4xx — returns active=false on bad tokens)
curl -X POST http://localhost:8000/auth/token/introspect \
  -H "Authorization: Bearer $ACCESS" \
  -H "Content-Type: application/json" \
  -d '{"token":"..."}'
# -> {"active": true, "sub": "...", "exp": ..., "jti": "...", "token_type": "access|refresh", "email": "..."}

# Revocation (RFC 7009-inspired; refresh tokens only; caller must be owner)
curl -X POST http://localhost:8000/auth/token/revoke \
  -H "Authorization: Bearer $ACCESS" \
  -H "Content-Type: application/json" \
  -d '{"token":"<refresh_token>"}'

/auth/token/introspect for a refresh token additionally checks the JTI against the allowlist; an expired or revoked-but-consumed JTI returns active=false.

/auth/token/revoke returns 400 if the token is not a refresh token, and 403 if the caller's user ID does not match the token's sub.

Statelessly disabling token_adapter (tests only)

config = FastAuthConfig(
    ...,
    token_adapter=adapter.token,
    require_token_adapter_for_refresh=False,   # tests only — disables revocation/replay protection
)

This is only for tests/demos. Setting it removes logout revocation, replay detection, and password-reset password-revocation. Never use it in production.

Common mistakes

  • 401 Refresh token has already been used — your client reused a refresh token. Treat refresh responses like one-time-use; always store the new refresh token and discard the old one.
  • Bearer at /auth/token/introspect — the introspect endpoint requires an authenticated access token, not the token being introspected.
  • Sending a refresh token via Bearer/auth/refresh reads it from the request body OR the refresh cookie, not from the Authorization header.