Authentication, Sessions, and Tokens
Authentication establishes which principal is making a request. Authorization decides whether that principal may perform an action. A valid token is evidence presented to authentication code; it is not proof that the requested operation is allowed.
Production authentication design starts with threat boundaries:
- Is the client a same-site browser, mobile app, command-line tool, service, or third party?
- Can it keep a secret?
- Which system is the identity provider and which is the API resource server?
- How quickly must access be revoked?
- What happens when credentials, a signing key, or a database leak?
- Which actions require recent or multi-factor authentication?
Use TLS for every credential-bearing request. Tokens in plaintext HTTP, URLs, logs, analytics events, or exception details should be treated as compromised.
A request authentication pipeline
credential transport
-> syntax and size validation
-> signature, session, or key verification
-> issuer, audience, expiry, and status checks
-> principal construction
-> authorization policy
-> tenant-scoped business operation
Keep the resulting principal small and explicit:
from dataclasses import dataclass
from uuid import UUID
@dataclass(frozen=True, slots=True)
class Principal:
subject_id: UUID
tenant_id: UUID
session_id: UUID | None
authentication_method: str
authentication_time: int
Do not pass a decoded token dictionary throughout the codebase. A typed principal prevents transport-specific claims from becoming accidental authorization rules.
Password storage
Passwords are low-entropy secrets. Store a password hash produced by a password hashing function designed to be expensive and salted, not an encrypted password and not a fast hash such as SHA-256. OWASP recommends Argon2id when available, with parameters selected for the deployment and reviewed over time.
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, encoded_hash: str) -> bool:
return password_hash.verify(password, encoded_hash)
Benchmark the configured cost on production-class hardware. A cost that is too low weakens offline resistance; one that is too high turns login into an easy CPU denial of service. Cap accepted password byte length before hashing, while allowing long passphrases within that limit. Do not silently truncate.
Each encoded Argon2 hash carries its salt and work parameters. A separate pepper can add protection against a database-only compromise, but the pepper must be held in a secrets manager or HSM and requires an explicit rotation and recovery plan.
Verification and hash upgrades
Libraries can identify hashes that use obsolete parameters. After a successful login, create a new hash and replace the old one in the same controlled workflow. This migrates active accounts without storing plaintext.
To reduce account enumeration and timing differences, verify a fixed dummy hash when an account is absent. Return the same public error for an unknown user and a wrong password. This is only one layer: responses, status codes, password reset, registration, and rate limits must follow the same disclosure policy.
from typing import Final
# Generate this deployment value once with hash_password() and store it in
# configuration. It must be a syntactically valid hash for the active hasher.
DUMMY_HASH: Final[str] = settings.authentication_dummy_hash
async def authenticate_password(
session: AsyncSession,
*,
normalized_email: str,
password: str,
) -> User | None:
user = await users.by_normalized_email(session, normalized_email)
candidate_hash = user.password_hash if user is not None else DUMMY_HASH
accepted = verify_password(password, candidate_hash)
if user is None or not accepted or not user.is_active:
return None
return user
The dummy value must be generated by the application's configured hasher. Perfectly equal response time is unrealistic, so combine uniform messages with account-aware and network-aware throttling, monitoring, and stronger controls for suspicious activity.
Never log passwords. Avoid passing password values into generic model representations, traces, or validation errors. Password reset tokens should be random, single-use, short-lived, stored hashed, and invalidate relevant sessions when policy requires it.
Server-side sessions
A browser session normally uses a high-entropy opaque identifier in a cookie. The server stores session state and can revoke it immediately. The identifier must carry no meaningful state.
Recommended cookie properties for a same-site application are:
The __Host- prefix requires Secure, Path=/, and no Domain attribute in supporting browsers. HttpOnly prevents direct JavaScript access but does not neutralize XSS; injected JavaScript can still make authenticated requests. SameSite helps with CSRF but is not a complete substitute for CSRF protection in every deployment.
Store a digest of the random session token so a read-only session-table leak does not immediately reveal usable cookies. A fast cryptographic hash is appropriate for a uniformly random token, unlike a human password.
import hashlib
import secrets
def new_session_token() -> tuple[str, bytes]:
token = secrets.token_urlsafe(32)
digest = hashlib.sha256(token.encode("ascii")).digest()
return token, digest
A session row commonly records:
- internal session ID and token digest;
- subject and current tenant context;
- created, last-seen, idle-expiry, and absolute-expiry timestamps;
- authentication time and method;
- revoked timestamp and reason;
- selected device or risk metadata, with privacy limits.
Rotate the session identifier after login, privilege elevation, password change, and other trust-boundary changes to prevent session fixation. Enforce idle and absolute lifetimes on the server. Logout revokes server state and expires the cookie. A "log out all devices" operation revokes every relevant session.
For cookie-authenticated state changes, implement CSRF protection. A synchronizer token or properly constructed signed double-submit token is common. Verify the request origin where appropriate and never use state-changing GET endpoints.
Session tradeoffs
Server-side sessions provide simple revocation and small client credentials. They require a highly available shared session store or database and a cleanup policy. Caching sessions can reduce reads, but revocation and cache consistency must be designed rather than assumed.
Do not store an entire permission model forever in the session. Role and tenant membership changes need a defined propagation time.
JWT access tokens
A JSON Web Token is a compact set of claims protected by a signature or MAC. A signed JWT is generally readable by its holder. It is not encrypted and should not contain passwords, private data, or secrets.
JWT access tokens are useful when multiple resource servers must verify short-lived credentials without a database lookup on every request. They introduce key distribution, claim validation, token size, stale authorization, and revocation complexity. A monolith does not become more scalable merely by replacing an opaque session with a JWT.
Claims with a purpose
Common registered claims are:
iss: exact trusted issuer;aud: intended resource server;sub: stable subject identifier;exp: expiry time;nbf: not valid before;iat: issue time;jti: unique token identifier.
Use namespaced or collision-resistant names for private claims. Avoid putting fast-changing role lists or resource permissions in a long-lived token. Tenant context in a token must still be checked against current resource and membership policy.
Issuing and verifying a typed token
The following example uses an asymmetric EdDSA key and PyJWT. Exact algorithms depend on organizational key infrastructure and interoperability requirements. Verification pins an allowlist and never accepts an algorithm merely because the token header requests it.
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid4
import jwt
from jwt import InvalidTokenError
from pydantic import BaseModel, ConfigDict, ValidationError
class AccessClaims(BaseModel):
model_config = ConfigDict(extra="forbid")
iss: str
aud: str
sub: UUID
tenant_id: UUID
exp: int
nbf: int
iat: int
jti: UUID
token_type: str
@dataclass(frozen=True, slots=True)
class TokenKeys:
issuer: str
audience: str
active_kid: str
private_key: str
public_keys: dict[str, str]
def issue_access_token(
*,
subject_id: UUID,
tenant_id: UUID,
keys: TokenKeys,
lifetime: timedelta = timedelta(minutes=10),
) -> str:
now = datetime.now(UTC)
claims = {
"iss": keys.issuer,
"aud": keys.audience,
"sub": str(subject_id),
"tenant_id": str(tenant_id),
"iat": now,
"nbf": now,
"exp": now + lifetime,
"jti": str(uuid4()),
"token_type": "access",
}
return jwt.encode(
claims,
keys.private_key,
algorithm="EdDSA",
headers={"kid": keys.active_kid, "typ": "at+jwt"},
)
class AccessTokenRejected(Exception):
pass
def verify_access_token(token: str, keys: TokenKeys) -> AccessClaims:
if len(token) > 8192:
raise AccessTokenRejected
try:
header = jwt.get_unverified_header(token)
if header.get("alg") != "EdDSA" or header.get("typ") != "at+jwt":
raise AccessTokenRejected
kid = header.get("kid")
if not isinstance(kid, str) or kid not in keys.public_keys:
raise AccessTokenRejected
payload = jwt.decode(
token,
keys.public_keys[kid],
algorithms=["EdDSA"],
issuer=keys.issuer,
audience=keys.audience,
options={
"require": ["iss", "aud", "sub", "exp", "nbf", "iat", "jti"]
},
)
claims = AccessClaims.model_validate(payload)
if claims.token_type != "access":
raise AccessTokenRejected
return claims
except (InvalidTokenError, ValidationError, AccessTokenRejected) as exc:
raise AccessTokenRejected from exc
Reading the unverified header is safe only for selecting from a preconfigured key allowlist. Never turn kid, jku, or x5u into an arbitrary file path or URL. Pin issuer, audience, accepted algorithms, token type, required claims, and a small clock-skew leeway if the deployment needs it.
Keep signing keys outside source control. Resource servers receive only public verification keys when asymmetric signatures are used. Rotation is staged:
- Publish the new public key.
- Begin signing with its new
kid. - Retain the old public key until every old token has expired plus operational margin.
- Remove the old key and investigate any unexpected remaining use.
Compromise is different from routine rotation. Stop trusting the key, revoke or invalidate affected credentials, and follow an incident response procedure.
Revocation choices
JWT validation alone cannot know that a user was disabled after token issuance. Options include:
- short access-token lifetimes and server-side refresh revocation;
- a session or token-version check for sensitive requests;
- a denylist keyed by
jtiuntil expiry; - opaque access tokens with introspection;
- event-driven caches of account and membership status.
Each choice trades request latency and infrastructure for revocation speed. State the maximum authorization staleness explicitly.
Do not store long-lived bearer tokens in browser localStorage as a default. XSS can read them. Same-site browser applications often benefit from secure server-side cookies or a backend-for-frontend that keeps OAuth tokens out of browser JavaScript. Cookie transport then requires CSRF controls.
Refresh token rotation
Refresh tokens are long-lived credentials used only at the authorization server to obtain new access tokens. They should have a narrower audience, never be accepted by application resource endpoints, and receive stronger storage and revocation controls.
Opaque random refresh tokens simplify server-side lifecycle management. Store only a digest plus metadata:
refresh_tokens
id
family_id
subject_id
token_digest
issued_at
expires_at
used_at
revoked_at
replaced_by_id
Rotation flow:
- Hash the presented token and lock its database row.
- Reject an absent, expired, or revoked token.
- If it was already used, treat this as possible replay and revoke the token family.
- Mark it used and issue a new random token in the same family.
- Store the replacement digest and link the rows in one transaction.
- Issue a new short-lived access token only after policy checks.
import hashlib
import secrets
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class RefreshSecret:
plaintext: str
digest: bytes
def create_refresh_secret() -> RefreshSecret:
plaintext = secrets.token_urlsafe(48)
digest = hashlib.sha256(plaintext.encode("ascii")).digest()
return RefreshSecret(plaintext=plaintext, digest=digest)
The fast hash is safe here because the input is generated with high entropy. An HMAC with a server-held key provides additional protection if only the database leaks.
Concurrent refreshes from one client need an explicit policy. With strict reuse detection, the losing request can revoke the whole family. Clients should single-flight refresh operations. A short server grace mechanism can reduce false alarms but must not return multiple independently valid descendants or hide real replay.
Bind refresh families to a session and record security events. Rotate or revoke on password reset, account disablement, suspicious login, and explicit logout according to policy. Never send a refresh token to every microservice.
OAuth 2.0 and OpenID Connect
OAuth 2.0 delegates access; OpenID Connect adds an identity layer. In a standard deployment:
- the authorization server authenticates the user and issues tokens;
- the client requests access;
- the resource server is the FastAPI API that validates the access token;
- scopes describe delegated capabilities.
For browser and native public clients, use Authorization Code with PKCE. Validate exact redirect URIs, state, PKCE, and OpenID Connect nonce where applicable. Prefer a well-maintained identity provider and protocol library over implementing an authorization server from scratch.
Resource servers validating external JWTs should use configured issuer discovery and JWKS endpoints, restrict outbound destinations, cache keys with bounded refresh, and handle rotation without fetching an attacker-controlled URL from a token header. Verify iss, aud, algorithm, time claims, and token use.
The password flow is legacy
FastAPI's OAuth2PasswordBearer describes how a bearer token is obtained and exposed in OpenAPI. It does not validate tokens. OAuth2PasswordRequestForm parses the OAuth password-grant form shape.
The OAuth 2.0 Security Best Current Practice states that the Resource Owner Password Credentials grant must not be used. It exposes the user's password to the client, prevents modern authentication methods, and trains an architecture around credential collection. New browser, mobile, and third-party clients should use Authorization Code with PKCE instead.
You may encounter a first-party legacy endpoint:
from typing import Annotated
from fastapi import Depends
from fastapi.security import OAuth2PasswordRequestForm
@router.post("/oauth/token", include_in_schema=False)
async def legacy_password_token(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TokenResponse:
# Treat form.username as an identifier only after canonicalization.
# Apply uniform errors, rate limits, MFA policy, auditing, and token rotation.
raise UnsupportedGrantTypeError("migrate this client to authorization code with PKCE")
Do not publish an insecure implementation merely to make Swagger UI login convenient. If a constrained first-party system temporarily retains this flow, document the exception, block untrusted clients, apply the same controls as the normal login service, and maintain a migration plan.
Machine-to-machine clients can use client credentials when a trusted confidential client acts as itself. Keep client secrets in workload secret storage, rotate them, narrowly scope the grant, and prefer stronger client authentication such as private-key methods or workload identity where supported.
API keys
API keys authenticate applications or automation, not usually human users. They are bearer credentials unless combined with request signatures or mutual TLS.
A manageable key format contains a non-secret lookup ID and a random secret shown once:
Store the public ID, an HMAC or digest of the secret, owner, allowed scopes, environment, creation and expiry, last-used metadata, and revocation state. Show only a short fingerprint later.
import hmac
import secrets
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class IssuedAPIKey:
plaintext: str
public_id: str
secret_mac: bytes
def issue_api_key(mac_key: bytes) -> IssuedAPIKey:
public_id = secrets.token_hex(8)
secret = secrets.token_urlsafe(32)
secret_mac = hmac.digest(mac_key, secret.encode("ascii"), "sha256")
return IssuedAPIKey(
plaintext=f"ak_live_{public_id}_{secret}",
public_id=public_id,
secret_mac=secret_mac,
)
On verification, parse strictly, load by public ID, recompute the MAC, and compare with hmac.compare_digest. Rotate by allowing an overlap window between two independently revocable keys. Do not place keys in query strings. They leak through URLs, logs, browser history, and referrers.
API-key scopes are an input to authorization, not a reason to skip resource and tenant checks. Rate-limit and audit by key identity as well as network origin.
FastAPI authentication dependency
Authentication belongs in a dependency that verifies the credential and returns a principal. Authorization dependencies can then consume it.
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer = HTTPBearer(auto_error=False)
async def get_principal(
credential: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
) -> Principal:
if credential is None or credential.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = verify_access_token(credential.credentials, token_keys)
except AccessTokenRejected as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return Principal(
subject_id=claims.sub,
tenant_id=claims.tenant_id,
session_id=None,
authentication_method="bearer",
authentication_time=claims.iat,
)
Use 401 when authentication is missing or invalid and include an appropriate challenge. A valid principal lacking permission normally receives 403, subject to resource-enumeration policy. Do not return signature details or expiration diagnostics that help an attacker; clients can use a stable error code if refresh behavior depends on expiry.
Defense around login
Authentication endpoints need controls beyond token correctness:
- rate limits by account signal, network, client, and risk, with care for shared networks;
- progressive delays or challenges that do not enable cheap account lockout attacks;
- multi-factor authentication, preferably phishing-resistant methods for high-risk access;
- verified password reset and recovery flows with single-use tokens;
- notification and session review for material security changes;
- logs for success, failure, refresh replay, key use, reset, MFA, and revocation;
- redaction of passwords, cookies, authorization headers, reset tokens, and complete API keys;
- recent-authentication requirements for password, payment, key, and membership changes.
Credential stuffing is distributed and frequently uses correct passwords stolen elsewhere. Per-IP rate limiting alone is insufficient. Use breached-password screening and encourage password managers where product policy permits.
Testing authentication
Unit tests should cover password hash verification and upgrade decisions, claim validation, cursor-like token parsing limits, API-key parsing, and policy mapping. Integration tests should cover the full request boundary.
Test at least:
- unknown account and wrong password return the same public result;
- login, reset, and refresh are rate-limited and audited without secrets;
- expired, not-yet-valid, wrong-issuer, wrong-audience, wrong-type, unsigned, and wrong-algorithm JWTs fail;
- unknown
kiddoes not trigger arbitrary network or file access; - key rotation accepts the intended overlap and rejects retired keys;
- session IDs rotate on authentication and privilege changes;
- cookie attributes and CSRF behavior match the deployment;
- refresh rotation permits one use and detects replay;
- logout and account disablement meet the stated revocation window;
- API keys are displayed once, stored non-recoverably, scoped, rotated, and revocable;
- logs and traces contain no raw credentials.
Use controlled clocks in token tests and keep a few boundary cases around exp and allowed leeway. Do not disable signature verification in tests except in a unit explicitly testing parsing behavior.
Interview discussion
Session cookie or JWT?
Use a server session when immediate revocation and a browser-oriented trust boundary are primary. Use a short-lived JWT when several resource servers need local verification and the team can operate signing keys and stale-claim behavior. Neither removes authorization or CSRF/XSS considerations.
Why are refresh tokens rotated?
Rotation makes a successfully used token obsolete. Reuse of an old token signals possible theft and can revoke the family. It limits replay but introduces concurrency and state-management requirements.
What must a JWT verifier validate?
Signature with a pinned algorithm and trusted key, exact issuer, intended audience, expiry and other required time claims, token type, required claims, and application-specific session or subject state where policy requires it.
Why not put every permission in the access token?
Permissions change, tokens are copied to multiple systems, and large claims leak metadata and become stale. Keep access tokens short-lived and claims minimal, then perform resource authorization against current state where necessary.
Why is SHA-256 wrong for passwords but acceptable for random session tokens?
Human passwords have low, biased entropy and require a slow password hashing function with salt. A cryptographically random token has high entropy, so a fast one-way digest is sufficient for database lookup protection; an HMAC can further protect against a database-only leak.
Authoritative references
- OWASP Password Storage Cheat Sheet
- OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- OAuth 2.0 Security Best Current Practice, RFC 9700
- OAuth 2.0 for Browser-Based Applications (active IETF Internet-Draft)
- Proof Key for Code Exchange, RFC 7636
- JSON Web Token, RFC 7519
- JSON Web Token Best Current Practices, RFC 8725
- OpenID Connect Core 1.0
- FastAPI OAuth2 and JWT tutorial