FastAPI

FastAPI JWT Authentication (Beginner JWT Flow)

Thirdy Gayares
11 min read

Beginner Goal: Gets JWT Working First

This version is intentionally practical: no database, no bcrypt, no python-jose. We usePyJWT (import jwt) + dummy users so you can clearly understand the JWT flow.

  • Create access token and refresh token
  • Verify token and read payload claims
  • Protect a FastAPI endpoint using Bearer token
  • Refresh tokens without logging in again
  • Practice the flow in Swagger UI (/docs)

JWT Mental Model (Simple Version)

Think of JWT as a signed note. The server signs it, then the client sends it back on the next request. The server can verify the signature and trust the payload if the token is valid and not expired.

TokenPurposeTypical Lifetime
Access TokenUsed to call protected endpoints like /meShort (e.g. 15 min)
Refresh TokenUsed to request a new access tokenLonger (e.g. 7 days)
πŸ’‘
Focus for beginners: learn the lifecycle first. You can add password hashing, database users, cookies, and refresh token rotation after this tutorial.

GitHub Demo (Runnable FastAPI Sample)

I added a runnable sample inside this repo so your tutorial has a concrete code reference instead of theory-only notes.

GitHub folder: https://github.com/thirdygayares/thirdyV2/tree/master/examples/fastapi-jwt-beginner

Main files: app/main.py, app/jwt_utils.py, app/settings.py, .env.example

examples/fastapi-jwt-beginner/
β”œβ”€β”€ .env.example
β”œβ”€β”€ .gitignore
β”œβ”€β”€ README.md
β”œβ”€β”€ requirements.txt
└── app/
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ main.py
    β”œβ”€β”€ jwt_utils.py
    └── settings.py
⚠️
Demo only: the sample uses plain password comparison for dummy users so the JWT concepts are easier to follow. Do not copy that part to production.

Setup + .env (No jose, No bcrypt)

We only install what we need for the JWT flow: FastAPI, Uvicorn, PyJWT, and pydantic-settings for loading values from .env.

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
PyJWT==2.10.1
pydantic-settings==2.6.1
.env
JWT_SECRET_KEY=replace-this-with-a-long-random-secret-key-32chars-min
JWT_ACCESS_TOKEN_EXPIRES=900
JWT_REFRESH_TOKEN_EXPIRES=604800
app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    JWT_SECRET_KEY: str = "dev-secret-change-me"
    JWT_ACCESS_TOKEN_EXPIRES: int = 900
    JWT_REFRESH_TOKEN_EXPIRES: int = 604800

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

settings = Settings()
run.sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload

Core JWT Functions (Access + Refresh)

This is the heart of the tutorial. We create one reusable function (create_jwt_token) and then wrap it withcreate_access_token and create_refresh_token.

app/jwt_utils.py
from datetime import UTC, datetime, timedelta
from typing import Any

import jwt
from fastapi import HTTPException

from .settings import settings

ALGORITHM = "HS256"


def create_jwt_token(
    user_data: dict[str, Any],
    secret: str = settings.JWT_SECRET_KEY,
    expires_in: int = settings.JWT_ACCESS_TOKEN_EXPIRES,
    token_type: str = "access",
) -> str:
    payload = user_data.copy()
    payload["token_type"] = token_type
    payload["iat"] = datetime.now(UTC)
    payload["exp"] = datetime.now(UTC) + timedelta(seconds=expires_in)
    token = jwt.encode(payload, secret, algorithm=ALGORITHM)
    return token


def create_access_token(user_data: dict[str, Any]) -> str:
    return create_jwt_token(
        user_data=user_data,
        expires_in=settings.JWT_ACCESS_TOKEN_EXPIRES,
        token_type="access",
    )


def create_refresh_token(user_data: dict[str, Any]) -> str:
    return create_jwt_token(
        user_data=user_data,
        expires_in=settings.JWT_REFRESH_TOKEN_EXPIRES,
        token_type="refresh",
    )


def verify_jwt_token(token: str, secret: str = settings.JWT_SECRET_KEY) -> dict[str, Any]:
    try:
        payload = jwt.decode(token, secret, algorithms=[ALGORITHM])
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
FunctionWhat it doesWhy it exists
create_access_tokenBuilds a short-lived JWTUsed for protected API calls
create_refresh_tokenBuilds a longer-lived JWTUsed to issue a new access token
verify_jwt_tokenDecodes and validates signature + expiryCentralizes error handling
πŸ’‘
Why add token_type? It lets you reject a refresh token on endpoints that require an access token (and vice versa).

FastAPI Routes with Dummy Data (JWT Only)

No database lookup in this version. We keep a small in-memory dictionary so beginners can focus on token behavior.

app/main.py
from typing import Any

from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel

from .jwt_utils import create_access_token, create_refresh_token, verify_jwt_token

app = FastAPI(title="FastAPI JWT", version="0.1.0")
security = HTTPBearer(auto_error=False)

DUMMY_USERS: dict[str, dict[str, Any]] = {
    "thirdy": {"id": "user-1", "username": "thirdy", "password": "demo123", "role": "admin"},
    "maria": {"id": "user-2", "username": "maria", "password": "demo123", "role": "user"},
}

class LoginRequest(BaseModel):
    username: str
    password: str

class RefreshRequest(BaseModel):
    refresh_token: str

class TokenResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"

@app.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest):
    user = DUMMY_USERS.get(payload.username)
    if not user or user["password"] != payload.password:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    token_payload = {"sub": user["id"], "username": user["username"], "role": user["role"]}
    return {
        "access_token": create_access_token(token_payload),
        "refresh_token": create_refresh_token(token_payload),
        "token_type": "bearer",
    }


def get_access_payload(credentials: HTTPAuthorizationCredentials | None = Depends(security)):
    if credentials is None:
        raise HTTPException(status_code=401, detail="Missing Bearer token")

    payload = verify_jwt_token(credentials.credentials)
    if payload.get("token_type") != "access":
        raise HTTPException(status_code=401, detail="Access token required")
    return payload

@app.get("/me")
def me(payload = Depends(get_access_payload)):
    return {
        "user_id": payload["sub"],
        "username": payload["username"],
        "role": payload["role"],
        "message": "You used a valid access token",
    }

@app.post("/refresh", response_model=TokenResponse)
def refresh_tokens(payload: RefreshRequest):
    decoded = verify_jwt_token(payload.refresh_token)
    if decoded.get("token_type") != "refresh":
        raise HTTPException(status_code=401, detail="Refresh token required")

    next_payload = {"sub": decoded["sub"], "username": decoded["username"], "role": decoded["role"]}
    return {
        "access_token": create_access_token(next_payload),
        "refresh_token": create_refresh_token(next_payload),
        "token_type": "bearer",
    }
βœ…
Beginner win: with only three endpoints (/login, /me, /refresh) you already understand the full JWT lifecycle.

Practice in Swagger UI (/docs)

Swagger UI is perfect for beginners because you can test the flow visually without building a frontend yet.

Swagger UI screenshot for FastAPI docs at /docs
Local Swagger UI at http://127.0.0.1:8000/docs. Use this to test login, protected routes, and refresh flow.
  1. 1
    Open http://127.0.0.1:8000/docs
  2. 2
    Call POST /login using thirdy and demo123
  3. 3
    Copy the access_token and refresh_token
  4. 4
    Click Authorize and paste the raw access_token only
  5. 5
    Call GET /me (should return user info)
  6. 6
    Call POST /refresh with the refresh_token
  7. 7
    Repeat GET /me using the newly issued access token
πŸ’‘
Swagger tip (important): in FastAPI Swagger’s Authorize modal for HTTPBearer, paste the token value only. Swagger already adds the Bearer prefix for requests.

Swagger Demo: POST /login

Swagger UI showing POST /login executed and returning access and refresh tokens
Execute POST /login with thirdy / demo123. The response returns bothaccess_token and refresh_token.

Swagger Demo: GET /me (Authorized)

Swagger UI showing GET /me success after authorizing with the access token
After authorizing with the access token, GET /me returns the decoded user data from the token claims.

Swagger Demo: POST /refresh

Swagger UI showing POST /refresh executed with a refresh token to return new tokens
Send the refresh_token to POST /refresh to get a new access token (and a new refresh token in this demo).
POST /login
{
  "username": "thirdy",
  "password": "demo123"
}

Response
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}
πŸ’‘
Debugging Tip: Use jwt.io for quick inspection
If a token is failing, paste your local demo token into jwt.io to inspect the header and payload claims.
  • Check token_type (must be access for /me)
  • Check exp if the token is already expired
  • Check sub, username, and role for expected values
⚠️
Do not paste production tokens or secrets into jwt.io. Use it for local/dev debugging only (like this tutorial’s dummy tokens).

Beginner Scenarios (Try These)

These scenarios help you understand why we separate access and refresh tokens, not just how to generate them.

ScenarioWhat to doExpected result
1. Happy path loginCall /login with thirdy/demo123You receive access_token + refresh_token
2. Protected endpoint successUse access token in Authorization: Bearer ... then call /me200 response with user info
3. Wrong token typeSend refresh token to /me401 + "Access token required"
4. Refresh flowSend refresh token to /refreshNew access token (and new refresh token) is issued
5. Invalid tokenChange 1 character in a token and retry401 + "Invalid token"
6. Expired tokenTemporarily set JWT_ACCESS_TOKEN_EXPIRES=5 and wait401 + "Token expired" then use refresh token

Common Beginner Mistakes (and Fixes)

MistakeSymptomFix
Using refresh token on /me401 Access token requiredUse access token for protected API calls
Swagger modal with `Bearer <token>`401 Invalid token (double Bearer)Paste raw token only in Swagger Authorize modal
Missing Bearer prefix in curl/Postman401 Missing Bearer token / auth failsSend `Authorization: Bearer <token>`
Wrong secret keyInvalid token after restartKeep same JWT secret in `.env` while testing
Very long access expiryLooks fine in demo but weak securityKeep access short, refresh longer
Treating JWT as encryptedSensitive data exposed in payloadJWT payload is readable; never store passwords/secrets
⚠️
Important: JWT payloads are Base64URL encoded, not encrypted. Anyone holding the token can read the claims.

What to Add Next (After You Understand JWT)

Once the beginner flow is clear, then you can safely add production pieces step by step:

  • Bcrypt password hashing for real user passwords
  • Database users instead of dummy in-memory data
  • Refresh token rotation and token revocation
  • HttpOnly cookies for browser-based apps
  • Rate limiting on login endpoint
βœ…
Recap: If you can explain when to use create_access_token vs create_refresh_token, and you can test the cycle in Swagger, you already understand JWT fundamentals in FastAPI.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.