FastAPI has become one of the leading frameworks for building Python APIs, and its built-in security utilities make implementing authentication straightforward. This tutorial walks through building a complete, production-oriented authentication system using the OAuth2 password flow with JWT tokens -- from password hashing and token creation to protecting routes with dependency injection and adding role-based access control.
FastAPI's security system is built around dependency injection and the OpenAPI standard. Instead of decorators or middleware that hide authentication logic, FastAPI uses explicit Depends() calls that make every protected route self-documenting. When you add authentication to an endpoint, FastAPI automatically updates the interactive Swagger UI docs at /docs with the correct security scheme, so clients can test authenticated requests directly from the browser. This tutorial builds each layer of the authentication stack incrementally, so you understand what each piece does and why it is there.
Project Setup and Dependencies
Create a new project directory, set up a virtual environment, and install the required packages. The current FastAPI recommendation is to use pwdlib with Argon2 for password hashing (replacing the older passlib library, which is no longer actively maintained) and PyJWT for token operations.
# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate # macOS/Linux
# venv\Scripts\activate # Windows
# Install dependencies
pip install "fastapi[standard]"
pip install pyjwt
pip install "pwdlib[argon2]"
pip install python-dotenv
# fastapi[standard] includes uvicorn, pydantic,
# python-multipart (for form data), and other essentials
Create a .env file to hold your secret key. Generate one using openssl rand -hex 32 on the command line. Never commit this file to version control.
# .env
JWT_SECRET_KEY=your-64-character-hex-secret-here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
Password Hashing with pwdlib and Argon2
Storing plain-text passwords is one of the most dangerous mistakes in application security. If your database is compromised, every user's password is exposed. Hashing transforms the password into an irreversible string that can be verified but not reversed. The Argon2 algorithm -- selected as the winner of the 2015 Password Hashing Competition -- is specifically designed to resist GPU-based brute-force attacks by requiring significant amounts of memory during computation. As the OWASP Password Storage Cheat Sheet states, Argon2id "provides a balanced approach to resisting both side-channel and GPU-based attacks." RFC 9106 formally standardizes Argon2 and recommends the Argon2id variant as the default for password hashing.
# security.py
from pwdlib import PasswordHash
# PasswordHash.recommended() uses Argon2id with secure defaults
password_hash = PasswordHash.recommended()
def hash_password(plain_password: str) -> str:
"""Hash a plain-text password for storage."""
return password_hash.hash(plain_password)
def verify_password(plain_password: str, hashed: str) -> bool:
"""Verify a plain-text password against a stored hash."""
return password_hash.verify(plain_password, hashed)
The PasswordHash.recommended() constructor configures Argon2id with the default parameters from argon2-cffi (time_cost=3, memory_cost=65536 KiB, parallelism=4), which aligns with secure hashing practice. For stricter OWASP compliance, you can tune these values using pwdlib's Argon2Hasher directly -- OWASP's Password Storage Cheat Sheet recommends a minimum of 19 MiB memory (m=19456) with 2 iterations for Argon2id, or 46 MiB memory (m=47104) with 1 iteration. Passwords hashed with older algorithms like bcrypt will still verify correctly if you add bcrypt as a secondary hasher, and pwdlib can automatically upgrade them to Argon2 on the next login via the verify_and_update() method.
FastAPI's official documentation migrated from passlib to pwdlib because passlib is no longer maintained and relied on Python's crypt module, which was deprecated in Python 3.11 (PEP 594) and removed entirely in Python 3.13. Francois Voron, the creator of pwdlib, explained that passlib's inactivity and uncertain maintenance status motivated the new library, noting that without updates, passlib would stop working from Python 3.13 onward (source: fvoron.com, February 2024). The fastapi-users library adopted pwdlib and Argon2 as its default in version 13.0.0 (March 2024), and the FastAPI core documentation followed with PR #13917, merged September 2025.
| Feature | passlib (legacy) | pwdlib (current) |
|---|---|---|
| Maintained | No (last release 2020) | Yes (actively developed) |
| Python 3.13+ support | Broken (crypt module removed) | Full support |
| Default algorithm | bcrypt | Argon2id |
| Auto-upgrade hashes | Via CryptContext | Via verify_and_update() |
| FastAPI docs recommendation | Removed (Sept 2025) | Current standard |
| Legacy algorithm support | Extensive (MD5, SHA, DES, etc.) | Argon2 and Bcrypt only |
Pydantic Models for Users and Tokens
Define Pydantic models for the data structures your API will work with. These models validate input, serialize output, and generate the OpenAPI schema that powers the Swagger docs.
# models.py
from pydantic import BaseModel, EmailStr
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
role: str | None = None
class User(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
disabled: bool = False
role: str = "user"
class UserInDB(User):
hashed_password: str
Creating and Signing JWT Tokens
The token creation function takes a dictionary of claims, adds an expiration timestamp, and signs the result with your secret key. The sub (subject) claim holds the username, and we include a custom role claim for authorization logic later.
# auth.py
import os
import jwt
import secrets
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
ALGORITHM = os.environ.get("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(
os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "30")
)
def create_access_token(
data: dict,
expires_delta: timedelta | None = None,
) -> str:
"""Create a signed JWT with an expiration claim."""
to_encode = data.copy()
now = datetime.now(timezone.utc)
expire = now + (expires_delta or timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
))
to_encode.update({
"exp": expire,
"iat": now,
"jti": secrets.token_hex(16),
})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
To understand what you are signing, here is what a decoded JWT payload looks like after create_access_token runs:
# Decoded JWT payload (what lives inside the token)
{
"sub": "kandi", # who this token belongs to
"role": "admin", # custom claim for RBAC
"exp": 1742169600, # expires: Unix timestamp
"iat": 1742167800, # issued at: Unix timestamp
"jti": "a3f7c8e91b2d4a5f6e7890123456abcd" # unique token ID (for revocation)
}
# The finished JWT is three Base64-encoded segments:
# HEADER.PAYLOAD.SIGNATURE
#
# The header and payload are NOT encrypted -- anyone
# can decode them. The signature only proves they
# have not been tampered with. Never put passwords,
# API keys, or personal data in the payload.
Think about that from an attacker's perspective: if someone intercepts this token, they can decode the header and payload freely. The signature prevents modification, not reading. This is why JWTs are described as signed, not encrypted -- and why you never put sensitive information inside them.
Why HS256 and Not RS256?
This tutorial uses HS256 (HMAC with SHA-256), a symmetric algorithm where the same secret key both signs and verifies tokens. It is the right choice when a single server or a small cluster handles both token creation and validation. RS256 (RSA with SHA-256) uses an asymmetric key pair -- a private key signs tokens and a public key verifies them. RS256 becomes the better choice when token verification happens in a different trust boundary than token creation: microservices architectures where multiple services need to validate tokens without sharing the signing secret, systems where third-party services consume your tokens, or API gateways that verify tokens before forwarding requests to backend services. If every service that verifies tokens also has the power to create them (because they share the secret), a compromise in any one service compromises your entire authentication system. RS256 eliminates that risk by keeping the private signing key in one place.
Building the Token Endpoint
The /token endpoint is where the OAuth2 password flow happens. The client sends a username and password as form data (not JSON -- this is what the OAuth2 specification requires). This is not arbitrary: RFC 6749 Section 4.3.2 specifies application/x-www-form-urlencoded as the content type for token requests. The reason is interoperability -- OAuth2 predates the era where JSON payloads became the default, and the spec was designed so that any HTTP client, from a browser form to a command-line tool, could request tokens without requiring a JSON serializer. FastAPI's OAuth2PasswordRequestForm enforces this format automatically, and the Swagger UI sends credentials correctly because it reads the security scheme from OpenAPI.
# main.py
from datetime import timedelta
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
)
from models import Token, User, UserInDB
from security import hash_password, verify_password
from auth import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
app = FastAPI(title="Secure API Demo")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# Simulated database (replace with your actual database)
fake_users_db = {
"kandi": UserInDB(
username="kandi",
email="kandi@example.com",
full_name="Kandi Brian",
role="admin",
hashed_password=hash_password("secure-password-here"),
),
}
def authenticate_user(username: str, password: str):
"""Verify credentials and return the user or None."""
user = fake_users_db.get(username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
@app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = authenticate_user(
form_data.username, form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data={"sub": user.username, "role": user.role},
expires_delta=timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
),
)
return Token(
access_token=access_token, token_type="bearer"
)
The error message for failed authentication should always say "Incorrect username or password" -- never "User not found" or "Wrong password" separately. Specific error messages let attackers enumerate valid usernames.
Protecting Routes with Dependency Injection
FastAPI's Depends() system is the core mechanism for protecting routes. You create a dependency function that extracts the JWT from the Authorization: Bearer header, decodes it, validates the claims, and returns the current user. Any route that declares this dependency is automatically protected -- if the token is missing, expired, or invalid, FastAPI returns a 401 response before your route handler ever runs.
# dependencies.py
import jwt
from jwt.exceptions import InvalidTokenError
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from models import TokenData, User, UserInDB
from auth import SECRET_KEY, ALGORITHM
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# Reference to your user database
from main import fake_users_db
async def get_current_user(
token: str = Depends(oauth2_scheme),
) -> UserInDB:
"""Decode the JWT and return the current user."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, SECRET_KEY, algorithms=[ALGORITHM]
)
username: str = payload.get("sub")
role: str = payload.get("role")
if username is None:
raise credentials_exception
token_data = TokenData(username=username, role=role)
except InvalidTokenError:
raise credentials_exception
user = fake_users_db.get(token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: UserInDB = Depends(get_current_user),
) -> UserInDB:
"""Ensure the user account is not disabled."""
if current_user.disabled:
raise HTTPException(
status_code=400, detail="Inactive user"
)
return current_user
The from main import fake_users_db line above creates a circular import in a real project. In production, extract your database logic into a separate module (e.g., database.py) that both main.py and dependencies.py import from. This tutorial keeps everything together for clarity, but always decouple your data layer in real applications.
Now protecting any route is a single Depends() call:
# Add to main.py
from dependencies import get_current_active_user
@app.get("/users/me", response_model=User)
async def read_users_me(
current_user: User = Depends(get_current_active_user),
):
return current_user
@app.get("/protected-data")
async def read_protected_data(
current_user: User = Depends(get_current_active_user),
):
return {
"message": f"Hello {current_user.full_name}",
"role": current_user.role,
}
The oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") declaration does two things: it creates the dependency that extracts the Bearer token from the header, and it tells FastAPI to add an "Authorize" button to the Swagger docs at /docs so you can test authenticated endpoints interactively.
Adding Role-Based Access Control
With the role claim already in the JWT, adding role-based restrictions is a matter of creating one more dependency layer. This function checks the decoded role and raises a 403 Forbidden error if the user does not have the required permission.
# dependencies.py (add to existing file)
def require_role(required_role: str):
"""Create a dependency that enforces a specific role."""
async def role_checker(
current_user: UserInDB = Depends(
get_current_active_user
),
) -> UserInDB:
if current_user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return current_user
return role_checker
# Usage in a route:
@app.get("/admin/dashboard")
async def admin_dashboard(
current_user: User = Depends(require_role("admin")),
):
return {
"message": "Welcome to the admin dashboard",
"user": current_user.username,
}
This pattern composes cleanly with FastAPI's dependency chain: the require_role function depends on get_current_active_user, which depends on get_current_user, which depends on oauth2_scheme. Each layer adds one check, and if any layer fails, the request is rejected before reaching the route handler.
Testing the Complete Flow
Start the server with uvicorn main:app --reload and open http://localhost:8000/docs in your browser. FastAPI's built-in Swagger UI lets you test every step of the authentication flow interactively. Here is the sequence:
- Get a token: Send a POST request to
/tokenwith your username and password as form data. The response includes your JWT. - Authorize: Click the "Authorize" button in the Swagger UI and paste the token. All subsequent requests will include it in the
Authorization: Bearerheader. - Access protected routes: Call
/users/meto see your user profile, or/admin/dashboardto test role-based access. - Test rejection: Try accessing
/admin/dashboardwith a non-admin token. The API returns a 403 Forbidden response.
# Command-line testing with curl:
# Step 1: Get a token
curl -X POST http://localhost:8000/token \
-d "username=kandi&password=secure-password-here"
# Response: {"access_token":"eyJhbGci...","token_type":"bearer"}
# Step 2: Use the token to access a protected route
curl http://localhost:8000/users/me \
-H "Authorization: Bearer eyJhbGci..."
# Step 3: Test the admin route
curl http://localhost:8000/admin/dashboard \
-H "Authorization: Bearer eyJhbGci..."
Common Pitfalls and Production Hardening
The code above is a working authentication system, but shipping it to production without addressing these pitfalls will leave gaps. These are the issues that tutorials rarely cover but that will surface the moment real users start hitting your API.
Token Revocation
JWTs are stateless by design -- once issued, a token is valid until it expires. If a user logs out or you need to revoke access immediately (compromised account, role change, employee termination), the token remains usable. The standard solution is a server-side token denylist: store the jti (the unique token ID we included in the claims) in a fast datastore like Redis with a TTL matching the token's remaining lifetime. Check the denylist in your get_current_user dependency before accepting the token. This adds a single cache lookup per request but gives you the ability to kill sessions instantly.
Refresh Tokens
Short-lived access tokens (15-30 minutes) are a security best practice, but forcing users to re-enter credentials every half hour is unusable. Implement a refresh token flow: issue a long-lived, opaque refresh token alongside the JWT. Store refresh tokens server-side (database, not a cookie). When the access token expires, the client sends the refresh token to a dedicated /token/refresh endpoint to get a new access token. This separates the short-lived bearer credential from the long-lived session credential, limiting the blast radius of a stolen access token.
HTTPS in Production
Bearer tokens sent over unencrypted HTTP are visible to anyone on the network. Always terminate TLS before your application, whether via a reverse proxy like Nginx, a load balancer, or a platform like Cloudflare. FastAPI's OAuth2PasswordBearer does not enforce HTTPS -- that is your infrastructure's responsibility.
Rate Limiting the Token Endpoint
The /token endpoint accepts usernames and passwords, making it a target for credential stuffing and brute-force attacks. Apply rate limiting (e.g., via slowapi or your reverse proxy) to restrict the number of login attempts per IP or per account within a time window. The OWASP Authentication Cheat Sheet recommends locking accounts or introducing progressive delays after a threshold of failed attempts.
Constant-Time Comparison
The authenticate_user function returns None immediately if the username is not found, but takes measurably longer if the user exists (because it runs the hash verification). This timing difference can leak whether a username is valid. A hardened version calls password_hash.hash("dummy-password") even when the user is not found, ensuring both code paths take roughly the same time.
CORS: The Wall Your Frontend Will Hit First
If your API and frontend run on different origins (which is the case for every single-page application, mobile app, or third-party integration), the browser will block requests before they reach your authentication logic. Cross-Origin Resource Sharing (CORS) is the mechanism that controls which domains can call your API. Without configuring it, authenticated requests from your frontend will fail with an opaque error, and no amount of correct JWT logic will help.
# Add to main.py -- CORS configuration
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://your-frontend-domain.com",
# Add specific origins, never use ["*"] with
# allow_credentials=True
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization"],
)
The critical mistake here is setting allow_origins=["*"] alongside allow_credentials=True. Browsers reject this combination outright -- and for good reason. A wildcard origin with credentials means any website on the internet could make authenticated requests to your API using your users' tokens. Always list specific, trusted origins. Think of it this way: your CORS policy defines the front door of your API. Leaving it wide open does not just invite guests -- it invites attackers to walk in carrying your users' credentials.
Where Should the Client Store the JWT?
The tutorial above returns a JWT in the response body, and the curl examples pass it manually in the Authorization header. But a real frontend application needs to persist that token somewhere between requests. This decision has significant security implications, and getting it wrong is one of the most common vulnerabilities in modern web applications.
There are three primary options, each with a distinct tradeoff:
localStorage: Simple to implement -- localStorage.setItem("token", jwt) and you are done. But localStorage is fully accessible to any JavaScript running on your page. If an attacker exploits a cross-site scripting (XSS) vulnerability, they can read your token and exfiltrate it to their own server. The OWASP community specifically recommends against storing session identifiers in local storage for this reason.
HttpOnly cookies: The server sets the JWT in a cookie with the HttpOnly, Secure, and SameSite=Strict flags. JavaScript cannot access HttpOnly cookies at all, which eliminates the XSS token-theft vector. The browser attaches the cookie to every request automatically. The tradeoff is that cookies are vulnerable to cross-site request forgery (CSRF) unless you pair them with a CSRF token or rely on SameSite enforcement. Cookies are also limited to about 4KB, which is rarely a problem for well-structured JWTs but worth knowing.
In-memory (the current recommended approach): Store the short-lived access token in a JavaScript variable (React state, a module-scoped variable, etc.) and store a long-lived refresh token in an HttpOnly cookie. This gives you the best of both patterns: the access token is invisible to cookie-based CSRF attacks, and the refresh token is invisible to XSS-based JavaScript theft. The downside is that a page refresh clears the in-memory access token, so your application needs to silently request a new one from the refresh endpoint on load. This is the approach described by OWASP as the current gold standard for browser-based applications.
No storage method is invulnerable in isolation. The purpose of choosing the right storage mechanism is to reduce your attack surface, not eliminate it. Token storage must be paired with strong Content Security Policy headers, input sanitization, and short token lifetimes to form a layered defense.
Silent Token Refresh: What Happens When the Token Expires Mid-Session
A 30-minute access token means that a user who is actively working in your application will get a 401 error every half hour unless you handle expiration transparently. The silent refresh pattern solves this: before (or immediately after) the access token expires, the client sends the refresh token to a dedicated endpoint and receives a new access token without the user ever seeing a login screen.
# Add a refresh endpoint to main.py
import secrets
from datetime import timedelta
# In production, store refresh tokens in a database
# (not in memory) with user association and expiry
refresh_token_store: dict[str, str] = {}
@app.post("/token/refresh", response_model=Token)
async def refresh_access_token(
refresh_token: str,
):
"""Exchange a valid refresh token for a new access token."""
username = refresh_token_store.get(refresh_token)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
user = fake_users_db.get(username)
if not user or user.disabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
# Issue a new access token
new_access_token = create_access_token(
data={"sub": user.username, "role": user.role},
expires_delta=timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
),
)
# Rotate the refresh token (one-time use)
del refresh_token_store[refresh_token]
new_refresh_token = secrets.token_urlsafe(64)
refresh_token_store[new_refresh_token] = username
return Token(
access_token=new_access_token,
token_type="bearer",
)
# Return new_refresh_token via HttpOnly cookie
# in production (not in the JSON body)
The key detail in this pattern is refresh token rotation: every time a refresh token is used, it is invalidated and a new one is issued. If an attacker steals a refresh token and the legitimate user also tries to use it, one of them will trigger an invalid-token error -- which is your signal that the refresh token family has been compromised. At that point, revoke all tokens for that user and force re-authentication.
Sources and References
- FastAPI Official Documentation -- OAuth2 with JWT: fastapi.tiangolo.com/tutorial/security/oauth2-jwt/
- OWASP Password Storage Cheat Sheet: cheatsheetseries.owasp.org -- Password Storage
- RFC 9106 -- Argon2 Memory-Hard Function: rfc-editor.org/rfc/rfc9106
- pwdlib Documentation: frankie567.github.io/pwdlib/
- FastAPI PR #13917 -- Migration from passlib to pwdlib: github.com/fastapi/fastapi/pull/13917 (merged September 29, 2025)
- fastapi-users v13.0.0 Release Notes: github.com/fastapi-users -- v13.0.0
- PEP 594 -- Removing dead batteries from the standard library: peps.python.org/pep-0594/ (removal of the crypt module)
- PyJWT Documentation: pyjwt.readthedocs.io
- OWASP Authentication Cheat Sheet: cheatsheetseries.owasp.org -- Authentication
- RFC 6749 -- The OAuth 2.0 Authorization Framework: rfc-editor.org/rfc/rfc6749 (Section 4.3.2 -- Resource Owner Password Credentials Grant)
- FastAPI CORS Middleware Documentation: fastapi.tiangolo.com/tutorial/cors/
- OWASP Session Management Cheat Sheet: cheatsheetseries.owasp.org -- Session Management
Key Takeaways
- Use pwdlib with Argon2 for password hashing: This is FastAPI's current recommended approach, replacing the unmaintained passlib library. Argon2's memory-hard design resists GPU-based brute-force attacks far more effectively than bcrypt alone.
- Leverage FastAPI's dependency injection for route protection: The
Depends()system makes authentication explicit, composable, and self-documenting. Each dependency layer handles one concern -- token extraction, signature verification, active-user check, role validation -- and they chain together cleanly. - Keep tokens short-lived and include only necessary claims: Set access tokens to expire in 15-30 minutes. Include
sub,exp,iat,jti, androlein the payload. Never store sensitive data in the JWT because it is signed, not encrypted -- anyone who intercepts the token can read the payload. - Use generic error messages for authentication failures: Always respond with "Incorrect username or password" rather than specifying which field was wrong. This prevents username enumeration attacks.
- Store secrets in environment variables, not source code: Use a
.envfile for local development (excluded from version control) and a cloud secrets manager for production. Generate your JWT signing key withopenssl rand -hex 32. - Configure CORS before your first frontend integration: Explicitly list trusted origins and never combine
allow_origins=["*"]withallow_credentials=True. A misconfigured CORS policy can silently expose your API to cross-origin attacks. - Choose token storage based on your threat model: For browser applications, the current recommended pattern is storing short-lived access tokens in memory and refresh tokens in HttpOnly cookies. This defends against both XSS and CSRF. No single storage method is sufficient without layered defenses including Content Security Policy and input sanitization.
- Implement refresh token rotation for production session management: Invalidate each refresh token after a single use. If a refresh token is used twice, treat the entire token family as compromised and force re-authentication.
FastAPI's opinionated-but-flexible security system gives you production-ready authentication without forcing you into a specific architecture. The OAuth2 password flow with JWT tokens covers the majority of API authentication needs, and the dependency injection pattern makes it natural to layer on additional checks like role-based access control, rate limiting, or multi-factor verification as your application grows. But remember that authentication code is only one layer of a secure system. The decisions you make about token storage, CORS policy, refresh token handling, and algorithm selection determine whether your API is robust in practice or only in theory. Every design choice in an authentication system is a tradeoff between security, usability, and complexity -- the goal is to make those tradeoffs intentionally, with a clear understanding of what each one costs.