By AndyPublished

FastAPI JWT Authentication with PyJWT, OAuth2PasswordBearer and Argon2

FastAPI JWT authentication uses three pieces: a /token endpoint that checks the password and returns a signed JWT, an OAuth2PasswordBearer dependency that pulls the token from theAuthorization header, and a get_current_user dependency that verifies it with PyJWT and raises 401 on failure. FastAPI's own tutorial now uses PyJWT and pwdlib with Argon2, and so does this guide. The app below was run under Uvicorn and tested with TestClienton Python 3.12 with FastAPI 0.141.1, PyJWT 2.15.0, pwdlib 0.3.1 and Uvicorn 0.53.0.

How JWT Authentication Works in FastAPI

The flow follows the OAuth 2.0 password grant shape, simplified. The client posts a username and password as form data to/token. The server checks the password hash and, if it matches, returns a JSON body withaccess_token and token_type: "bearer". From then on the client sendsAuthorization: Bearer <token> (RFC 6750) on every request.

On the server side, OAuth2PasswordBearer does only one job: it extracts the token from that header, or responds 401 if the header is missing. It does not verify anything. Verification is yourget_current_user dependency, which decodes the token with PyJWT, checks signature, algorithm, expiry, issuer and audience, and returns a user object. Endpoints declare that dependency and receive the user as a typed parameter. Because the token is self-contained, no session table is consulted on each request; that is the benefit, and also why revocation needs extra work (covered at the end). If you are weighing this against server-side sessions, see JWT vs session tokens.

Installing PyJWT and pwdlib

pip install fastapi "uvicorn[standard]" pyjwt "pwdlib[argon2]" python-multipart
# for RS256/ES256 tokens (JWKS), add the crypto extra:
pip install "pyjwt[crypto]"

export JWT_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"

python-multipart is needed because the OAuth2 password flow posts form data. Older tutorials install python-jose and passlib; FastAPI's docs have since moved topyjwt and pwdlib. passlib's last release was 1.7.4 in October 2020. Do not install the unrelated jwt package from PyPI alongside PyJWT: both provide a top-leveljwt module, and whichever wins produces confusing AttributeErrors.

A Complete FastAPI JWT Auth Example

This is a single-file app: password hashing, token issue, a verifying dependency, a role check and a protected route. Compared with the minimal version in FastAPI's tutorial, it adds iss andaud claims, requires them on decode, allows 30 seconds of clock skew and reads the secret from the environment.

# main.py
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel

SECRET_KEY = os.environ["JWT_SECRET"]  # fail fast if missing
ALGORITHM = "HS256"
ISSUER = "https://api.example.com"
AUDIENCE = "https://api.example.com"
ACCESS_TOKEN_TTL = timedelta(minutes=15)

password_hash = PasswordHash.recommended()  # Argon2id
DUMMY_HASH = password_hash.hash("dummy-password")

# Stand-in for a users table. Store only hashes.
USERS = {
    "ada@example.com": {
        "id": "42",
        "role": "admin",
        "hashed_password": password_hash.hash("correct horse"),
    }
}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()


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


class CurrentUser(BaseModel):
    id: str
    role: str


def authenticate(email: str, password: str) -> dict | None:
    user = USERS.get(email)
    if user is None:
        password_hash.verify(password, DUMMY_HASH)  # equalise timing
        return None
    if not password_hash.verify(password, user["hashed_password"]):
        return None
    return user


def create_access_token(user: dict) -> str:
    now = datetime.now(timezone.utc)
    claims = {
        "sub": user["id"],
        "role": user["role"],
        "iss": ISSUER,
        "aud": AUDIENCE,
        "iat": now,
        "exp": now + ACCESS_TOKEN_TTL,
    }
    return jwt.encode(claims, SECRET_KEY, algorithm=ALGORITHM)


def decode_access_token(token: str) -> CurrentUser:
    payload = jwt.decode(
        token,
        SECRET_KEY,
        algorithms=[ALGORITHM],
        issuer=ISSUER,
        audience=AUDIENCE,
        leeway=30,
        options={"require": ["exp", "iat", "iss", "aud", "sub"]},
    )
    return CurrentUser(id=payload["sub"], role=payload["role"])


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> CurrentUser:
    try:
        return decode_access_token(token)
    except ExpiredSignatureError:
        detail = "Token expired"
    except InvalidTokenError:
        detail = "Could not validate credentials"
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail=detail,
        headers={"WWW-Authenticate": "Bearer"},
    )


def require_role(role: str):
    async def checker(user: Annotated[CurrentUser, Depends(get_current_user)]) -> CurrentUser:
        if user.role != role:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
        return user
    return checker


@app.post("/token")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
    user = authenticate(form.username, form.password)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return Token(
        access_token=create_access_token(user),
        expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
    )


@app.get("/me")
async def me(user: Annotated[CurrentUser, Depends(get_current_user)]) -> CurrentUser:
    return user


