Skip to content

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:

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

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:

raw = token.generate(encoding_key=next_key, algorithm="HS512")

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:

str(AccessToken(sub="user-42"))  #> "sub='user-42' exp=1788009360"

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:

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

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:

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

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:

token = AccessToken.from_token(raw)

It runs three steps, in this order:

  1. Structural parse. The string is wrapped in JWTStr, which checks that it is three base64url segments whose header and payload decode to JSON objects.
  2. Claim validation. The payload is passed to model_validate(), so field types, Exp/Nbf/Iat/iss/aud checks, extra="forbid" and any validators of your own run here.
  3. Signature verification. The raw string is handed to jwt.decode() with decoding_key and algorithm.

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():

raw = SessionToken.from_claims(sub="user-42", exp=int(time.time()) + 900).generate()

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.