HomeBlog

A Guide to Authentication in FastAPI with JWT

10 min read
A shield icon protecting a login form, symbolizing API security and authentication in FastAPI.
By David Muraya • September 21 2025

When you build an API, securing it is not an optional step; it's a critical responsibility. Vulnerabilities in authentication are consistently listed as a top security risk by industry standards like the OWASP Top 10, where "Identification and Authentication Failures" can lead to catastrophic data breaches.

The consequences of getting this wrong are severe. In 2022, the Australian telecommunications company Optus suffered a massive data breach where an unauthenticated API endpoint allowed attackers to access the personal data of nearly 10 million customers. Similarly, in 2021, a flaw in Peloton's API allowed unauthenticated requests to access private user account data, including location, gender, and age. These incidents highlight how a single insecure API can expose sensitive information on a massive scale. Proper authentication ensures that only legitimate users can perform actions, protecting both your application and your users' data from such disasters.

In this guide, we'll walk through how to implement authentication in FastAPI correctly. We will start with the simplest method, HTTP Basic Auth, to understand the core concepts. Then, we will move on to building a secure, production-ready system using the industry-standard OAuth2 protocol with JWT tokens for handling user sessions.

The Simplest Method: HTTP Basic Auth

HTTP Basic Auth is the most straightforward way to protect an endpoint. The browser sends a username and password with every request.

FastAPI makes this easy to implement.

from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

security = HTTPBasic()

@app.get("/users/me")
def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
    return {"username": credentials.username, "password": credentials.password}

Here's what's happening:

  1. We create a security object using HTTPBasic().
  2. We use Depends(security) in our endpoint. FastAPI uses this to automatically handle the authentication flow.
  3. If the client provides valid credentials, the credentials object will contain the username and password.

But, Basic Auth has significant drawbacks. It sends the username and password in plain text (Base64 encoded, which is easily reversed) with every single request. This is insecure, especially over an unencrypted connection. For modern applications, we need something better.

The Modern Standard: OAuth2 with JWT

OAuth2 is the industry-standard protocol for authorization. We'll use one of its common flows, the "Password Flow," where a user exchanges a username and password for an access token. This token is then used for all subsequent requests.

The token itself will be a JSON Web Token (JWT).

What is a JWT?

A JWT is a compact, URL-safe string that contains JSON data. It looks like a random jumble of characters, but it's composed of three parts: a header, a payload, and a signature.

  • It's signed, not encrypted. This means anyone can read the data inside a JWT, but they can't tamper with it. The signature guarantees that the token was created by you and hasn't been changed.

Securing Passwords with Hashing

Before we can authenticate a user, we need to store their password securely. You should never store passwords in plain text. If your database is ever compromised, attackers would have access to every user's password.

Instead, we store a "hash" of the password. A hash is a one-way conversion of the password into a gibberish-looking string. You can't reverse the hash to get the original password, but you can check if a given password matches a stored hash. For best practices, see the OWASP Password Storage Cheat Sheet.

Following FastAPI's latest recommendations, we'll use the pwdlib library with the Argon2 algorithm. Argon2 is a modern, secure hashing algorithm designed to be resistant to GPU cracking attacks.

First, install pwdlib with Argon2 support:

pip install "pwdlib[argon2]"

Here's how to create and verify password hashes:

# In a file like auth_security.py
from pwdlib import PasswordHash

# Create a PasswordHash instance with recommended settings (uses Argon2)
password_hash = PasswordHash.recommended()

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Checks if a plain password matches a hashed password."""
    return password_hash.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """Hashes a plain password."""
    return password_hash.hash(password)

The User Model

Our authentication functions will need a database model to represent a user. This model will store the user's email, the hashed password, and an is_active flag.

Here is what a simple User model using SQLModel looks like.

from typing import ClassVar, Optional
from sqlmodel import Field, SQLModel

# Assuming TimestampMixin is defined as in the mixins article
from .mixins import TimestampMixin

class User(TimestampMixin, SQLModel, table=True):
    """Represents a user account in the system."""

    __tablename__: ClassVar[str] = "user"

    id: Optional[int] = Field(default=None, primary_key=True, index=True)
    email: str = Field(unique=True, index=True, nullable=False)
    hashed_password: str = Field(nullable=False)
    is_active: bool = Field(default=True)

Notice that this model uses a TimestampMixin. This is a reusable class that adds created_at and updated_at fields to our models, keeping our code clean and DRY. You can learn how to create this in my guide on Reusable Model Fields in SQLModel with Mixins.

Implementing the Authentication Flow

Now let's put all the pieces together. The following code, which uses the python-jose library for JWT operations, sets up the functions needed to create tokens and protect our endpoints.

# In a file like auth.py
from datetime import datetime, timedelta, timezone
from typing import Optional

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlmodel import Session

from app.backend.database.connection import get_db
from app.backend.database.models import User
from app.backend.database.utils.user_crud import get_user_by_email
from app.backend.schemas.authentication.models import TokenData
from app.common.utils.auth.auth_security import verify_password
from app.config.main import get_settings

# Load settings from our centralized config
settings = get_settings()

SECRET_KEY = settings.SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# This tells FastAPI where the client should go to get a token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    """Creates a JWT access token."""
    to_encode = data.copy()
    now = datetime.now(timezone.utc)

    if expires_delta:
        expire = now + expires_delta
    else:
        expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    to_encode.update({"exp": expire, "nbf": now})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

async def authenticate_user(db: Session, email: str, password: str):
    """Authenticates a user by checking their email and password."""
    user = await get_user_by_email(db, email=email)
    if not user or not verify_password(password, user.hashed_password):
        return False
    return user

async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    """Decodes the JWT token to get 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])
        email: str = payload.get("sub")
        if email is None:
            raise credentials_exception
        token_data = TokenData(email=email)
    except JWTError:
        raise credentials_exception

    user = await get_user_by_email(session=db, email=token_data.email)
    if user is None:
        raise credentials_exception
    return user