@app.delete("/admin/cache")
async def clear_cache(user: Annotated[CurrentUser, Depends(require_role("admin"))]):
    return {"cleared_by": user.id}

A few details are deliberate. PyJWT converts datetime values for expand iat into NumericDate integers, as RFC 7519 requires. The DUMMY_HASHcheck comes from FastAPI's tutorial: hashing even when the user does not exist stops response timing from revealing which email addresses have accounts. algorithms=[ALGORITHM] is a fixed allow-list, so a token whose header says none or RS256 is rejected withInvalidAlgorithmError. The reasoning behind each claim is in JWT claims explained.

ℹ
PyJWT 2.15 emits an InsecureKeyLengthWarning when an HS256 key is shorter than 32 bytes. Treat it as an error: generate the secret with secrets.token_urlsafe(48) oropenssl rand, as shown in the JWT secret key generator guide.

Hardening the /token endpoint

The login route is the one place an attacker can guess passwords, so rate-limit it by IP and by account, and return the same message for "no such user" and "wrong password". PasswordHash.recommended() produces Argon2id hashes ($argon2id$v=19$m=65536,t=3,p=4 with pwdlib 0.3.1). When you later raise the cost parameters, password_hash.verify_and_update(password, stored_hash) returns a tuple of(valid, new_hash); if new_hash is not None, save it, and users are migrated to the stronger settings as they log in.

HS256 with one shared secret is right when the same service issues and verifies tokens. If several services need to verify tokens that only one service issues, switch to RS256 or ES256: the issuer keeps the private key and publishes the public key, so a compromised downstream service cannot mint tokens. The trade-offs are in HS256 vs RS256.

Running and Testing the FastAPI JWT Token Flow

uvicorn main:app --reload

TOKEN=$(curl -s -X POST localhost:8000/token \
  --data-urlencode username=ada@example.com \
  --data-urlencode "password=correct horse" | jq -r .access_token)

curl -s localhost:8000/me -H "Authorization: Bearer $TOKEN"
# {"id":"42","role":"admin"}

The interactive docs at /docs show an Authorize button, becauseOAuth2PasswordBearer registers the security scheme in the OpenAPI schema. For automated tests,TestClient exercises the same code paths without a server:

# test_main.py  (run with: JWT_SECRET=... pytest)
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_login_and_me():
    r = client.post("/token", data={"username": "ada@example.com", "password": "correct horse"})
    assert r.status_code == 200
    token = r.json()["access_token"]
    assert client.get("/me", headers={"Authorization": f"Bearer {token}"}).json() == {"id": "42", "role": "admin"}

def test_rejects_missing_and_tampered_tokens():
    assert client.get("/me").status_code == 401
    assert client.get("/me", headers={"Authorization": "Bearer abc.def.ghi"}).status_code == 401

In testing, no token returned 401 {"detail":"Not authenticated"} withWWW-Authenticate: Bearer; a tampered, wrong-audience or expired token returned 401; and a valid non-admin token on /admin/cache returned 403. Starlette 1.7 prints a deprecation warning suggesting httpx2 as the TestClient transport; the tests still pass with httpx. To inspect a token the API rejects, paste it into the jwtdecode.app decoder, which runs locally in the browser, and compareiss, aud and exp against the values above.

PyJWT exceptions you will see

ExceptionMeaning
ExpiredSignatureErrorexp is in the past (beyond leeway). Message: "Signature has expired".
InvalidSignatureErrorSignature does not match the key. Message: "Signature verification failed".
InvalidAudienceErroraud does not match the audience you passed.
InvalidIssuerErroriss does not match the issuer you passed.
MissingRequiredClaimErrorA claim listed in options["require"] is absent, e.g. "Token is missing the "aud" claim".
DecodeErrorNot a JWT at all, e.g. "Not enough segments".
InvalidAlgorithmErrorThe header alg is not in your algorithms list.

All of these subclass InvalidTokenError, which is why a single exceptcovers them. More causes and fixes are in common JWT errors.

FastAPI JWT Middleware vs Dependencies

People search for "FastAPI JWT middleware", but in FastAPI the idiomatic tool is a dependency, not middleware. A dependency gives the endpoint a typed CurrentUser, shows up in the OpenAPI schema, can be overridden in tests with app.dependency_overrides, and can be applied per route, per router or to the whole app:

from fastapi import APIRouter

# every route on this router requires a valid token
orders = APIRouter(prefix="/orders", dependencies=[Depends(get_current_user)])

# or the whole app (then exempt /token by mounting it on a separate app or router)
# app = FastAPI(dependencies=[Depends(get_current_user)])

ASGI middleware sees every request, including /docs and /token, and has no clean way to pass a typed user to the endpoint or to document the scheme. Use middleware for cross-cutting concerns such as logging or rejecting obviously malformed headers, and dependencies for authentication and authorisation.

