Skip to content

JWT

By default, FastAuth signs access + refresh tokens with HS256 using your shared secret. For asymmetric verification across services, switch to RS256/RS512 with JWKS.

Token shape

Both access and refresh tokens share a claim set; only type and exp differ.

{
  "sub": "user_id",
  "jti": "cuid2",
  "iss": "(optional)",
  "aud": ["(optional list)"],
  "iat": 1700000000,
  "exp": 1700000900,
  "type": "access | refresh",
  "email": "a@b.com",
  "name": "A",
  "email_verified": false
}

Lifetimes

Token Default Configurable via
access 15 min (900s) jwt.access_token_ttl
refresh 30 days (2592000s) jwt.refresh_token_ttl
refresh (remember-me) 90 days (7776000s) jwt.remember_me_ttl, active when POST /auth/login is called with remember=true

Decode validates exp (essential), sub (essential), and iss/aud if set. Expired or malformed tokens raise InvalidTokenError; protected routes translate this to 401.

HS256 (default)

config = FastAuthConfig(
    secret=">=32 byte random secret",   # `fastauth generate-secret`
    ...,
)

Single shared secret. Simple, fine for single-process apps. Shares the secret with anyone who verifies tokens — including resource services.

RS256 / RS512 (JWKS)

from fastauth import JWTConfig

config = FastAuthConfig(
    ...,
    jwt=JWTConfig(
        algorithm="RS256",          # or "RS512"
        jwks_enabled=True,         # required for RS*
        # private_key = PEM_PRIVATE,  # optional — auto-generated if absent
        # public_key  = PEM_PUBLIC,    # optional — auto-generated if absent
        key_rotation_interval=86400,  # prune keys older than 2× this interval
    ),
)

auth = FastAuth(config)

@asynccontextmanager
async def lifespan(app: FastAPI):
    await adapter.create_tables()
    await auth.initialize_jwks()      # required before issuing RS256 tokens
    yield
  • JWKS endpoint: GET /.well-known/jwks.json (mounted at app root, not under route_prefix). Returns {"keys": [...]} or {"keys": []} if JWKS is disabled/failed.
  • Auto-generated keys are ephemeral. If you omit private_key/public_key, JWKSManager.initialize() generates a fresh 2048-bit RSA key pair at startup. Existing tokens are invalidated on every restart. For production, supply PEM keys from a file or secrets manager.
  • Auto-rotation is manual. Nothing in FastAuth calls jwks_manager.rotate() on a schedule. To rotate a key, call await auth.jwks_manager.rotate() from a scheduled task. _prune_old_keys keeps keys newer than 2 × key_rotation_interval plus the current key, and only prunes when more than one key exists and key_rotation_interval is set.
  • RS256/RS512 require jwks_enabled=TrueFastAuthConfig.__post_init__ raises ConfigError otherwise (config.py:267-272).

modify_jwt hook

Add custom claims without subclassing the token service:

from fastauth import EventHooks

class MyHooks(EventHooks):
    def modify_jwt(self, token, user):
        return {**token, "plan": user.get("plan", "free")}

config = FastAuthConfig(..., hooks=MyHooks())

modify_jwt is called inside async_create_token_pair for every access and refresh token. The return value replaces the payload.

Resource-service verification (microservice pattern)

The JWKS endpoint lets a separate service verify tokens with only the public key:

import httpx
from joserfc import jwt
from joserfc.jwk import KeySet

# Cache the KeySet in your resource service
def get_keyset():
    resp = httpx.get("http://auth-service/.well-known/jwks.json")
    return KeySet.import_key_set(resp.json())

def verify(access_token: str):
    keyset = get_keyset()   # refresh on key rotation
    claims = jwt.decode(access_token, keyset)
    if claims["type"] != "access":
        raise ValueError("not an access token")
    return claims

See Microservice guide and examples/jwt-microservice/.

Common mistakes

  • Calling auth.mount(app) before defining the lifespanmount registers the JWKS route based on static config.jwt.jwks_enabled; it does not need jwks_manager to exist yet. initialize_jwks() must run inside the lifespan, which is after mount. The order in shipped examples (examples/full/main.py, examples/jwt-microservice/auth_service.py) is correct.
  • Auto-generated keys in production → tokens invalidated on every deploy.
  • Forget await auth.initialize_jwks() → first sign-in throws because jwks_manager is None.