Files
gh-christianlouis-inboxconv…/docs/adr/007-jwt-authentication.md
T

6.3 KiB

ADR 007: Use JWT for API Authentication

Status: Accepted
Date: 2026-02-08
Deciders: Security Team, Development Team

Context

The multi-tenant SaaS API needs a stateless authentication mechanism that:

  1. Works with both browser-based frontends and programmatic API clients
  2. Supports short-lived access tokens to limit exposure on compromise
  3. Allows session refresh without re-entering credentials
  4. Integrates with Google OAuth2 (users who sign in via Google)
  5. Requires no server-side session store

Decision

We will use JWT (JSON Web Tokens) with HMAC-SHA256 (HS256) signing for both access tokens and refresh tokens, implemented via the python-jose library.

  • Access tokens: 30-minute TTL, sent in Authorization: Bearer <token> header
  • Refresh tokens: 7-day TTL, exchanged for a new access token
  • Token subject: User ID (stored as str in sub claim, decoded to int in deps)

Alternatives Considered

1. Session Cookies (server-side sessions)

  • Pros: Easy revocation, browser-native, CSRF protectable with SameSite
  • Cons: Requires server-side session store (Redis), doesn't work well for API-first architecture, harder to use from mobile/CLI clients

2. OAuth2 Opaque Tokens

  • Pros: Easy revocation, no token content leakage
  • Cons: Every request requires a database lookup to validate the token (not stateless)

3. JWT with RS256 (RSA asymmetric signing)

  • Pros: Public key verification allows third-party validation without sharing secret
  • Cons: Additional key management complexity, slower to sign/verify, not required at current scale

4. API Keys (long-lived static tokens)

  • Pros: Simple for machine-to-machine, easy to understand
  • Cons: Long-lived tokens increase risk on compromise, no user-session semantics

5. Paseto (Platform-Agnostic Security Tokens)

  • Pros: Better defaults than JWT (no algorithm confusion attacks), cleaner spec
  • Cons: Less widespread adoption, fewer library options in Python, migration cost from JWT

Rationale

JWT with HS256 was chosen because:

  1. Stateless: No database lookup required to validate a token — the signature is self-authenticating
  2. Short TTL: 30-minute access tokens limit the window of opportunity if a token is stolen
  3. Refresh Token Pattern: 7-day refresh tokens allow long sessions without exposing long-lived access tokens
  4. Standard: JWT is the de-facto standard for REST API authentication
  5. OAuth2 Compatibility: Google OAuth2 tokens can be exchanged for our own JWTs, giving unified auth handling
  6. python-jose: Well-maintained library with HS256/RS256 support and JWKS endpoint capability

Implementation

# backend/app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError

ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7

def create_access_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {"sub": subject, "exp": expire, "type": "access"}
    return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")

def create_refresh_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    payload = {"sub": subject, "exp": expire, "type": "refresh"}
    return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")

def decode_token(token: str) -> dict:
    return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
# backend/app/core/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -> User:
    try:
        payload = decode_token(token)
        user_id = int(payload["sub"])   # sub is str (jose requirement), decode to int
    except (JWTError, KeyError, ValueError):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    user = await db.get(User, user_id)
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return user

Token Subject Encoding

The sub claim uses str(user.id) when creating tokens and int(payload["sub"]) when decoding. This is required because python-jose mandates string subjects per the JWT spec.

Consequences

Positive

  • No database query per request for authentication (stateless validation)
  • Short access token TTL limits blast radius of token theft
  • Works identically for browser, mobile, and CLI clients
  • Google OAuth flow exchanges Google's token for our own JWT (unified handling)

Negative

  • No instant token revocation (must wait for TTL to expire)
  • Refresh token theft allows extended session hijacking
  • SECRET_KEY compromise invalidates all tokens and requires rotation

Token Revocation Strategy

For logout and forced revocation, a token blocklist stored in Redis can be used:

# Add jti (JWT ID) claim to tokens
# On logout: store jti in Redis with TTL = token TTL
# On each request: check if jti is blocklisted

This is planned but not yet implemented; current logout deletes the token client-side only.

Security Considerations

  1. HTTPS Only: JWTs must only be transmitted over TLS in production
  2. No Sensitive Data in Payload: JWT payload is base64-encoded, not encrypted — never put passwords or PII in claims
  3. Algorithm Pinning: Always specify algorithms=["HS256"] in jwt.decode() to prevent algorithm confusion attacks
  4. Secret Key Length: SECRET_KEY must be ≥ 32 characters (validated at startup)
  5. Token Storage: Frontend stores tokens in localStorage (XSS risk); consider httpOnly cookies for hardened deployments
  • See ADR-006 for SECRET_KEY management
  • See ADR-003 for FastAPI dependency injection (Depends(get_current_user))

References