Token models¶
JWTModel is a pydantic.BaseModel subclass whose
instances are JWTs. Everything a normal Pydantic model can do — validators,
computed fields, nested models, aliases, inheritance — still applies; the class
adds encoding, decoding and signature verification on top.
from pydantic_jwt import ConfigDict, Exp, JWTModel, after
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)
Issuing tokens¶
generate()¶
generate() dumps the model with
model_dump(mode="json") and signs the result:
mode="json" means every field is serialised the way it would be in JSON — a
datetime claim becomes an ISO-8601 string, an Enum becomes its value, a
UUID becomes a string. Custom serialisers declared with
@field_serializer are respected, so you control exactly what lands in the
payload.
Both arguments are optional and override model_config:
This is how key rotation is done without declaring a second model — see Configuration.
If neither the argument nor model_config supplies a key and an algorithm,
generate() raises a jwt_missing_key error.
jwt_str¶
jwt_str returns the signed token as a
JWTStr — a str subclass with header, payload, algorithm
and signature properties:
token = AccessToken(sub="user-42")
token.jwt_str == token.generate() #> True
token.jwt_str.header #> {'alg': 'HS256', 'typ': 'JWT'}
token.jwt_str.payload #> {'sub': 'user-42', 'exp': 1788009360}
It signs on every access, exactly like generate(). Use it when you want to
look inside the token you just issued; use generate() when you only need the
string.
str(token) does not sign
Signing is generate() and jwt_str only. str(token) is Pydantic's
default, so it renders the claims:
Which means f"Bearer {token}" produces a broken header rather than a
credential. Write f"Bearer {token.generate()}".
Earlier releases signed in __str__. That was removed because it made every
log line and f-string a potential credential leak, and made print() raise
on a model without an encoding_key. If you are upgrading, replace
str(token) with token.generate().
from_claims()¶
from_claims() builds a model from claim
values rather than from a token:
On an ordinary model that is just model_validate() over keyword arguments, and
the plain constructor does the same thing. It earns its keep on a
verified_only model, where it is the only way
to construct one on the issuing side.
It takes an optional positional-only validation context before the claims, which is how you build a deliberately invalid token in a test:
Everything else still runs — field types, claim markers, extra handling — so
from_claims(sub="user-42", exp=<past>) raises just like the constructor would.
Reading tokens¶
from_token()¶
from_token() is the explicit entry point:
It runs three steps, in this order:
- Structural parse. The string is wrapped in
JWTStr, which checks that it is three base64url segments whose header and payload decode to JSON objects. - Claim validation. The payload is passed to
model_validate(), so field types,Exp/Nbf/Iat/iss/audchecks,extra="forbid"and any validators of your own run here. - Signature verification. The raw string is handed to
jwt.decode()withdecoding_keyandalgorithm.
Claims are checked before the signature
A token that is both expired and forged reports the expiry, because step 2
runs first. Never treat a specific error type as evidence about the rest of
the token — only a from_token() call that returns is a token that passed
everything.
The keyword arguments override model_config for one call:
AccessToken.from_token(
raw,
decoding_key=old_key,
algorithm="HS256",
require_keys=True,
context={"validate_claims": False},
)
The algorithm is never taken from the token's own alg header. That closes
the classic algorithm-confusion attack, in which an attacker re-signs a token
with alg: HS256 using a public RSA key as the HMAC secret.
model_validate() on a string¶
JWTModel installs a union core schema — token string first, then the normal
model schema — so validating a string does the same work as from_token():
AccessToken.model_validate(raw) # parses, validates, verifies
AccessToken.model_validate({"sub": "user-42"}) # plain model construction
That union is what makes a token model usable as a field type:
from pydantic import BaseModel
class Session(BaseModel):
access_token: AccessToken
session = Session(access_token=raw) # the string is verified here
session.access_token.sub #> 'user-42'
Only a string is verified
AccessToken(sub="x") and AccessToken.model_validate({"sub": "x"}) take
the payload branch of the union: no signature is involved, because you are
building a token to sign, not checking one. Never accept an AccessToken
straight out of a request body and treat it as authenticated — or set
verified_only=True so the model refuses
the payload for you.
Errors from the string branch are reported by Pydantic as a union failure, so
exc.errors() contains one entry per branch. The JWT-specific entry is the one
whose type starts with jwt_.
Refusing unverified payloads¶
verified_only=True closes the gap above at the type level. The model then
accepts only what has actually been verified:
class SessionToken(JWTModel):
model_config = ConfigDict(
algorithm="HS256",
decoding_key=SECRET,
verified_only=True,
)
sub: str
exp: Exp
| Input | Result |
|---|---|
SessionToken.from_token(raw) |
✅ verified, signature checked |
SessionToken.model_validate(raw) — a token string |
✅ verified, signature checked |
Envelope(token=raw) — a token string in a field |
✅ verified, signature checked |
Envelope(token=existing) — an existing instance |
✅ passes through unchanged |
SessionToken.from_claims(sub="x", exp=…) |
✅ deliberate construction |
SessionToken(sub="x", exp=…) |
❌ jwt_unverified_payload |
SessionToken.model_validate({"sub": "x", …}) |
❌ jwt_unverified_payload |
Envelope(token={"sub": "x", …}) — a nested dict |
❌ jwt_unverified_payload |
SessionToken(sub="attacker", exp=253402300799)
# ValidationError: SessionToken only accepts a verified token string
# [type=jwt_unverified_payload]
The nested-dict row is the one that matters: it is what makes a verified_only
model safe to name as a request body field, where a plain model would let a
client hand you any claims they like.
The escape hatch is deliberate and explicit. On the issuing side, build with
from_claims():
Two things it does not cover
model_construct() skips validation entirely, so it bypasses the guard —
that is true of every Pydantic validator, not just this one.
And the flag says nothing about which key verified the token. A model that
trusts several issuers still needs iss/aud
claim markers.
An existing instance passes through by identity (is), so re-validating a model
you already verified costs nothing and cannot launder a payload into one.
Configuration inheritance¶
model_config follows Pydantic's rules — a subclass merges its parent's
config — which makes a shared base model the natural place to put keys:
class Signed(JWTModel):
model_config = ConfigDict(algorithm="HS256", encoding_key=SECRET, decoding_key=SECRET)
class AccessToken(Signed):
sub: str
exp: Exp = after(minutes=15)
class RefreshToken(Signed):
model_config = ConfigDict(encoding_key=REFRESH_SECRET, decoding_key=REFRESH_SECRET)
sub: str
exp: Exp = after(days=30)
jti: str = uuid()
RefreshToken keeps algorithm="HS256" from Signed and overrides only the
keys.
Unknown claims¶
JWTModel sets extra="forbid" by default, so a token carrying a claim the
model does not declare is rejected with extra_forbidden. That is the right
default for tokens you issue yourself: it means the model is an exhaustive
description of the payload.
Third-party issuers (Auth0, Keycloak, Google) add their own claims. Either declare them, or relax the config:
class GoogleIdToken(JWTModel):
model_config = ConfigDict(extra="ignore", algorithm="RS256", decoding_key=PUBLIC_KEY)
sub: str
email: str
exp: Exp
extra="allow" keeps the undeclared claims on the instance and round-trips them
back into a re-signed token.
JSON Schema and OpenAPI¶
A JWTModel describes itself to JSON Schema as a string, not an object,
because that is what travels over the wire:
AccessToken.model_json_schema()
#> {
#> "type": "string",
#> "format": "jwt",
#> "description": "JWT token containing claims: sub, exp"
#> }
Used as a field, it keeps that shape:
{
"type": "object",
"properties": {
"access_token": {
"type": "string",
"format": "jwt",
"title": "Access Token",
"description": "JWT token containing claims: sub, exp"
}
},
"required": ["access_token"]
}
The schema says "string", but serialisation does not sign — see below.
Serialisation¶
Validation is asymmetric with serialisation, and it is worth knowing exactly what each call returns:
| Call | Result |
|---|---|
token.generate(), token.jwt_str |
the signed compact token |
token.model_dump(mode="json") |
dict[str, Any] of claims — what generate() signs |
token.model_dump_json() |
JSON object of claims, e.g. '{"sub":"user-42"}' |
dict(token) |
dict of claims, values unconverted |
str(token) |
Pydantic's field display, e.g. "sub='user-42' exp=1788009360" |
token.model_dump() |
the model instance itself, not a dict |
That last row is a consequence of the token-string-first union schema: the
plain-validator branch carries no serialiser, so Python-mode dumping passes the
object through unchanged. The same applies to a nested field —
Session(access_token=raw).model_dump() yields
{"access_token": AccessToken(...)}, while model_dump_json() yields a nested
JSON object of claims rather than the compact token.
So: use mode="json" whenever you want claims as data, and build the wire
representation explicitly:
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
TokenResponse(access_token=AccessToken.from_claims(sub="user-42").generate())
API reference¶
Full signatures: JWTModel.