Dependencies also make tests simpler. In a test that is about business logic rather than auth, setapp.dependency_overrides[get_current_user] = lambda: CurrentUser(id="42", role="admin") and the endpoint receives that user without any token being minted. Keep at least one end-to-end test, like the ones above, that goes through the real /token route and the real decode, so a broken issuer or audience setting cannot hide behind the override.

For a browser front end served from the same site, an HttpOnly cookie keeps the token out of reach of injected JavaScript, which localStorage cannot do. FastAPI'sResponse.set_cookie and the Cookie() parameter are enough; the samedecode_access_token function does the verification.

from fastapi import Cookie, Response

@app.post("/login-cookie")
async def login_cookie(form: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response):
    user = authenticate(form.username, form.password)
    if user is None:
        raise HTTPException(status_code=401, detail="Incorrect email or password")
    response.set_cookie(
        "access_token", create_access_token(user),
        httponly=True, secure=True, samesite="lax", max_age=900,
    )
    return {"ok": True}


async def get_user_from_cookie(access_token: Annotated[str | None, Cookie()] = None) -> CurrentUser:
    if access_token is None:
        raise HTTPException(status_code=401, detail="Not authenticated")
    try:
        return decode_access_token(access_token)
    except InvalidTokenError:
        raise HTTPException(status_code=401, detail="Could not validate credentials")

Cookies are sent automatically, so state-changing routes need CSRF protection: SameSite=Laxblocks cross-site POSTs in modern browsers, and a CSRF token or an Origin header check covers the rest. The full comparison is in JWT storage: localStorage vs cookie.

Verifying Tokens from an Identity Provider (JWKS)

If Auth0, Entra ID, Cognito or Keycloak issues the tokens, verify them with the provider's public keys.PyJWKClient fetches and caches the JWKS and selects the key by kid. Background on key sets is in what is JWKS.

from jwt import PyJWKClient, PyJWTError

jwks_client = PyJWKClient(os.environ["JWKS_URI"], cache_keys=True, lifespan=300)

def get_provider_claims(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        return jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256"],
            issuer=os.environ["OIDC_ISSUER"],
            audience=os.environ["API_AUDIENCE"],
        )
    except PyJWTError:
        raise HTTPException(status_code=401, detail="Invalid token",
                            headers={"WWW-Authenticate": "Bearer"})

Two details matter here. In testing, an unknown kid raisesPyJWKClientError, which is not a subclass of InvalidTokenError, so catch the base PyJWTError here or the request becomes a 500. And the key fetch uses blocking I/O, so this dependency is a plain def: FastAPI runs sync dependencies in a thread pool rather than blocking the event loop.

FastAPI WebSocket JWT Authentication

Browsers cannot set an Authorization header on a WebSocket handshake, soOAuth2PasswordBearer does not apply. The simplest option is a short-lived token in the query string, verified before accept():

from fastapi import WebSocket, WebSocketException

@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket, token: str | None = None):
    try:
        user = decode_access_token(token or "")
    except InvalidTokenError:
        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
    await websocket.accept()
    await websocket.send_json({"hello": user.id})

An invalid token closed the connection with code 1008 in testing. Query strings end up in access logs, so use a dedicated, very short-lived ticket rather than the main access token, or pass the token in theSec-WebSocket-Protocol header or the first message. Long-lived sockets also outlive the token'sexp; the trade-offs are covered in JWT WebSocket authentication.

Refresh Tokens and Logout in FastAPI

The 15-minute access token above needs a renewal path. Issue an opaque refresh token at login, store a hash of it in your database with the user ID and expiry, and expose POST /token/refresh that validates, rotates and returns a new access token. Logout deletes the stored refresh token; the access token simply runs out. If you need instant revocation, add a jti claim and check it against a deny-list inget_current_user. See the refresh token pattern and JWT logout and revocation.

FastAPI JWT Production Checklist

  • ·algorithms=[...] fixed in code, never read from the token header.
  • ·issuer, audience and options={"require": [...]} passed to every jwt.decode call.
  • ·Access tokens of 15 minutes or less, with a small leeway.
  • ·Secret or private key from the environment or a secrets manager, at least 32 random bytes for HS256.
  • ·401 with WWW-Authenticate: Bearer for authentication failures, 403 for role failures.
  • ·Rate limiting on /token, and HTTPS in front of Uvicorn.

Summary

FastAPI JWT authentication is a /token route that verifies an Argon2 hash withpwdlib and returns jwt.encode(...), plus aget_current_user dependency built on OAuth2PasswordBearer that callsjwt.decode with a fixed algorithms list, issuer, audience and required claims, and raises 401 on InvalidTokenError. Apply it with dependencies rather than middleware, use PyJWKClient for provider tokens, keep access tokens short, and handle WebSockets separately. For general hardening, see JWT security best practices.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder