Skip to main content

JWT Authentication

Users can sign up. Now they need to log in. HTTP is stateless—every request is independent. How does the server know who's making a request?

Tokens.

The user logs in once, gets a token, and includes it in every subsequent request. The server validates the token to identify the user. No session storage needed.

How JWT Works

1. User sends email/password to /token
2. Server verifies password (using verify_password from L08)
3. Server creates a signed JWT containing user identity
4. User includes token in Authorization header
5. Server validates signature, extracts user

Key insight: JWTs are signed, not encrypted. Anyone can read the payload. But only your server can create valid signatures.

Installing Dependencies

uv add python-jose[cryptography]
  • python-jose - JWT encoding/decoding library
  • [cryptography] - Cryptographic backend for signing

JWT Configuration

Add to config.py:

class Settings(BaseSettings):
# ... existing settings ...

secret_key: str # For signing tokens
algorithm: str = "HS256"
access_token_expire_minutes: int = 30

Add to .env:

SECRET_KEY=your-secret-key-here

Generate a secure key:

openssl rand -hex 32

Token Functions

Add to auth.py:

from datetime import datetime, timedelta
from typing import Optional
from jose import jwt, JWTError
from config import get_settings

settings = get_settings()


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a signed JWT token."""
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)


def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token."""
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
return None

Test token creation:

>>> from auth import create_access_token, decode_token
>>> token = create_access_token({"sub": "alice@example.com"})
>>> token
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZUBleGFtcGxlLmNvbSIsImV4cCI6MTcwNTMxODIwMH0.xxxxx'
>>> decode_token(token)
{'sub': 'alice@example.com', 'exp': 1705318200}

The token has three parts (separated by dots):

  1. Header - Algorithm info ({"alg": "HS256"})
  2. Payload - Your data ({"sub": "alice@example.com", "exp": ...})
  3. Signature - Proves the token is authentic

Login Endpoint

OAuth2 expects a specific request format. Add to main.py:

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import Session, select
from datetime import timedelta
from models import User
from security import verify_password
from auth import create_access_token
from database import get_session
from config import get_settings

app = FastAPI(title="Task API")
settings = get_settings()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


@app.post("/token")
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(get_session)
):
"""Authenticate user and return JWT token."""
# Find user by email
user = session.exec(
select(User).where(User.email == form_data.username)
).first()

# Verify credentials
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)

# Create token
access_token = create_access_token(
data={"sub": user.email},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes)
)
return {"access_token": access_token, "token_type": "bearer"}

Security note: The error message is intentionally generic. "Incorrect email or password" doesn't reveal whether the email exists—preventing enumeration attacks.

Test the login:

curl -X POST http://localhost:8000/token \
-d "username=alice@example.com&password=SecurePass123"

Output:

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}

Note: OAuth2 uses form data (not JSON) and the field is called username even though we're using email.

Protecting Routes

Create a dependency that extracts the current user from the token:

from auth import decode_token


async def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session)
) -> User:
"""Extract and validate user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

payload = decode_token(token)
if payload is None:
raise credentials_exception

email: str = payload.get("sub")
if email is None:
raise credentials_exception

user = session.exec(select(User).where(User.email == email)).first()
if user is None:
raise credentials_exception

return user

Now use it to protect routes:

@app.get("/users/me")
def read_current_user(current_user: User = Depends(get_current_user)):
"""Return current user info."""
return {"id": current_user.id, "email": current_user.email}

Test protected route:

# Without token - fails
curl http://localhost:8000/users/me
# {"detail":"Not authenticated"}

# With token - succeeds
curl http://localhost:8000/users/me \
-H "Authorization: Bearer eyJhbGci..."
# {"id": 1, "email": "alice@example.com"}

Protecting Task Routes

Associate tasks with users:

@app.post("/tasks", status_code=201)
def create_task(
task: TaskCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Create a task for the current user."""
db_task = Task(**task.dict(), owner_id=current_user.id)
session.add(db_task)
session.commit()
session.refresh(db_task)
return db_task


@app.get("/tasks")
def list_tasks(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""List tasks belonging to current user."""
return session.exec(
select(Task).where(Task.owner_id == current_user.id)
).all()

Now users only see their own tasks.

Swagger UI Integration

FastAPI's Swagger UI has built-in OAuth2 support:

  1. Open /docs
  2. Click the "Authorize" button (lock icon)
  3. Enter email and password
  4. Click "Authorize"
  5. All requests now include the token automatically

This makes testing protected endpoints easy without manually copying tokens.

Hands-On Exercise

Step 1: Install python-jose:

uv add python-jose[cryptography]

Step 2: Add JWT settings to config.py and .env

Step 3: Create auth.py with token functions

Step 4: Add /token endpoint to main.py

Step 5: Create get_current_user dependency

Step 6: Add /users/me protected route

Step 7: Test the complete flow:

# Create a user (from L08)
curl -X POST http://localhost:8000/users/signup \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "password": "SecurePass123"}'

# Login to get token
curl -X POST http://localhost:8000/token \
-d "username=bob@example.com&password=SecurePass123"

# Use token to access protected route
curl http://localhost:8000/users/me \
-H "Authorization: Bearer <your-token>"

Common Mistakes

Mistake 1: Using JSON for /token

# Wrong - OAuth2 expects form data
curl -X POST http://localhost:8000/token \
-H "Content-Type: application/json" \
-d '{"username": "test", "password": "secret"}'

# Correct - form data
curl -X POST http://localhost:8000/token \
-d "username=test&password=secret"

Mistake 2: Forgetting WWW-Authenticate header

# Wrong - browsers won't prompt for credentials
raise HTTPException(status_code=401, detail="Not authenticated")

# Correct - proper header
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

Mistake 3: Putting sensitive data in tokens

# Wrong - anyone can decode JWTs!
create_access_token({"sub": email, "password": password})

# Correct - only identifiers
create_access_token({"sub": email})

Mistake 4: Hardcoding the secret key

# Wrong - exposed in code
SECRET_KEY = "my-secret-key"

# Correct - from environment
settings.secret_key

The Authentication Flow

Here's the complete picture:

┌─────────────┐     POST /users/signup      ┌─────────────┐
│ Client │ ───────────────────────────►│ Server │
│ │ {"email", "password"} │ │
│ │◄─────────────────────────── │ (hashes & │
│ │ {"id", "email"} │ stores) │
└─────────────┘ └─────────────┘

┌─────────────┐ POST /token ┌─────────────┐
│ Client │ ───────────────────────────►│ Server │
│ │ username=&password= │ │
│ │◄─────────────────────────── │ (verifies & │
│ │ {"access_token": ...} │ signs JWT) │
└─────────────┘ └─────────────┘

┌─────────────┐ GET /tasks ┌─────────────┐
│ Client │ ───────────────────────────►│ Server │
│ │ Authorization: Bearer │ │
│ │◄─────────────────────────── │ (validates │
│ │ [user's tasks] │ & serves) │
└─────────────┘ └─────────────┘

Try With AI

Prompt 1: Token Expiration

My JWT tokens expire after 30 minutes. What happens when a
user's token expires mid-session? How should my frontend
handle this? Should I implement refresh tokens?

What you're learning: Token expiration is a UX and security tradeoff. Short-lived tokens are more secure but require refresh logic.

Prompt 2: Custom Token Claims

I want to include user roles in my JWT so I can check permissions
without a database query. What are the tradeoffs?
What claims should vs shouldn't go in a JWT?

What you're learning: JWTs can carry any data, but there are size and staleness tradeoffs. Roles cached in JWTs can't be revoked instantly.

Prompt 3: Testing Authentication

How do I write pytest tests for my protected endpoints?
I need to:
1. Create a test user
2. Get a token in the test
3. Include it in requests
4. Test both authenticated and unauthenticated cases

What you're learning: Testing auth requires fixtures and patterns. Understanding these makes your test suite reliable.


Reflect on Your Skill

You built a fastapi-agent skill in Lesson 0. Test and improve it based on what you learned.

Test Your Skill

Using my fastapi-agent skill, help me implement JWT authentication.
Does my skill include token generation, validation, protected routes, and OAuth2 password flow?

Identify Gaps

Ask yourself:

  • Did my skill include JWT token creation with python-jose?
  • Did it handle OAuth2PasswordBearer and get_current_user dependency?
  • Did it cover protected endpoints and token validation?

Improve Your Skill

If you found gaps:

My fastapi-agent skill is missing JWT authentication patterns.
Update it to include token creation/validation with python-jose,
OAuth2PasswordBearer setup, get_current_user dependency,
and protected route implementation with Depends().