JSON Web Tokens
HTTP is stateless: every request is independent. The server has no memory of the fact that the same client successfully logged in a moment ago.
To carry identity across requests, the server hands the client a token after login and the client sends that token with every authenticated request. The token has to be cheap to verify, hard to forge, and self-contained.
Generating Tokens with PyJWT
PyJWT is the standard Python library for encoding and decoding JWTs. Add it to the project:
uv add pyjwt
The token-generation function lives in security.py alongside the password helpers:
from datetime import UTC, datetime, timedelta
import jwt
from .config import get_settings
ACCESS_TOKEN_EXPIRE_MINUTES = 60
JWT_ALGORITHM = "HS256"
def create_access_token(
*, subject: str, expires_delta: timedelta | None = None
) -> str:
expires_at = datetime.now(UTC) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
payload = {
"sub": subject,
"exp": expires_at,
}
return jwt.encode(
payload,
get_settings().jwt_secret_key.get_secret_value(),
algorithm=JWT_ALGORITHM,
)
subject is keyword-only and string-typed — the user's ID, stringified. expires_delta lets a caller override the default token lifetime (useful for tests that want a deliberately-short or expired token), and falls back to one hour.
The secret comes from settings, not a constant. That's what the next subsection is about.
The Secret Belongs in Settings
A signing secret in source code is one short step from "leaked secret in a public repo." It also can't differ between development and production, which means an attacker who learns the staging key can mint valid production tokens.
Settings gets a new field:
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = DEFAULT_DATABASE_URL
debug: bool = False
jwt_secret_key: SecretStr = Field(min_length=32)
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8"
)
SecretStr is the Pydantic type for sensitive values: when a Settings instance is logged or repr'd, the field renders as ********** instead of leaking the actual key. The min_length=32 constraint refuses to start the app if the configured key is too short to be safe.
There's no default. If JWT_SECRET_KEY is missing from the environment, the application crashes at startup with a Pydantic validation error. That's intentional: a secret you forgot to set is worse than a missing database URL, because the app will appear to work locally with whatever value the developer pasted in by accident.
To generate a strong key for development:
uv run python -c "import secrets; print(secrets.token_urlsafe(32))"
Drop the result into .env:
DATABASE_URL=postgresql+psycopg://release_tracker:release_tracker@localhost:5432/release_tracker
DEBUG=true
JWT_SECRET_KEY=<paste the generated value here>
.env stays out of git
The .env file holds the database URL, the JWT signing key, and (eventually) any other secrets the application needs. It belongs in .gitignore from day one, and a leaked .env is the same severity as a leaked database password. Chapter 11 covers the production side of secret handling.
The application can now mint signed tokens. Verifying them on every authenticated request is what the next page covers.