Most FastAPI auth tutorials stop at "it returns a token". This one goes further: one file, 118 lines, five endpoints, and every response below comes from actually running it, including the failure cases most guides skip: no token, wrong password, a tampered token and an expired one.

I build it line by line in this video:

Tested with: Python 3.12 · FastAPI 0.128 · PyJWT 2.15 · pwdlib 0.2 (Argon2) · Pydantic 2.13 · Uvicorn 0.39.

What you'll build

Endpoint What it does
GET / Root message and a link to the docs
GET /health Health check
POST /token Log in with username and password and get a JWT
GET /me Returns the logged-in user (token required)
GET /protected Example of a guarded route (token required)

The flow: the client sends a username and password to /token, gets back a signed JWT, then sends it as Authorization: Bearer <token> on every protected request. One dependency, get_current_user(), does all the checking, so protecting a new route is a single line.

1. Install

requirements.txt:

fastapi>=0.115.0 uvicorn[standard]>=0.30.0 pyjwt>=2.9.0 pwdlib[argon2]>=0.2.0 python-multipart>=0.0.9 python-dotenv>=1.0.1 
python3.12 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt 
  • PyJWT signs and verifies tokens. (FastAPI's docs moved from python-jose to PyJWT.)
  • pwdlib[argon2] hashes passwords with Argon2, the modern replacement for passlib.
  • python-multipart is needed because the login endpoint reads an HTML-style form.

Use Python 3.10 or newer: the code uses str | None type hints, which Pydantic rejects on 3.9.

2. Config: secret, algorithm, expiry

load_dotenv() app = FastAPI(title="FastAPI JWT Authentication", version="1.0.0") JWT_SECRET = os.getenv("JWT_SECRET", "dev-only-secret-change-me") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 password_hash = PasswordHash.recommended() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 

PasswordHash.recommended() picks Argon2. OAuth2PasswordBearer(tokenUrl="token") tells FastAPI where tokens come from. It also powers the Authorize button in Swagger UI.

Generate a real secret and put it in .env. More on why below:

python -c "import secrets; print(secrets.token_urlsafe(32))" 
JWT_SECRET=paste-the-output-here 

3. Users, models and password checks

For the demo, users live in a dict, and the password is hashed when the app starts, so no plain-text password is ever stored:

DEMO_USERS = { "demo": { "username": "demo", "full_name": "Demo User", "hashed_password": password_hash.hash("demo123"), } } class Token(BaseModel): access_token: str token_type: str class User(BaseModel): username: str full_name: str | None = None def authenticate_user(username: str, password: str) -> User | None: record = DEMO_USERS.get(username) if record is None: return None if not password_hash.verify(password, record["hashed_password"]): return None return get_user(username) 

Notice that User has no password field. Because the routes declare response_model=User, the hash can never leak into a response by accident.

4. Creating the token

def create_access_token(data: dict, expires: timedelta | None = None) -> str: payload = data.copy() if expires is None: expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) payload["exp"] = datetime.now(timezone.utc) + expires return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) 

Use datetime.now(timezone.utc), not the deprecated utcnow(). PyJWT checks exp for you when it decodes.

5. The dependency that guards every route

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User: error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) except InvalidTokenError: raise error username = payload.get("sub") if username is None: raise error user = get_user(username) if user is None: raise error return user 

Two details matter here:

  • algorithms=[ALGORITHM] is a list you pass explicitly. Never let the token's own header decide the algorithm.
  • Every failure returns the same 401. A bad signature, an expired token and an unknown user all look identical to the client, which gives attackers nothing to learn from.

6. The endpoints

@app.post("/token", response_model=Token) async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): user = authenticate_user(form.username, form.password) if user is None: raise HTTPException(status_code=401, detail="Incorrect username or password") token = create_access_token({"sub": user.username}) return Token(access_token=token, token_type="bearer") @app.get("/me", response_model=User) async def read_me(user: Annotated[User, Depends(get_current_user)]): return user @app.get("/protected") async def protected(user: Annotated[User, Depends(get_current_user)]): return { "message": f"Hello {user.username}, your JWT is valid.", "framework": "FastAPI", "language": "Python", } 

To protect any new route, add user: Annotated[User, Depends(get_current_user)] to its parameters. That's the whole pattern.

7. Run it

uvicorn app:app --reload 

Open http://127.0.0.1:8000/docs, click Authorize, and log in with demo / demo123. Or test it with curl, as I did below.

8. Real test results: the happy path

Log in:

curl -X POST http://127.0.0.1:8000/token -d "username=demo&password=demo123" 
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW...","token_type":"bearer"} 

A JWT is three base64url parts separated by dots: header, payload, signature. Decoding the first two:

{"alg":"HS256","typ":"JWT"} {"sub":"demo","exp":1791785986} 

The payload is readable by anyone. It's signed, not encrypted, so never put passwords or private data in it.

Use the token:

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/me 
{"username":"demo","full_name":"Demo User"} 
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/protected 
{"message":"Hello demo, your JWT is valid.","framework":"FastAPI","language":"Python"} 

9. Real test results: the failure cases

This is the part that tells you your auth actually works.

Test Status Response
/me with no token 401, www-authenticate: Bearer {"detail":"Not authenticated"}
Login with a wrong password 401 {"detail":"Incorrect username or password"}
/me with the last character of the token changed 401 {"detail":"Could not validate credentials"}
/me with a token that expired 1 minute ago 401 {"detail":"Could not validate credentials"}

Changing a single character breaks the signature, and the expired token is rejected by PyJWT's built-in exp check. Neither needed any extra code.

The warning PyJWT 2.15 gave me

When I signed a token with the fallback secret, PyJWT printed:

InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. 

"dev-only-secret-change-me" is only 25 bytes. For HS256 your secret should be at least 32 bytes. secrets.token_urlsafe(32) gives you 43 characters, which is plenty. Set JWT_SECRET in .env and the warning disappears.

Before you ship this

This is a learning foundation, not a finished security system:

  • Move users into a real database, and hash passwords at signup, not at startup.
  • Keep the secret out of the repo. Use a secrets manager in production, and rotate it.
  • Add refresh tokens, so the 30-minute access token can stay short-lived.
  • Rate-limit /token to slow down password guessing.
  • Use HTTPS. A bearer token is as good as a password while it's valid.
  • Keep the failure tests from section 9 in your test suite.

Full video

Every line is typed on screen, then tested live in Swagger UI: FastAPI JWT Authentication Tutorial (2026).

Stuck on an error? Paste the traceback in the comments and I'll take a look.

Drafted with AI assistance. The code is from my own project, and every response above comes from a real run I did on October 12, 2026.