Skip to main content

pydantic-jwt

CI PyPI Python License: MIT

JWT tokens as Pydantic models.

Declare your token as a model, and get parsing, claim validation, signature verification and encoding out of it — with the claims typed, autocompleted and checked like any other Pydantic field.

📖 Documentation

Install

pip install pydantic-jwt

Requires Python 3.10+, Pydantic 2.10+ and PyJWT 2.8+.

Basic usage

from pydantic_jwt import ConfigDict, Exp, JWTModel, after, uuid

SECRET = "keep-me-out-of-your-source"


class AccessToken(JWTModel):
    model_config = ConfigDict(
        algorithm="HS256",
        encoding_key=SECRET,
        decoding_key=SECRET,
    )

    sub: str
    exp: Exp = after(minutes=15)
    jti: str = uuid()

That single class is both ends of the flow.

Issue a token:

token = AccessToken(sub="user-42")
raw = token.generate()  # 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'

Read one back — the string is parsed, exp is validated, and the signature is verified against decoding_key:

token = AccessToken.from_token(raw)
token.sub  # 'user-42'

Anything wrong with the token is a normal Pydantic error, so it fits wherever Pydantic already does:

from pydantic import ValidationError

try:
    token = AccessToken.model_validate(untrusted)
except ValidationError as exc:
    {error["type"] for error in exc.errors()}
    # 'jwt_invalid_signature'  — signed with the wrong key
    # 'jwt_claim_invalid'      — expired, wrong issuer, wrong audience
    # 'jwt_format'             — not a JWT at all
    # 'extra_forbidden'        — a claim the model does not declare
    # 'jwt_unverified_payload' — a payload where a verified token was required

Claims

Exp, Nbf and Iat are annotated int types that validate themselves against the current time:

from pydantic_jwt import Exp, Iat, JWTModel, Nbf


class SessionToken(JWTModel):
    sub: str
    exp: Exp
    nbf: Nbf
    iat: Iat

IssClaim and AudClaim check a token was minted by the issuer you expect, for the service you are:

from typing import Annotated

from pydantic_jwt import AudClaim, IssClaim


class AccessToken(JWTModel):
    sub: str
    iss: Annotated[str, IssClaim("https://auth.example.com")]
    aud: Annotated[str | list[str], AudClaim("billing-api")]

Clock skew between servers is handled with leeway, in seconds:

from pydantic_jwt import ExpClaim

exp: Annotated[int, ExpClaim(leeway=30)]

Don't want a claim checked at all? Annotate it as a plain int. Need a rule of your own? Subclass Claim.

Three helpers build field defaults. They are evaluated per instance, so every token gets a fresh value:

from datetime import datetime, timezone

from pydantic_jwt import after, at, uuid


class SessionToken(JWTModel):
    sub: str
    exp: Exp = after(hours=1, minutes=30)
    nbf: Nbf = at(datetime(2030, 1, 1, tzinfo=timezone.utc))
    jti: str = uuid()

after() takes weeks, days, hours, minutes, seconds and milliseconds.

Refusing unverified payloads

By default a JWTModel can be built from a dict as well as from a token — the same class issues and reads, so both are needed. verified_only=True closes the payload branch for models that only ever consume tokens:

class AccessToken(JWTModel):
    model_config = ConfigDict(
        algorithm="HS256",
        encoding_key=SECRET,
        decoding_key=SECRET,
        verified_only=True,
    )

    sub: str
    exp: Exp = after(minutes=15)


AccessToken.from_token(raw)  # ✅ signature checked
AccessToken.model_validate(raw)  # ✅ signature checked

AccessToken(sub="admin")  # ❌ ValidationError: jwt_unverified_payload
AccessToken.model_validate({"sub": "admin"})  # ❌ jwt_unverified_payload

That includes a nested dict, which is what makes such a model safe to name as a request-body field type — a client cannot hand you the claims they would like to have.

On the issuing side, construct one deliberately with from_claims():

raw = AccessToken.from_claims(sub="user-42").generate()

It still validates everything — field types, claim markers, extra — and takes an optional positional context first, for building a deliberately invalid token in a test:

stale = AccessToken.from_claims({"validate_claims": False}, sub="user-42", exp=1)

With FastAPI

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

