FastAPI JWT authentication FastAPI JWT authentication

FastAPI + JWT/OAuth2 Authentication from Scratch: A Complete Guide

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

  1. What You Will Build
  2. OAuth2 vs JWT: Clearing Up the Confusion
  3. Project Setup
  4. Install Dependencies
  5. Complete FastAPI JWT/OAuth2 Authentication Code
  6. How the Authentication Flow Works
  7. Understanding Password Hashing with Argon2
  8. Understanding JWT Access and Refresh Tokens
  9. Protecting Routes with FastAPI Dependencies
  10. Testing the API with Swagger UI and cURL
  11. Refresh Tokens, Rotation, and Revocation
  12. Moving from In-Memory Users to a Real Database
  13. Adding OAuth2 Scopes
  14. Common Errors and Troubleshooting
  15. Security Considerations
  16. Production Considerations
  17. FAQ
  18. Conclusion

What You Will Build

You will build a simple but realistic authentication API using FastAPI.

The API will include:

EndpointMethodPurpose
/auth/registerPOSTRegister a new user
/auth/tokenPOSTLogin and receive JWT tokens
/auth/refreshPOSTRefresh access token using refresh token
/auth/logoutPOSTRevoke refresh token
/users/meGETProtected route returning current user
/healthGETHealth 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:

  1. The client registers a user with /auth/register.
  2. The client sends username and password to /auth/token.
  3. FastAPI validates the credentials.
  4. The server returns:
  • A short-lived access token
  • A longer-lived refresh token
  1. The client calls protected endpoints using:
Authorization: Bearer ACCESS_TOKEN
  1. FastAPI extracts the token using OAuth2PasswordBearer.
  2. The get_current_user dependency validates the JWT.
  3. If valid, the route handler receives the current user.
  4. When the access token expires, the client calls /auth/refresh with the refresh token.
  5. 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, and last_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_KEY between 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:

  • localStorage is 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 iat claims
  • 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.

ClientCommon ApproachNotes
SPAMemory + refresh via HttpOnly cookieMore secure if implemented carefully
SPAlocalStorageSimple but vulnerable to XSS
MobileSecure storage/keychainRecommended
Server-to-serverEnvironment/secret managerDo not log tokens
Traditional web appHttpOnly session cookieRequires 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.