async def get_current_active_user(current_user: User = Depends(get_current_user)):
    """Checks if the current user is active."""
    if not current_user.is_active:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive User")
    return current_user

Breaking Down the Code

  1. Configuration: We load our SECRET_KEY and other settings from a centralized configuration, as described in my guide to Pydantic Settings. The SECRET_KEY is a long, random string used to sign our JWTs.
  2. oauth2_scheme: This creates an OAuth2PasswordBearer instance. The tokenUrl points to the endpoint where clients will send their username and password to get a token.
  3. create_access_token: This function builds the JWT. It takes a dictionary of data, adds an expiration time (exp), and signs it with our SECRET_KEY. We use the sub (subject) claim to store the user's email, which is standard practice.
  4. authenticate_user: This function is used by our token endpoint. It finds a user by email using a CRUD function (get_user_by_email) and then uses our verify_password function to check if the provided password is correct. For a full guide on creating these database utility functions, see my article on Connecting FastAPI to a Database with SQLModel.
  5. get_current_user: This is the core dependency for protecting our routes.
    • It depends on oauth2_scheme, which tells FastAPI to look for a Bearer token in the Authorization header.

    • It decodes the token using jwt.decode. If the token is invalid or expired, a JWTError is raised.

    • It extracts the user's email from the sub claim, fetches the user from the database and validates it using the TokenData Pydantic model. This model ensures the data we extracted from the token has the expected structure and type.

      from pydantic import BaseModel
      
      class TokenData(BaseModel):
          email: str
      
    • Finally, it fetches the user from the database.

  6. get_current_active_user: This is a layered dependency. It first calls get_current_user and then adds another check to ensure the user's account is active.

To protect an endpoint, you simply add Depends(get_current_active_user) to it.

from typing import Annotated

@app.get("/users/profile")
async def read_users_profile(current_user: Annotated[User, Depends(get_current_active_user)]):
    return current_user

FastAPI will now handle the entire authentication flow for this endpoint. If a valid token isn't provided, it will automatically return a 401 Unauthorized error.


FAQ

Q: What's the difference between authentication and authorization? A: Authentication is about verifying who a user is. Authorization is about determining what an authenticated user is allowed to do. This article focuses on authentication.

Q: How do I add role-based permissions (e.g., admin vs. user)? A: This is the next step after authentication, known as authorization. To implement it, you would typically add a role field (e.g., 'admin', 'user') to your User model. Then, you can create another dependency that checks the role of the current_user and raises a 403 Forbidden error if they don't have the required permissions for a specific endpoint.

Q: Should I store the JWT in a cookie or in Local Storage? A: Both are common. Storing it in an HttpOnly cookie can protect against Cross-Site Scripting (XSS) attacks. Storing it in Local Storage is simpler but can be vulnerable to XSS. The choice depends on your security requirements.

Q: How do I handle expired tokens and keep users logged in? A: A production-ready system uses two types of tokens: a short-lived access token (e.g., 15 minutes) and a long-lived refresh token. When the access token expires, your front-end application sends the refresh token to a special endpoint (e.g., /api/v1/auth/refresh). This endpoint validates the refresh token and issues a new access token, keeping the user logged in without them needing to re-enter their password.

Q: How should I manage my SECRET_KEY in production? A: Your SECRET_KEY should never be hardcoded in your source code. It must be a long, complex, and randomly generated string. As shown in the article, you should load it from an environment variable using a settings management library like Pydantic's BaseSettings. This allows you to use different keys for development and production and keeps your secrets out of version control.

Q: Why not just use the user ID in the JWT sub claim instead of the email? A: You can use either. Using the email is common because it's guaranteed to be unique. Using the user ID is also perfectly fine and can be slightly more efficient for database lookups if the ID is the primary key.

Share This Article

About the Author

David Muraya is a Solutions Architect specializing in Python, FastAPI, and Cloud Infrastructure. He is passionate about building scalable, production-ready applications and sharing his knowledge with the developer community. You can connect with him on LinkedIn.

Related Blog Posts

Enjoyed this blog post? Check out these related posts!

Adding Google Authentication to Your FastAPI Application

Adding Google Authentication to Your FastAPI Application

A guide to adding Google Authentication to your FastAPI app.

Read More...

Secure FastAPI Environment Variables on Cloud Run with Secret Manager

Secure FastAPI Environment Variables on Cloud Run with Secret Manager

A Step-by-Step Guide to Managing Production Secrets on Google Cloud.

Read More...

6 Essential FastAPI Middlewares for Production-Ready Apps

6 Essential FastAPI Middlewares for Production-Ready Apps

A guide to the 6 key middlewares for building secure, performant, and resilient FastAPI applications.

Read More...

Building Resilient Task Queues in FastAPI with ARQ Retries

Building Resilient Task Queues in FastAPI with ARQ Retries

How to Handle Failures and Implement Robust Retry Logic in FastAPI Background Jobs with ARQ.

Read More...

On this page

The Simplest Method: HTTP Basic AuthThe Modern Standard: OAuth2 with JWTWhat is a JWT?Securing Passwords with HashingThe User ModelImplementing the Authentication FlowBreaking Down the CodeFAQ

Contact Me

Have a project in mind? Send me an email at hello@davidmuraya.com and let's bring your ideas to life. I am always available for exciting discussions.

© 2025 David Muraya. All rights reserved.