Skip to content

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:

Set-Cookie: __Host-session=<opaque-value>; Path=/; Secure; HttpOnly; SameSite=Lax

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:

  1. Publish the new public key.
  2. Begin signing with its new kid.
  3. Retain the old public key until every old token has expired plus operational margin.
  4. 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 jti until 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:

  1. Hash the presented token and lock its database row.
  2. Reject an absent, expired, or revoked token.
  3. If it was already used, treat this as possible replay and revoke the token family.
  4. Mark it used and issue a new random token in the same family.
  5. Store the replacement digest and link the rows in one transaction.
  6. 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:

ak_live_<public_id>_<random_secret>

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 kid does 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