ca648ccf8b
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/dd261f53-4891-437e-bd71-5561ece62d7d
6.3 KiB
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:
- Works with both browser-based frontends and programmatic API clients
- Supports short-lived access tokens to limit exposure on compromise
- Allows session refresh without re-entering credentials
- Integrates with Google OAuth2 (users who sign in via Google)
- 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
strinsubclaim, decoded tointin 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:
- Stateless: No database lookup required to validate a token — the signature is self-authenticating
- Short TTL: 30-minute access tokens limit the window of opportunity if a token is stolen
- Refresh Token Pattern: 7-day refresh tokens allow long sessions without exposing long-lived access tokens
- Standard: JWT is the de-facto standard for REST API authentication
- OAuth2 Compatibility: Google OAuth2 tokens can be exchanged for our own JWTs, giving unified auth handling
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_KEYcompromise 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
- HTTPS Only: JWTs must only be transmitted over TLS in production
- No Sensitive Data in Payload: JWT payload is base64-encoded, not encrypted — never put passwords or PII in claims
- Algorithm Pinning: Always specify
algorithms=["HS256"]injwt.decode()to prevent algorithm confusion attacks - Secret Key Length:
SECRET_KEYmust be ≥ 32 characters (validated at startup) - Token Storage: Frontend stores tokens in
localStorage(XSS risk); considerhttpOnlycookies for hardened deployments
Related Decisions
- See ADR-006 for SECRET_KEY management
- See ADR-003 for FastAPI dependency injection (
Depends(get_current_user))