FastAPI has become one of the most popular Python frameworks for building modern APIs. It is fast, developer-friendly, and ships with built-in support for OpenAPI, validation, dependency injection, and security flows.
One of the most common requirements for any production API is authentication. In this tutorial, you will learn how to build FastAPI + JWT/OAuth2 authentication from scratch. You will implement:
- User registration
- Login with OAuth2 password flow
- JWT access tokens
- JWT refresh tokens
- Token refresh rotation
- Logout/refresh revocation
- Protected routes using Bearer tokens
- Security and production best practices
By the end of this article, you will understand not only how to implement authentication in FastAPI, but also how JWTs work, what OAuth2 really means in this context, and how to prepare your authentication system for production.
Table of Contents
- What You Will Build
- OAuth2 vs JWT: Clearing Up the Confusion
- Project Setup
- Install Dependencies
- Complete FastAPI JWT/OAuth2 Authentication Code
- How the Authentication Flow Works
- Understanding Password Hashing with Argon2
- Understanding JWT Access and Refresh Tokens
- Protecting Routes with FastAPI Dependencies
- Testing the API with Swagger UI and cURL
- Refresh Tokens, Rotation, and Revocation
- Moving from In-Memory Users to a Real Database
- Adding OAuth2 Scopes
- Common Errors and Troubleshooting
- Security Considerations
- Production Considerations
- FAQ
- Conclusion
What You Will Build
You will build a simple but realistic authentication API using FastAPI.
The API will include:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/register | POST | Register a new user |
/auth/token | POST | Login and receive JWT tokens |
/auth/refresh | POST | Refresh access token using refresh token |
/auth/logout | POST | Revoke refresh token |
/users/me | GET | Protected route returning current user |
/health | GET | Health check |
The example uses an in-memory user store for simplicity. Later, you will see how to adapt it to a database such as PostgreSQL.
OAuth2 vs JWT: Clearing Up the Confusion
Many tutorials say “OAuth2 JWT authentication” as if they are the same thing. They are not.
What is OAuth2?
OAuth2 is an authorization framework. It defines ways for clients to obtain delegated access to resources.
FastAPI supports several OAuth2 flows, including:
- Authorization Code flow
- Implicit flow
- Client Credentials flow
- Resource Owner Password Credentials flow
In this tutorial, we use the OAuth2 password flow, also known as the Resource Owner Password Credentials grant. This is commonly used for first-party applications where the same organization controls the frontend and backend.
What is JWT?
JWT stands for JSON Web Token. It is a token format.
A JWT usually contains:
- Header
- Payload
- Signature
Example JWT payload:
{
"sub": "alice",
"type": "access",
"jti": "123e4567-e89b-12d3-a456-426614174000",
"iat": 1700000000,
"exp": 1700000900,
"iss": "fastapi-auth-example",
"aud": "fastapi-auth-api"
}
JWTs are signed, not encrypted by default. Do not put sensitive information inside a JWT unless it is also encrypted.
OAuth2 + JWT in FastAPI
In this article:
- OAuth2 defines how credentials and tokens are exchanged.
- JWT is the format of the issued access and refresh tokens.
- FastAPI dependencies validate incoming Bearer tokens.
Important: The OAuth2 password flow is convenient for learning and first-party apps, but OAuth 2.1 de-emphasizes it. For third-party clients, prefer Authorization Code flow with PKCE or an external identity provider.
Project Setup
Create a new project directory:
mkdir fastapi-jwt-auth
cd fastapi-jwt-auth
Create a virtual environment:
python -m venv .venv
Activate it:
Linux/macOS
source .venv/bin/activate
Windows PowerShell
.venv\Scripts\Activate.ps1
Install Dependencies
Install FastAPI, Uvicorn, JWT, Argon2 password hashing, and multipart form support:
pip install "fastapi[standard]" "uvicorn[standard]" PyJWT argon2-cffi python-multipart
Create a requirements.txt file:
fastapi[standard]
uvicorn[standard]
PyJWT
argon2-cffi
python-multipart
Generate a secure secret key:
Linux/macOS
export SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
Windows PowerShell
$env:SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))"
For production, use a proper secret manager or environment variable system instead of manually exporting secrets.
Complete FastAPI JWT/OAuth2 Authentication Code
Create a file called main.py and add the following code.
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import Argon2Error
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError(
"SECRET_KEY environment variable is required. "
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
TOKEN_ISSUER = "fastapi-auth-example"
TOKEN_AUDIENCE = "fastapi-auth-api"
# ---------------------------------------------------------------------
# App and security objects
# ---------------------------------------------------------------------
app = FastAPI(title="FastAPI JWT/OAuth2 Authentication")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
ph = PasswordHasher()
# In production, use Redis or a database for revoked tokens.
revoked_refresh_jtis: set[str] = set()
# ---------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------
class User(BaseModel):
username: str
full_name: Optional[str] = None
disabled: bool = False
class UserInDB(User):
hashed_password: str
class UserCreate(BaseModel):
username: str = Field(
min_length=3,
max_length=32,
pattern=r"^[a-zA-Z0-9_-]+$"
)
password: str = Field(
min_length=8,
max_length=128
)
full_name: Optional[str] = None
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
refresh_token: str
class RefreshRequest(BaseModel):
refresh_token: str
# ---------------------------------------------------------------------
# Fake database
# ---------------------------------------------------------------------
fake_users_db: dict[str, UserInDB] = {}
# ---------------------------------------------------------------------
# Password utilities
# ---------------------------------------------------------------------
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return ph.verify(hashed_password, plain_password)
except Argon2Error:
return False
# ---------------------------------------------------------------------
# User utilities
# ---------------------------------------------------------------------
def get_user(username: str) -> Optional[UserInDB]:
return fake_users_db.get(username)
def authenticate_user(username: str, password: str) -> Optional[UserInDB]:
user = get_user(username)
if not user:
return None
if user.disabled:
return None
if not verify_password(password, user.hashed_password):
return None
return user
# ---------------------------------------------------------------------
# JWT utilities
# ---------------------------------------------------------------------
def create_token(subject: str, expires_delta: timedelta, token_type: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"type": token_type,
"jti": str(uuid.uuid4()),
"iat": int(now.timestamp()),
"exp": int((now + expires_delta).timestamp()),
"iss": TOKEN_ISSUER,
"aud": TOKEN_AUDIENCE,
}
token = jwt.encode(
payload,
SECRET_KEY,
algorithm=ALGORITHM
)
return token
def decode_token(token: str, expected_type: str) -> dict:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
audience=TOKEN_AUDIENCE,
issuer=TOKEN_ISSUER,
options={
"require": ["exp", "iat", "sub", "jti"]
}
)
except jwt.InvalidTokenError:
raise credentials_exception
if payload.get("type") != expected_type:
raise credentials_exception
return payload
# ---------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
payload = decode_token(token, expected_type="access")
username = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token subject",
headers={"WWW-Authenticate": "Bearer"},
)
user = get_user(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if user.disabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return user
# ---------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------
@app.post(
"/auth/register",
response_model=User,
status_code=status.HTTP_201_CREATED,
tags=["auth"]
)
def register(payload: UserCreate):
existing_user = get_user(payload.username)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
hashed_password = hash_password(payload.password)
user = UserInDB(
username=payload.username,
full_name=payload.full_name,
disabled=False,
hashed_password=hashed_password
)
fake_users_db[user.username] = user
return user
@app.post(
"/auth/token",
response_model=Token,
tags=["auth"]
)
def login(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_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_token(
subject=user.username,
expires_delta=access_expires,
token_type="access"
)
refresh_token = create_token(
subject=user.username,
expires_delta=refresh_expires,
token_type="refresh"
)
return Token(
access_token=access_token,
expires_in=int(access_expires.total_seconds()),
refresh_token=refresh_token
)
@app.post(
"/auth/refresh",
response_model=Token,
tags=["auth"]
)
def refresh(payload: RefreshRequest):
token_payload = decode_token(
payload.refresh_token,
expected_type="refresh"
)
jti = token_payload.get("jti")
if jti is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
if jti in revoked_refresh_jtis:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has been revoked",
)
# Rotate refresh token: revoke the old one.
revoked_refresh_jtis.add(jti)
username = token_payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token subject",
)
user = get_user(username)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
if user.disabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
access_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_token(
subject=user.username,
expires_delta=access_expires,
token_type="access"
)
new_refresh_token = create_token(
subject=user.username,
expires_delta=refresh_expires,
token_type="refresh"
)
return Token(
access_token=access_token,
expires_in=int(access_expires.total_seconds()),
refresh_token=new_refresh_token
)
@app.post(
"/auth/logout",
tags=["auth"]
)
def logout(payload: RefreshRequest):
"""
Revoke a refresh token.
Since JWT access tokens are stateless, this does not instantly revoke
an access token. It prevents the refresh token from being used again.
"""
try:
token_payload = decode_token(
payload.refresh_token,
expected_type="refresh"
)
except HTTPException:
# Do not leak whether the token was invalid or expired.
return {"detail": "Logged out"}
jti = token_payload.get("jti")
if jti:
revoked_refresh_jtis.add(jti)
return {"detail": "Logged out"}
# ---------------------------------------------------------------------
# Protected routes
# ---------------------------------------------------------------------
@app.get(
"/users/me",
response_model=User,
tags=["users"]
)
def read_current_user(current_user: UserInDB = Depends(get_current_user)):
return current_user
# ---------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------
@app.get(
"/health",
tags=["health"]
)
def health():
return {"status": "ok"}
Run the application:
uvicorn main:app --reload
Open the interactive docs:
http://127.0.0.1:8000/docs
How the Authentication Flow Works
The authentication process works like this:
- The client registers a user with
/auth/register. - The client sends username and password to
/auth/token. - FastAPI validates the credentials.
- The server returns:
- A short-lived access token
- A longer-lived refresh token
- The client calls protected endpoints using:
Authorization: Bearer ACCESS_TOKEN
- FastAPI extracts the token using
OAuth2PasswordBearer. - The
get_current_userdependency validates the JWT. - If valid, the route handler receives the current user.
- When the access token expires, the client calls
/auth/refreshwith the refresh token. - The server revokes the old refresh token and issues new tokens.
Understanding Password Hashing with Argon2
Never store passwords in plain text.
This example uses Argon2 via the argon2-cffi library.
ph = PasswordHasher()
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return ph.verify(hashed_password, plain_password)
except Argon2Error:
return False
Argon2 is a strong password hashing algorithm designed to resist brute-force attacks. It is generally preferred over older algorithms such as MD5 or SHA1.
In production, you can tune Argon2 parameters such as:
- Time cost
- Memory cost
- Parallelism
For most applications, the defaults are a reasonable starting point, but high-security systems may need custom tuning.
Understanding JWT Access and Refresh Tokens
This implementation issues two token types:
Access Token
The access token is used to access protected API endpoints.
It should be short-lived, for example:
ACCESS_TOKEN_EXPIRE_MINUTES = 15
Example claims:
{
"sub": "alice",
"type": "access",
"jti": "some-unique-token-id",
"iat": 1700000000,
"exp": 1700000900,
"iss": "fastapi-auth-example",
"aud": "fastapi-auth-api"
}
Refresh Token
The refresh token is used to obtain a new access token.
It should be longer-lived, for example:
REFRESH_TOKEN_EXPIRE_DAYS = 7
Example claims:
{
"sub": "alice",
"type": "refresh",
"jti": "another-unique-token-id",
"iat": 1700000000,
"exp": 1700604800,
"iss": "fastapi-auth-example",
"aud": "fastapi-auth-api"
}
The type claim prevents a refresh token from being used as an access token.
The jti claim gives the token a unique ID, which makes revocation possible.
Protecting Routes with FastAPI Dependencies
FastAPI makes route protection simple with dependencies.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
This tells FastAPI where to obtain tokens and how to extract the Bearer token from incoming requests.
Then:
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
payload = decode_token(token, expected_type="access")
username = payload.get("sub")
...
Finally, protect a route:
@app.get("/users/me")
def read_current_user(current_user: UserInDB = Depends(get_current_user)):
return current_user
If the token is missing, invalid, expired, or not an access token, FastAPI returns a 401 Unauthorized response.
Testing the API with Swagger UI and cURL
1. Register a User
curl -X POST http://127.0.0.1:8000/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"password": "supersecret123",
"full_name": "Alice Example"
}'
Expected response:
{
"username": "alice",
"full_name": "Alice Example",
"disabled": false
}
2. Login and Get Tokens
OAuth2 password flow expects form-encoded data, not JSON.
curl -X POST http://127.0.0.1:8000/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=alice&password=supersecret123"
Expected response:
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token": "eyJ..."
}
3. Access a Protected Route
ACCESS_TOKEN="YOUR_ACCESS_TOKEN"
curl http://127.0.0.1:8000/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
Expected response:
{
"username": "alice",
"full_name": "Alice Example",
"disabled": false
}
4. Refresh Tokens
REFRESH_TOKEN="YOUR_REFRESH_TOKEN"
curl -X POST http://127.0.0.1:8000/auth/refresh \
-H "Content-Type: application/json" \
-d "{
\"refresh_token\": \"$REFRESH_TOKEN\"
}"
5. Logout
REFRESH_TOKEN="YOUR_REFRESH_TOKEN"
curl -X POST http://127.0.0.1:8000/auth/logout \
-H "Content-Type: application/json" \
-d "{
\"refresh_token\": \"$REFRESH_TOKEN\"
}"
Refresh Tokens, Rotation, and Revocation
This example implements simple refresh token rotation.
When a refresh token is used:
revoked_refresh_jtis.add(jti)
The old refresh token is revoked and a new refresh token is issued.
This helps reduce damage if a refresh token is leaked.
However, the example stores revoked tokens in memory:
revoked_refresh_jtis: set[str] = set()
That is fine for learning, but not enough for production.
In production, use a persistent store such as:
- Redis
- PostgreSQL
- DynamoDB
- Memcached
Redis is a good fit because you can store revoked token IDs with an expiration:
SET revoked_refresh:TOKEN_JTI 1 EX 604800
Where 604800 is seven days in seconds.
Moving from In-Memory Users to a Real Database
The example uses:
fake_users_db: dict[str, UserInDB] = {}
In production, replace this with a database.
Example SQLAlchemy-style user model:
class UserRow(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(
unique=True,
index=True,
nullable=False
)
hashed_password: Mapped[str] = mapped_column(nullable=False)
full_name: Mapped[Optional[str]]
disabled: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow
)
Then replace:
def get_user(username: str) -> Optional[UserInDB]:
return fake_users_db.get(username)
With a database query such as:
def get_user(db: Session, username: str) -> Optional[UserRow]:
return db.query(UserRow).filter(UserRow.username == username).first()
Important database considerations:
- Add a unique constraint on username or email.
- Use migrations with Alembic.
- Never store plain-text passwords.
- Add indexes for login lookup fields.
- Use database transactions for writes.
- Consider adding
password_changed_at,failed_login_attempts, andlast_login_at.
Adding OAuth2 Scopes
OAuth2 scopes allow more granular permissions.
FastAPI supports scopes with OAuth2PasswordBearer:
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/auth/token",
scopes={
"items:read": "Read items",
"items:write": "Create or update items"
}
)
You can then include scopes in the token payload and validate them in dependencies.
Example idea:
{
"sub": "alice",
"scope": "items:read items:write"
}
Then create a dependency that checks whether the current token contains the required scope.
For many applications, roles and permissions stored in the database are more flexible than putting all permissions directly into JWTs.
Common Errors and Troubleshooting
401 Unauthorized: Not authenticated
This often means the Authorization header is missing or incorrectly formatted.
Correct header:
Authorization: Bearer YOUR_ACCESS_TOKEN
Incorrect:
Authorization: YOUR_ACCESS_TOKEN
401 Unauthorized: Invalid or expired token
Possible causes:
- Token expired
- Wrong
SECRET_KEY - Token was generated by another environment
- Token signature is invalid
- Token audience or issuer does not match
- Using refresh token as access token
Check:
TOKEN_ISSUER
TOKEN_AUDIENCE
SECRET_KEY
ALGORITHM
422 Validation Error
This usually means the request body or form data failed validation.
For /auth/token, remember that OAuth2 expects form-encoded data:
Content-Type: application/x-www-form-urlencoded
Not:
Content-Type: application/json
400 Username already registered
The username already exists in the user store.
In a database-backed app, ensure your database has a unique constraint on username.
Signature Verification Failed
This usually means the token was signed with a different secret.
Common causes:
- Different
SECRET_KEYbetween services - Secret changed after tokens were issued
- Development and production secrets mixed up
Token Works Locally but Fails in Production
Check:
- HTTPS configuration
- Environment variables
- Clock synchronization
- Token issuer and audience
- Reverse proxy headers
- CORS configuration
- Secret key deployment
Time drift is a common cause of JWT validation failures.
Security Considerations
Authentication is security-critical. The following points are essential.
1. Always Use HTTPS
Never transmit credentials or tokens over plain HTTP.
Use HTTPS everywhere, especially for:
/auth/register/auth/token/auth/refresh- Protected API routes
Without HTTPS, attackers can intercept passwords and tokens.
2. Keep Access Tokens Short-Lived
Use short expiration times for access tokens.
Recommended:
ACCESS_TOKEN_EXPIRE_MINUTES = 5 to 15
Short-lived access tokens reduce the impact of token theft.
3. Use Refresh Token Rotation
Refresh token rotation issues a new refresh token every time the old one is used.
This example implements basic rotation:
revoked_refresh_jtis.add(jti)
For stronger security, implement refresh token reuse detection. If a previously revoked refresh token is presented again, invalidate the whole token family and force re-login.
4. Store Revoked Tokens Persistently
The in-memory set is not safe for production:
revoked_refresh_jtis: set[str] = set()
If the app restarts, revoked tokens become valid again.
Use Redis, a database table, or another persistent store.
Example Redis key:
revoked_refresh:{jti}
Set the key expiration to match the refresh token expiration.
5. Do Not Put Sensitive Data in JWTs
JWTs are signed, not encrypted by default.
Avoid storing:
- Passwords
- Personal identification numbers
- Secret tokens
- Payment data
- Sensitive personal data
Only store claims your API needs.
6. Validate Token Claims
This example validates:
- Signature
- Expiration
- Issued-at time
- Subject
- Token type
- Issuer
- Audience
- JWT ID
In production, always validate:
exp
iat
sub
iss
aud
If you use jti, also validate revocation status.
7. Use Strong Password Hashing
This article uses Argon2, which is a strong modern choice.
Other acceptable options include:
- bcrypt
- scrypt
Avoid:
- MD5
- SHA1
- Unsalted hashes
- Reversible encryption for passwords
8. Protect Against Brute Force
Add:
- Rate limiting on login
- Account lockout after repeated failures
- CAPTCHA after suspicious activity
- Monitoring for credential stuffing
Example sensitive endpoints to protect:
/auth/token
/auth/register
/auth/refresh
9. Use Secure Secret Management
Do not hard-code secrets.
Bad:
SECRET_KEY = "mysecret"
Better:
SECRET_KEY = os.getenv("SECRET_KEY")
Best for production:
- AWS Secrets Manager
- HashiCorp Vault
- Azure Key Vault
- Google Secret Manager
- Kubernetes secrets with encryption
- Environment-specific secret injection
Rotate secrets periodically.
10. Choose the Right JWT Signing Algorithm
This example uses:
ALGORITHM = "HS256"
HS256 uses a shared secret.
It can work well when:
- One service signs tokens
- The same service verifies tokens
- You control all verification parties
For distributed systems, consider asymmetric algorithms such as:
- RS256
- ES256
With asymmetric algorithms:
- Authorization server signs with a private key
- Resource servers verify with a public key
- Public keys can be published using JWKS
11. Be Careful Where Tokens Are Stored in Clients
browser apps:
localStorageis vulnerable to XSS- Session storage is also vulnerable to XSS
- HttpOnly cookies reduce JavaScript access but require CSRF protection
mobile apps:
- Use secure device storage
- Use platform keychain/keystore where possible
server-to-server:
- Store tokens in memory or secure secret stores
- Avoid logging tokens
12. Do Not Leak User Enumeration
For login, use a generic error:
detail="Incorrect username or password"
Avoid:
detail="User not found"
or:
detail="Password incorrect"
This makes it harder for attackers to discover valid usernames.
13. Use CSRF Protection If Using Cookies
If you switch from Bearer headers to cookie-based authentication, add CSRF protection.
Bearer headers are not automatically sent by the browser, which reduces CSRF risk. Cookies are automatically sent, which increases CSRF risk.
14. Consider External Identity Providers
For serious production systems, consider using:
- Keycloak
- Auth0
- AWS Cognito
- Azure AD
- Google Identity Platform
- Okta
These providers handle many complex security concerns for you.
Production Considerations
Before deploying FastAPI JWT/OAuth2 authentication to production, review the following checklist.
1. Use Environment Variables for All Configuration
Do not hard-code:
SECRET_KEY- Database URLs
- Token lifetimes
- Issuer and audience values
- CORS origins
- Feature flags
Use environment variables or secret management tools.
2. Use a Real Database
Replace the in-memory user store.
Good options:
- PostgreSQL
- MySQL
- SQLite for small internal tools
- MongoDB if document storage fits your architecture
PostgreSQL is a strong default for most production APIs.
3. Add Database Migrations
Use Alembic with SQLAlchemy:
alembic init alembic
alembic revision --autogenerate -m "add users table"
alembic upgrade head
Migrations prevent manual schema mistakes during deployment.
4. Use Redis for Token Revocation
For refresh token revocation, Redis is often the best fit.
Store:
revoked_refresh:{jti}
With TTL equal to the remaining token lifetime.
This avoids unbounded growth of revocation data.
5. Add Rate Limiting
Use a rate limiting library or reverse proxy.
Options:
- SlowAPI
- NGINX rate limiting
- Cloudflare rate limiting
- AWS API Gateway throttling
- FastAPI middleware with Redis backend
Prioritize protection for:
/auth/token
/auth/register
/auth/refresh
/auth/logout
6. Add Logging and Monitoring
Log important authentication events:
- Successful login
- Failed login
- Token refresh
- Logout
- Revoked token usage
- Suspicious repeated failures
Do not log:
- Passwords
- Access tokens
- Refresh tokens
- Full authorization headers
- Sensitive personal data
Use structured logging and monitoring tools such as:
- OpenTelemetry
- Prometheus
- Grafana
- Sentry
- ELK stack
- Datadog
7. Disable or Protect OpenAPI Docs in Production
FastAPI Swagger UI is useful during development.
In production, you may disable it:
app = FastAPI(
title="API",
docs_url=None,
redoc_url=None,
openapi_url=None
)
Or protect it behind authentication, VPN, or IP allowlists.
8. Configure CORS Carefully
If your frontend is on a different domain, configure CORS.
Example:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
Avoid:
allow_origins=["*"]
when using credentials.
9. Use a Reverse Proxy
Deploy FastAPI behind:
- NGINX
- Traefik
- Caddy
- HAProxy
- Cloudflare
- AWS ALB
A reverse proxy can handle:
- TLS termination
- Request buffering
- Rate limiting
- Compression
- Security headers
- Load balancing
10. Set Security Headers
Add headers such as:
Strict-Transport-Security
X-Content-Type-Options
X-Frame-Options
Referrer-Policy
Content-Security-Policy
The exact headers depend on your client type and deployment.
11. Keep Server Time Synchronized
JWT validation depends on timestamps.
Use NTP or your cloud provider’s time synchronization service.
Clock drift can cause:
- Expired token errors
- Invalid
iatclaims - Intermittent authentication failures
12. Use Structured Error Responses
Keep errors consistent.
Example:
{
"detail": "Invalid or expired token"
}
Do not expose:
- Stack traces
- Database errors
- Internal file paths
- Secret configuration details
13. Add Automated Tests
At minimum, test:
- Registration
- Duplicate registration
- Login success
- Login failure
- Accessing protected route without token
- Accessing protected route with invalid token
- Accessing protected route with expired token
- Refresh token flow
- Refresh token rotation
- Logout revocation
Use fastapi.testclient.TestClient or httpx.AsyncClient.
14. Decide Where Tokens Live on the Client
Your choice depends on the client type.
| Client | Common Approach | Notes |
|---|---|---|
| SPA | Memory + refresh via HttpOnly cookie | More secure if implemented carefully |
| SPA | localStorage | Simple but vulnerable to XSS |
| Mobile | Secure storage/keychain | Recommended |
| Server-to-server | Environment/secret manager | Do not log tokens |
| Traditional web app | HttpOnly session cookie | Requires CSRF protection |
There is no universally perfect browser storage option. Choose based on threat model.
15. Plan Secret Rotation
You need a process for rotating SECRET_KEY.
Options:
- Accept tokens signed by old and new keys temporarily
- Force users to re-login
- Use asymmetric keys with JWKS and key IDs
For high-traffic systems, plan rotation carefully.
FAQ
Is JWT the same as OAuth2?
No. OAuth2 is an authorization framework. JWT is a token format. FastAPI can issue JWTs as part of an OAuth2 flow.
Should I use the OAuth2 password flow?
It is useful for first-party apps and learning. For third-party apps, public clients, or modern OAuth 2.1-aligned systems, prefer Authorization Code flow with PKCE or an external identity provider.
How long should a JWT access token live?
Usually 5 to 15 minutes. Shorter lifetimes reduce risk if a token is stolen.
How long should a refresh token live?
Common choices are days or weeks, depending on security requirements. Use rotation and revocation.
Can I revoke a JWT access token immediately?
Not easily, because JWTs are stateless. You can use short expiration times and maintain a blacklist, but that reduces some benefits of statelessness.
Refresh tokens are easier to revoke because they can be checked against a database or Redis.
Should I store JWTs in localStorage?
It is common but risky if your app has XSS vulnerabilities. For browser apps, consider HttpOnly cookies with CSRF protection, or keep tokens in memory and use secure refresh mechanisms.
Should I use HS256 or RS256?
HS256 is simpler and works when the same service signs and verifies tokens.
RS256 or ES256 is better when multiple services need to verify tokens without sharing a secret.
Is this example production-ready?
It demonstrates the core authentication pattern securely, but production systems need:
- Database storage
- Persistent token revocation
- Rate limiting
- Monitoring
- HTTPS
- Secret management
- Automated tests
- Deployment hardening
Conclusion
You now know how to build FastAPI + JWT/OAuth2 authentication from scratch using access tokens, refresh tokens, password hashing, and protected routes.
The key ideas are:
- Use OAuth2 password flow carefully and mainly for first-party applications.
- Use JWTs for short-lived access tokens.
- Use refresh tokens with rotation and revocation.
- Hash passwords with Argon2, bcrypt, or scrypt.
- Validate token signature, expiration, issuer, audience, and token type.
- Store secrets securely and never hard-code them.
- Use HTTPS in every environment that touches real users.
- Replace in-memory storage with a database and Redis before production.
FastAPI makes authentication implementation approachable, but real-world security requires careful design. Start with a simple, correct implementation, then harden it with monitoring, rate limiting, revocation, and proper infrastructure.
If you are building a serious production system, consider whether an external identity provider such as Keycloak, Auth0, AWS Cognito, or Azure AD is a better long-term choice. But for learning FastAPI security and building first-party APIs, this JWT/OAuth2 pattern is an excellent foundation.