Type something to search...
Authentication in FastAPI with OAuth2 and JWT

Authentication in FastAPI with OAuth2 and JWT

Most APIs need to know who's calling them. FastAPI doesn't ship a complete user system, but it gives you well-designed building blocks: OAuth2 helpers that read credentials and bearer tokens, a dependency system that makes "this route requires a logged-in user" a single parameter, and automatic docs that know how to log in. Combine those with a password hashing library and a JWT library and you have a solid token-based auth setup in a few small files.

This guide builds that setup step by step: hashing passwords properly, a login endpoint that follows the OAuth2 password flow, signed JWT access tokens with expiry, a get_current_user dependency that protects routes, and scope-based permissions. It finishes with tests and a checklist of the security details that are easy to get wrong.

The code was tested with FastAPI 0.142, PyJWT 2.15, and pwdlib 0.3 on Python 3.13. If you're new to FastAPI itself, start with building a REST API with FastAPI.

How the Pieces Fit

The flow you're building:

  1. The client sends a username and password to POST /auth/token.
  2. The server checks the password against a stored hash. If it's right, it returns a signed JWT access token.
  3. The client sends that token with every later request in an Authorization: Bearer <token> header.
  4. A dependency on protected routes verifies the token's signature and expiry, loads the user, and hands it to the endpoint.

The server never stores the token. It can trust a token because only the server knows the key used to sign it.

What's Actually in a JWT?

A JSON Web Token is three base64url-encoded parts joined by dots: header.payload.signature. Decoding the header and payload of the tokens this app issues gives:

{"alg": "HS256", "typ": "JWT"}                    # header
{"sub": "ada", "scopes": [...], "iat": ..., "exp": ..., "iss": "tasks-api"}  # payload

The payload is encoded, not encrypted: anyone holding the token can read it. Never put secrets or sensitive personal data in it. What the signature guarantees is that the payload hasn't been changed; editing a single character makes verification fail.

The registered claims used here are sub (subject, the user it's for), iat (issued at), exp (expiry), and iss (issuer). scopes is a custom claim.

Installing

python -m pip install "fastapi[standard]" pyjwt "pwdlib[argon2]" pydantic-settings
  • fastapi[standard] includes Uvicorn and python-multipart, which FastAPI needs to read the login form.
  • pyjwt creates and verifies tokens.
  • pwdlib hashes passwords, with Argon2 as the algorithm.
  • pydantic-settings loads the signing secret from the environment.

The project layout:

app/
├── __init__.py
├── config.py
├── deps.py
├── main.py
├── security.py
└── users.py
tests/
└── test_auth.py

Configuration and the Signing Secret

The JWT secret is the key to your whole auth system: anyone who has it can mint valid tokens for any user. Keep it out of your code and load it from the environment:

# app/config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    jwt_secret: str = Field(min_length=32)
    jwt_algorithm: str = "HS256"
    jwt_issuer: str = "tasks-api"
    access_token_minutes: int = 15


settings = Settings()

Generate a strong random value with Python's secrets module and put it in .env (which belongs in .gitignore) or your deployment's secret store:

python -c "import secrets; print(secrets.token_urlsafe(32))"
echo "JWT_SECRET=<paste the value here>" >> .env

If JWT_SECRET is missing, or shorter than 32 characters, the app refuses to start with a clear validation error instead of running with a weak key. Data validation with Pydantic covers how pydantic-settings works in more detail.

Hashing Passwords

Never store passwords, and never store them with a fast hash like SHA-256 either. Password hashing algorithms such as Argon2 are deliberately slow and memory-hungry, so an attacker who steals your database can't test billions of guesses per second. They also add a random salt to every hash, so two users with the same password get different hashes.

# app/security.py
from datetime import datetime, timedelta, timezone
from typing import Any

import jwt
from pwdlib import PasswordHash

from app.config import settings

password_hash = PasswordHash.recommended()

# Verified against when the username doesn't exist, so failed logins
# take the same time whether or not the user is real.
DUMMY_HASH = password_hash.hash("not-a-real-password")


def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)

PasswordHash.recommended() uses Argon2id with sensible parameters. A hash looks like $argon2id$v=19$m=65536,t=3,p=4$...: the algorithm, its cost settings, the salt, and the hash are all stored together, so you can raise the cost later and old hashes still verify. (pwdlib also offers verify_and_update() to rehash old passwords with new parameters on login.)

