pydantic-jwt
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.
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")andAccessToken.model_validate({"sub": "x"})construct a token you are about to sign; onlyfrom_token()(and validating from a token string) checks a signature. Setverified_only=Trueso the model refuses payloads outright — see below. str(token)does not sign. Signing isgenerate()andjwt_str, nothing else.str(token)renders the claims like any Pydantic model, sof"Bearer {token}"is a bug — writef"Bearer {token.generate()}".- Unknown claims are rejected. Models default to
extra="forbid", so tokens from third-party issuers that add their own claims needmodel_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=Falseaccepts 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)
| File | Size | Uploaded | |
|---|---|---|---|
| pydantic_jwt-1.0.0.tar.gz | 138.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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