app = FastAPI()
bearer_scheme = HTTPBearer()


def current_token(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
) -> AccessToken:
    try:
        return AccessToken.from_token(credentials.credentials)
    except ValueError:  # ValidationError and PydanticCustomError are both ValueErrors
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from None


CurrentToken = Annotated[AccessToken, Depends(current_token)]


@app.get("/me")
def me(token: CurrentToken) -> dict[str, str]:
    return {"user": token.sub}

The endpoint body works with a typed object, not a dict of unknown claims. AccessToken also reports itself to OpenAPI as a string with format: jwt, so the schema stays readable.

A complete application — login, refresh-token rotation, scopes and error handling — is in the FastAPI guide.

Configuration

Everything lives in model_config, alongside the usual Pydantic settings:

Key Description
algorithm Algorithm used to sign and verify, e.g. "HS256".
encoding_key Key used by generate() and jwt_str.
decoding_key Key used to verify incoming tokens.
require_keys If False, tokens are accepted without signature verification when no key is configured. Defaults to True.
verified_only If True, the model refuses to be built from a payload — only a verified token string or from_claims(). Defaults to False.

Both directions also take keys per call, which is handy for key rotation and multi-tenant deployments:

raw = token.generate(encoding_key=next_key, algorithm="HS256")
token = AccessToken.from_token(raw, decoding_key=next_key, algorithm="HS256")

Good to know

  • Building a model from a dict does not verify anything. AccessToken(sub="x") and AccessToken.model_validate({"sub": "x"}) construct a token you are about to sign; only from_token() (and validating from a token string) checks a signature. Set verified_only=True so the model refuses payloads outright — see below.
  • str(token) does not sign. Signing is generate() and jwt_str, nothing else. str(token) renders the claims like any Pydantic model, so f"Bearer {token}" is a bug — write f"Bearer {token.generate()}".
  • Unknown claims are rejected. Models default to extra="forbid", so tokens from third-party issuers that add their own claims need model_config = ConfigDict(extra="ignore") or explicit fields.
  • The algorithm is never read from the token header. It always comes from your configuration, which is what defeats algorithm-confusion attacks.
  • require_keys=False accepts unverified tokens. It logs a warning and moves on. Useful in tests, dangerous everywhere else.

More in the security notes.

Documentation

Quickstart The five-minute tour
Token models JWTModel, verified_only, from_claims()
Configuration Keys, algorithms, rotation
Claims Built-in and custom claim markers
Defaults after(), at(), uuid()
Validation and errors Error types and validation context
Working with raw tokens JWTStr
Security notes Sharp edges and scope
FastAPI A complete auth flow
API reference Generated from the source

Development

uv sync --all-groups
uv run pytest            # tests
uv run ruff check .      # lint
uv run mypy .            # types
uv run mkdocs serve      # docs at http://127.0.0.1:8000

The documentation site is built with MkDocs and served by Cloudflare as a static-asset Worker, rebuilt from this repository on every push. The Worker is configured in wrangler.jsonc; CI also runs mkdocs build --strict on every pull request.

License

MIT — see LICENSE.

Metadata

Release files for pydantic-jwt 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pydantic-jwt 1.0.0
File Size Uploaded
pydantic_jwt-1.0.0.tar.gz 138.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pydantic-jwt 1.0.0
File Interpreter ABI Platform
pydantic_jwt-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 152.6 kB

Release files / pydantic_jwt-1.0.0.tar.gz

Download URL pydantic_jwt-1.0.0.tar.gz
Size 138.2 kB
Tags Source
SHA-256 checksum
How to use checksums
08fe9f3d76f926917746c59c51a738e6cbf647518db60f7ca333caf10cc0d7d7
BLAKE2b-256 checksum
How to use checksums
eaf0e9bb6efb637b5b8783d94e985121e1144f6eb729bd22d917c4cfbb870330
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.

Transparency log

Release files / pydantic_jwt-1.0.0-py3-none-any.whl

Download URL pydantic_jwt-1.0.0-py3-none-any.whl
Size 14.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8beea6f76036551395628a29015a6e7b9852b501f3b5309217e14781342133b5
BLAKE2b-256 checksum
How to use checksums
aa9ce5ae692624de23bc1ed054a3268a3ecf49d554862e7ac5a1422db2105ce6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page