Back to Blog
Python

Python PyJWT FastAPI Authentication: Secure Token Handling

python pyjwt fastapi authentication: Implement JWT authentication in FastAPI using PyJWT: token creation, validation, dependencies, security, and common pitfalls.

JWTFastAPIPyJWTAuthenticationSecurity
Illustration of JWT token authentication flow in FastAPI with PyJWT, showing token generation and validation.

python pyjwt fastapi authentication requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When building a FastAPI application, you often need to protect endpoints so only authenticated users can access them. JWT (JSON Web Tokens) is a common choice, and PyJWT is a lightweight library for encoding and decoding these tokens. This article walks through a complete JWT authentication flow using Python, PyJWT, and FastAPI, from token creation to secure dependency injection and production considerations.

Setting Up the Project and Dependencies

Start by installing the required packages. PyJWT provides the token encoding and decoding logic, FastAPI handles the web framework, and Uvicorn serves the application. For managing the secret key, python-dotenv is useful, but you can also use environment variables directly.

pip install fastapi uvicorn pyjwt python-dotenv

Create a .env file to store your secret key and token expiration settings:

SECRET_KEY=your-secret-key-here ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7

Load these values in your application using os.getenv or python-dotenv:

import os from dotenv import load_dotenv load_dotenv() SECRET_KEY = os.getenv("SECRET_KEY") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30)) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", 7))

The secret key must be kept private and strong. In production, use a key management service or at least a long random string. Never hardcode it in source control.

Creating Access Tokens with PyJWT

PyJWT's encode function takes a payload, a secret key, and an algorithm. The payload typically contains claims like sub (subject, usually the user identifier), exp (expiration time), and iat (issued at). Use timezone-aware datetime objects to avoid timezone pitfalls.

import jwt from datetime import datetime, timedelta, timezone def create_access_token(data: dict, expires_delta: timedelta | None = None): to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta else: expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt

This function accepts a dictionary of claims, adds expiration and issued-at timestamps, and returns a signed token. The exp claim is essential—it tells PyJWT when the token is no longer valid. Without it, the token never expires, which is a security risk.

For a refresh token, you can use a longer expiration and include a claim like "type": "refresh" to distinguish it from an access token:

def create_refresh_token(data: dict): to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) to_encode.update({"exp": expire, "type": "refresh"}) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

Validating Tokens and Handling Errors

Decoding a token is not enough—you must verify its signature and expiration. PyJWT's decode function does both when you pass the secret and algorithm. It raises exceptions for invalid tokens, expired tokens, or missing claims.

from jwt import ExpiredSignatureError, InvalidTokenError def decode_token(token: str) -> dict: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token")

Always catch ExpiredSignatureError separately from InvalidTokenError. The latter is a base class that covers signature mismatch, malformed tokens, and other validation failures. Returning a generic 401 response prevents information leakage about why the token failed.

Note that jwt.decode automatically checks the exp claim if it is present. If you omit exp, the token is considered valid indefinitely, which is rarely what you want.

Building a FastAPI Dependency for Protected Routes

FastAPI's dependency injection system makes it straightforward to protect routes. Use HTTPBearer from fastapi.security to extract the token from the Authorization header, then validate it in a dependency that returns the current user.

from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials security = HTTPBearer() def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: token = credentials.credentials payload = decode_token(token) # Optionally fetch the user from a database using payload["sub"] return payload

Now protect any route by adding Depends(get_current_user) as a parameter:

from fastapi import FastAPI app = FastAPI() @app.get("/users/me") async def read_current_user(current_user: dict = Depends(get_current_user)): return current_user

The dependency extracts the token from the Authorization: Bearer <token> header, decodes and validates it, and returns the payload. If the token is missing or invalid, FastAPI returns a 403 Forbidden (for missing credentials) or the HTTPException from decode_token (401). You can customize the status codes by raising HTTPException with the appropriate status_code.

Handling Token Expiration and Refresh Tokens

Access tokens are short-lived to limit the damage if they are leaked. A refresh token, with a longer lifespan, can be used to obtain new access tokens without requiring the user to log in again. Implement a /refresh endpoint that validates the refresh token and issues a new access token.

from pydantic import BaseModel class RefreshRequest(BaseModel): refresh_token: str @app.post("/refresh") async def refresh_token(request: RefreshRequest): payload = decode_token(request.refresh_token) if payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="Invalid token type") user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="Invalid token payload") new_access_token = create_access_token({"sub": user_id}) return {"access_token": new_access_token, "token_type": "bearer"}

This endpoint checks that the token is indeed a refresh token (via the type claim) and that it contains a subject. Then it issues a new access token. For added security, you can rotate refresh tokens—revoke the old one and issue a new one—but that requires storing token state server-side, which defeats some of the stateless benefits of JWT. If you need revocation, consider a token blacklist or a database-backed session store.

Security Considerations for JWT in Production

JWT security depends heavily on how you handle the secret key, algorithm, and token storage.

Secret key management: Use a strong, random key. Store it in an environment variable or a secrets manager. Rotate it periodically, but be aware that rotating the key invalidates all existing tokens. Plan for a grace period where both old and new keys are accepted during transitions.

Algorithm selection: HS256 is symmetric—the same key signs and verifies. RS256 uses a private key to sign and a public key to verify, which is useful when multiple services need to verify tokens without sharing the signing key. PyJWT supports both, but HS256 is simpler for a single-service application.

Token storage on the client: Storing tokens in localStorage makes them accessible to JavaScript, increasing the risk of XSS theft. Using httpOnly cookies mitigates this but requires CSRF protection. For API-only backends, the Authorization header is standard, but ensure your frontend uses secure storage practices.

Clock skew: The exp claim is checked against the server's current time. If the server clock is ahead of the client's, tokens may appear expired prematurely. PyJWT allows a small leeway via the leeway parameter in decode:

payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], leeway=10)

This accepts tokens that expire within 10 seconds of the current time, accommodating minor clock differences.

Common Pitfalls and How to Avoid Them

Several mistakes can undermine your JWT implementation.

Decoding without verification: Calling jwt.decode(token, options={"verify_signature": False}) skips signature checks. This is only for debugging and must never appear in production code. Always verify the signature.

Using naive datetime for expiration: datetime.utcnow() returns a naive datetime, which PyJWT treats as UTC. Mixing naive and aware datetimes can cause off-by-one errors. Use datetime.now(timezone.utc) consistently.

Not checking the token type: If you use both access and refresh tokens, ensure your dependencies check the type claim. Otherwise, a refresh token could be used as an access token, extending its validity beyond the intended limit.

Catching too broad exceptions: Catching Exception around jwt.decode can hide unexpected errors. Catch specific PyJWT exceptions and let others propagate.

Hardcoding the secret key: Embedding the secret in source code makes it available to anyone with repository access. Use environment variables or a secrets manager.

Ignoring token revocation: JWT is stateless, so you cannot invalidate a token before it expires without additional infrastructure. If you need immediate revocation, maintain a blacklist of revoked token IDs (the jti claim) in a database or cache.

By addressing these pitfalls, your FastAPI authentication will be more robust and secure. The combination of PyJWT and FastAPI provides a solid foundation for stateless authentication, but the responsibility for secure implementation rests with the developer.

python pyjwt fastapi authentication: Practical Usage and Cod | RYUSLOG DEV