The DUMMY_HASH matters for the login endpoint below. If you return immediately when a username doesn't exist, but spend 50 ms hashing when it does, an attacker can tell which usernames are real from response times alone.

Creating and Verifying Tokens

The rest of security.py creates and decodes JWTs:

# app/security.py (continued)
def create_access_token(subject: str, scopes: list[str] | None = None) -> str:
    now = datetime.now(timezone.utc)
    payload: dict[str, Any] = {
        "sub": subject,
        "scopes": scopes or [],
        "iat": now,
        "exp": now + timedelta(minutes=settings.access_token_minutes),
        "iss": settings.jwt_issuer,
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)


def decode_access_token(token: str) -> dict[str, Any]:
    return jwt.decode(
        token,
        settings.jwt_secret,
        algorithms=[settings.jwt_algorithm],
        issuer=settings.jwt_issuer,
        options={"require": ["exp", "sub", "iat"]},
    )

PyJWT converts datetime values for iat and exp into the numeric timestamps the JWT spec requires. When decoding, it checks:

  • the signature, using the secret;
  • that the token's algorithm is in the algorithms list you allow. Always pass this list explicitly; letting the token choose its own algorithm has caused real vulnerabilities;
  • exp, rejecting expired tokens with ExpiredSignatureError;
  • iss matches, so tokens from another system using the same secret aren't accepted;
  • that the required claims are present.

Every failure raises a subclass of jwt.InvalidTokenError, so callers only need to catch one exception type.

Keep access tokens short-lived. Fifteen minutes is a common choice: there's no simple way to revoke a JWT before it expires, so the expiry is your main limit on how long a stolen token stays useful.

Users

A real app stores users in a database (see SQLAlchemy 2.0). To keep the focus on auth, a dictionary stands in here:

# app/users.py
from pydantic import BaseModel

from app.security import hash_password


class User(BaseModel):
    username: str
    full_name: str
    disabled: bool = False
    scopes: list[str] = []


class UserInDB(User):
    hashed_password: str


# A stand-in for a real users table.
FAKE_USERS_DB: dict[str, UserInDB] = {
    "ada": UserInDB(
        username="ada",
        full_name="Ada Lovelace",
        hashed_password=hash_password("correct horse battery staple"),
        scopes=["tasks:read", "tasks:write"],
    ),
    "bob": UserInDB(
        username="bob",
        full_name="Bob Reader",
        hashed_password=hash_password("another long passphrase"),
        scopes=["tasks:read"],
    ),
}


def get_user(username: str) -> UserInDB | None:
    return FAKE_USERS_DB.get(username)

Two models keep the hash from leaking: UserInDB is for internal use, and User is what endpoints return. Because FastAPI filters responses through the declared return type, even an accidental return user_in_db from an endpoint annotated -> User won't include hashed_password.

The Login Endpoint

The OAuth2 "password" flow specifies that the client sends username and password as form data (not JSON) to a token URL. FastAPI's OAuth2PasswordRequestForm reads exactly that:

# app/main.py
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel

from app.deps import CurrentUser, require_scope
from app.security import DUMMY_HASH, create_access_token, verify_password
from app.users import User, get_user

app = FastAPI(title="Tasks API")


class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"


@app.post("/auth/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
    user = get_user(form.username)
    hashed = user.hashed_password if user else DUMMY_HASH
    password_ok = verify_password(form.password, hashed)
    if user is None or not password_ok or user.disabled:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return Token(access_token=create_access_token(user.username, user.scopes))

Details worth noting:

  • The response shape, {"access_token": "...", "token_type": "bearer"}, is what the OAuth2 spec and FastAPI's docs UI expect.
  • The error message is identical for "no such user" and "wrong password", and both paths run a password verification. Don't help attackers enumerate accounts.
  • login is a plain def, not async def. Argon2 verification is deliberately CPU-heavy; in an async def endpoint it would block the event loop and stall every other request. FastAPI runs def endpoints in a thread pool, which keeps the server responsive.

Try it with curl:

curl -X POST http://127.0.0.1:8000/auth/token \
  -d "username=ada" -d "password=correct horse battery staple"

Protecting Routes with a Dependency

Now the important part: a dependency that turns a bearer token into a user, or rejects the request.

# app/deps.py
from typing import Annotated

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

from app.security import decode_access_token
from app.users import User, get_user

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

credentials_exception = HTTPException(
    status_code=status.HTTP_401_UNAUTHORIZED,
    detail="Could not validate credentials",
    headers={"WWW-Authenticate": "Bearer"},
)


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    try:
        payload = decode_access_token(token)
    except jwt.InvalidTokenError:
        raise credentials_exception
    user = get_user(payload["sub"])
    if user is None or user.disabled:
        raise credentials_exception
    return User(**user.model_dump(exclude={"hashed_password"}))


CurrentUser = Annotated[User, Depends(get_current_user)]

How it works:

  • OAuth2PasswordBearer is itself a dependency. It reads the Authorization header, checks it starts with Bearer, and returns the token string. If the header is missing, it responds with 401 {"detail": "Not authenticated"} before your code runs. Its tokenUrl tells the docs UI where to log in.
  • get_current_user depends on that scheme, verifies the token, and loads the user fresh. Loading the user on each request (rather than trusting everything in the token) means disabling an account takes effect right away, even for tokens that haven't expired.
  • CurrentUser is an Annotated alias, so protecting a route is a single typed parameter.

Using it:

# app/main.py (continued)
@app.get("/users/me")
async def read_me(user: CurrentUser) -> User:
    return user
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/auth/token \
  -d "username=ada" -d "password=correct horse battery staple" | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])")

curl http://127.0.0.1:8000/users/me -H "Authorization: Bearer $TOKEN"
{
  "username": "ada",
  "full_name": "Ada Lovelace",
  "disabled": false,
  "scopes": ["tasks:read", "tasks:write"]
}

FastAPI resolves dependencies once per request and caches them, so if several dependencies in the same request each depend on get_current_user, the token is decoded only once.

To protect every route in a router at once, put the dependency on the router: APIRouter(dependencies=[Depends(get_current_user)]).

Login in the Interactive Docs

Because the security scheme is declared with OAuth2PasswordBearer, the generated docs at /docs show an Authorize button. Enter a username and password, and the docs UI calls /auth/token and attaches the token to every request you try. It's the quickest way to poke at a protected API by hand.

Permissions with Scopes

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" A simple, effective approach is to put the user's permissions in the token as scopes and check them per route:

# app/deps.py (continued)
def require_scope(scope: str):
    async def checker(user: CurrentUser) -> User:
        if scope not in user.scopes:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Missing required scope: {scope}",
            )
        return user

    return checker

require_scope is a dependency factory: calling it returns a new dependency that requires one specific scope. Because checker depends on CurrentUser, it also inherits authentication. It's a closure over scope.

# app/main.py (continued)
@app.get("/tasks")
async def list_tasks(user: Annotated[User, Depends(require_scope("tasks:read"))]) -> list[str]:
    return [f"{user.username}: write the report", f"{user.username}: review PRs"]


@app.post("/tasks", status_code=201)
async def create_task(user: Annotated[User, Depends(require_scope("tasks:write"))]) -> dict[str, str]:
    return {"created_by": user.username}

Bob has only tasks:read, so he can list tasks but gets a 403 when creating one:

{ "detail": "Missing required scope: tasks:write" }

Note the status codes: 401 means "not authenticated" (missing, invalid or expired token; the client should log in again), while 403 means "authenticated, but not allowed". Keep them distinct so clients can react correctly.

Here the user's scopes are checked from the freshly loaded user record. You could instead check the scopes claim from the token, which avoids a lookup but means permission changes only take effect when the token expires. FastAPI also has a SecurityScopes mechanism that integrates scopes into the OpenAPI docs, if you want the docs UI to request specific scopes.

Testing Authentication

Auth code deserves tests for the failure paths as much as the happy path. With TestClient and pytest:

# tests/test_auth.py
from datetime import datetime, timedelta, timezone

import jwt
from fastapi.testclient import TestClient

from app.config import settings
from app.main import app

client = TestClient(app)


def login(username: str, password: str):
    return client.post("/auth/token", data={"username": username, "password": password})


def auth_header(username: str, password: str) -> dict[str, str]:
    token = login(username, password).json()["access_token"]
    return {"Authorization": f"Bearer {token}"}


def test_wrong_password_is_rejected():
    response = login("ada", "nope")
    assert response.status_code == 401
    assert response.headers["www-authenticate"] == "Bearer"


def test_me_requires_a_token():
    assert client.get("/users/me").status_code == 401


def test_me_returns_current_user():
    response = client.get("/users/me", headers=auth_header("ada", "correct horse battery staple"))
    assert response.json()["full_name"] == "Ada Lovelace"
    assert "hashed_password" not in response.json()


def test_scope_is_enforced():
    headers = auth_header("bob", "another long passphrase")
    assert client.get("/tasks", headers=headers).status_code == 200
    assert client.post("/tasks", headers=headers).status_code == 403


def test_expired_token_is_rejected():
    past = datetime.now(timezone.utc) - timedelta(hours=1)
    token = jwt.encode(
        {"sub": "ada", "iat": past, "exp": past + timedelta(minutes=5), "iss": settings.jwt_issuer},
        settings.jwt_secret,
        algorithm="HS256",
    )
    response = client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
    assert response.status_code == 401


def test_tampered_token_is_rejected():
    token = login("ada", "correct horse battery staple").json()["access_token"]
    header, payload, signature = token.split(".")
    forged = f"{header}.{payload}.{signature[::-1]}"
    response = client.get("/users/me", headers={"Authorization": f"Bearer {forged}"})
    assert response.status_code == 401

Run them with a test secret in the environment:

JWT_SECRET=test-secret-that-is-at-least-32-chars python -m pytest -q

Running pytest through python -m puts the project root on sys.path, so import app works from the tests/ folder. Note data= rather than json= for the login request; the OAuth2 password flow uses form encoding. For tests of other endpoints that shouldn't care about auth at all, you can bypass it with app.dependency_overrides[get_current_user] = lambda: some_user.

Refresh Tokens and Logout

Short-lived access tokens raise an obvious question: does the user have to type their password every 15 minutes? The standard answer is a refresh token: a longer-lived credential (days or weeks) that's only accepted by a /auth/refresh endpoint, which issues a new access token.

Unlike access tokens, refresh tokens are usually stored server-side (or at least tracked by ID in a database), which buys you what plain JWTs lack:

  • Logout deletes or revokes the refresh token. The access token still works until it expires, which is why it's short.
  • Rotation: issue a new refresh token on every refresh and invalidate the old one. If an old token is ever reused, you know it was stolen and can revoke the whole session.

For browser apps, consider whether you need bearer tokens at all. A session cookie marked HttpOnly, Secure, and SameSite keeps the credential out of reach of JavaScript (and therefore XSS), at the cost of needing CSRF protection. Bearer tokens shine for mobile apps, CLIs, and service-to-service calls.

Security Checklist

  • Serve everything over HTTPS. Bearer tokens and passwords are plain text on the wire otherwise.
  • Hash passwords with Argon2 (or bcrypt), never a fast hash, and never log raw passwords or tokens.
  • Load the JWT secret from the environment; make it long and random; rotate it if it leaks (which invalidates every token).
  • Always pass an explicit algorithms=[...] list to jwt.decode().
  • Keep access tokens short-lived and validate exp, iss (and aud if you issue tokens for several services).
  • Use the same error message and similar timing for unknown users and wrong passwords.
  • Rate-limit the login endpoint to slow down password guessing.
  • Don't put sensitive data in the payload; it's readable by anyone with the token.
  • If several services need to verify tokens, consider asymmetric signing (RS256 or EdDSA): only the auth service holds the private key, and the others verify with the public key.
  • For anything large, or if you need social login, SSO, or MFA, consider a dedicated identity provider and verify its tokens in FastAPI instead of building it all yourself.

Conclusion

FastAPI's OAuth2 helpers and dependency injection make token auth feel native. Hash passwords with Argon2 through pwdlib, issue short-lived JWTs signed with a secret from the environment, and verify them with PyJWT in a get_current_user dependency that loads the user fresh on each request. Protecting a route is then a single CurrentUser parameter, and dependency factories like require_scope layer permissions on top. Add tests for the failure cases, refresh tokens when you need longer sessions, and you have an auth setup you can understand end to end.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading