AI Hints
Home›FastAPI Tutorial›Real-World Features
FastAPI · Lesson 8 of 12

Authentication with JWT

⏱ 3 min read▶ Video at 16:21🎓 Free
Watch this lesson in the FastAPI courseWatch on YouTube ↗Full FastAPI course

Maya's staff can manage the menu. But so can anyone who finds the endpoint. She needs a lock, and a key. Proving who you are is called authentication. So let's add a login.

A lock and a key
From the video: A lock and a key
Staff manage the menu
…but so can anyone who finds it
Authentication = proving who you areso let’s add a login

Like a coat check

Think about a coat check. You show your ID once, and you get a ticket. After that, you only show the ticket. Our API works the same way. A staff member logs in with a username and password, and gets back a token. Then every request carries that token.

Like a coat check
From the video: Like a coat check
Show your IDusername + password
Get a ticketa token
Show the ticketon every request
Come inthe API says yes
POST /tokenlog in
access_tokenthe ticket

Never store plain-text passwords

First rule. Never store passwords as plain text. We store a hash, a one way scrambled version of the password. With the pwdlib library, we hash a password when it is created, and verify it at login. Even if the data leaks, the original passwords stay hidden.

terminal
password_hash.hash("sweetcrumbs")
'$argon2id$v=19$m=65536,t=3,p=4$CfJtNTCrXFE93n2xoNTn/w$KD12sD4AvyiqZRFRFVMA2cl5K3/Byhzps0chFRaF3zo'
password_hash.verify("sweetcrumbs", hashed)
True
password_hash.verify("oops", hashed)
False
Even if the data leaks, the passwords stay hidden

The login endpoint

The login endpoint is slash token. FastAPI gives us OAuth two password request form, which reads the username and password from a form. We look up the user, and verify the hash. If it fails, we return a four hundred and one. If it works, we create a token.

main.py (shortened)python
@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = USERS.get(form.username)
    if not user or not password_hash.verify(form.password, user["hashed_password"]):
        raise HTTPException(status_code=401, detail="Incorrect username or password")
    return {"access_token": create_access_token(user["username"]), "token_type": "bearer"}

Inside a JWT

The token is a JWT, a JSON web token. It has three parts. A header, a payload, and a signature. The payload holds the username, and an expiry time. The signature, made with our secret key, proves nobody changed it. Anyone can read a JWT, so never put secrets inside.

Inside a JWT
From the video: Inside a JWT
Header{"alg":"HS256","typ":"JWT"}
Payload{"sub":"maya","exp":1793326402}
Signatureproves nothing changed
sub = who · exp = when it expires
Anyone can read a JWT: never put secrets inside

Protect an endpoint

To protect an endpoint, we use a dependency. OAuth two password bearer pulls the token from the Authorization header. Our get current user dependency decodes it, checks the signature and the expiry, and finds the user. Any endpoint that asks for the current user is now locked.

main.py (shortened)python
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    return USERS.get(payload.get("sub"))

@app.get("/staff/me")
def read_me(user: CurrentUser): ...
It is just a dependency, like lesson 7

Try it

Let's try it. Without a token, slash staff slash me answers four hundred and one, not authenticated. A wrong password gets a four hundred and one, too. The right password returns a token. And with that token in the Authorization header, we are in.

terminal
curl -s localhost:8000/staff/me
{"detail":"Not authenticated"}
curl -s -X POST localhost:8000/token -d 'username=maya&password=oops'
{"detail":"Incorrect username or password"}
curl -s -X POST localhost:8000/token -d 'username=maya&password=sweetcrumbs'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXlhIiwiZXhwIjoxNzkzMzI2NDAyfQ.d7lgmIcKnZ2WycafcKSFa-shrZRfJZ6ALKskZL8hvI0","token_type":"bearer"}
curl -s localhost:8000/staff/me -H "Authorization: Bearer $TOKEN"
{"username":"maya","full_name":"Maya Baker"}
No token → 401 Not authenticated
Wrong password → 401

The docs have an Authorize button. Enter the username and password once, and every request from the docs now carries the token.

The Authorize button
Log in once: every request from the docs carries the token

The complete code for this lesson

Here is the full main.py exactly as it runs at the end of this lesson. Save it, install FastAPI with pip install "fastapi[standard]", and start it with fastapi dev main.py, then open http://127.0.0.1:8000/docs.

main.pypython
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 pwdlib import PasswordHash

SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-secret-change-me")
ALGORITHM = "HS256"

password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI(title="Sweet Crumbs API")

USERS = {
    "maya": {
        "username": "maya",
        "full_name": "Maya Baker",
        "hashed_password": password_hash.hash("sweetcrumbs"),
    }
}


def create_access_token(username: str):
    expire = datetime.now(timezone.utc) + timedelta(minutes=30)
    return jwt.encode({"sub": username, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = USERS.get(form.username)
    if not user or not password_hash.verify(form.password, user["hashed_password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return {"access_token": create_access_token(user["username"]), "token_type": "bearer"}


def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    user = USERS.get(payload.get("sub"))
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown user")
    return user


CurrentUser = Annotated[dict, Depends(get_current_user)]


@app.get("/staff/me")
def read_me(user: CurrentUser):
    return {"username": user["username"], "full_name": user["full_name"]}

Summary

Staff endpoints are now protected. In a real project, keep the secret key out of your code, and always use HTTPS. But Maya's data still lives in memory, and disappears when the server restarts. In the next lesson, a real database. See you there.

Key takeaways
  • Hash passwords, never store them
  • Login returns a signed token (JWT)
  • A dependency protects each endpoint
Next up: A Real Database — data that survives a restart