Skip to content

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:

shell
uv add pyjwt

The token-generation function lives in security.py alongside the password helpers:

src/release_tracker/security.py
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:

src/release_tracker/config.py
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:

shell
uv run python -c "import secrets; print(secrets.token_urlsafe(32))"

Drop the result into .env:

.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.