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.
str()¶
__str__ is generate(), so a token model can be dropped anywhere a string is
expected:
str() signs the token
Because __str__ signs, printing a model in a log statement or an f-string
writes a valid credential to the log. It also raises jwt_missing_key on
a model without an encoding_key, which can turn a debug print() into an
exception. Use repr(token) or token.model_dump() when you only want to
look at the claims.
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 == str(token) #> 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 str().
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 dictionary 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 —
see Security notes.
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_.
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 |
|---|---|
str(token), 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 |
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=str(AccessToken(sub="user-42")))
API reference¶
Full signatures: JWTModel.