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)¶
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 underroute_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, callawait auth.jwks_manager.rotate()from a scheduled task._prune_old_keyskeeps keys newer than2 × key_rotation_intervalplus the current key, and only prunes when more than one key exists andkey_rotation_intervalis set. RS256/RS512requirejwks_enabled=True—FastAuthConfig.__post_init__raisesConfigErrorotherwise (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 lifespan —mountregisters the JWKS route based on staticconfig.jwt.jwks_enabled; it does not needjwks_managerto exist yet.initialize_jwks()must run inside the lifespan, which is aftermount. 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 becausejwks_manager is None.