Claims¶
A claim marker is a frozen dataclass attached to a field with Annotated. It
adds an "after" validator to whatever schema the field already has, so the field
keeps its normal type — an Exp really is an int — and gains a rule that runs
every time the model validates.
Markers are not tied to JWTModel. They work on any BaseModel, which is
useful when you are validating a payload someone else decoded.
Time-based claims¶
Three claims compare an integer timestamp against the current clock. Each has a ready-made alias so the common case stays short:
| Alias | Expands to | Rule | Registered claim |
|---|---|---|---|
Exp |
Annotated[int, ExpClaim()] |
value > now — reject once expired |
RFC 7519 §4.1.4 |
Nbf |
Annotated[int, NbfClaim()] |
value <= now — reject before it becomes valid |
§4.1.5 |
Iat |
Annotated[int, IatClaim()] |
value <= now — reject if issued in the future |
§4.1.6 |
from pydantic_jwt import Exp, Iat, JWTModel, Nbf
class SessionToken(JWTModel):
sub: str
exp: Exp
nbf: Nbf
iat: Iat
Each marker also re-checks the type it received and raises jwt_type ("Value
must be an integer") for anything that is not an int. On the Exp/Nbf/Iat
aliases Pydantic has already produced an int by then, so that guard only fires
when you attach a marker to a looser annotation:
Leeway¶
Server clocks drift. leeway, in seconds, widens the accepted window on the
risky side of each check:
from typing import Annotated
from pydantic_jwt import ExpClaim, IatClaim, NbfClaim
class SessionToken(JWTModel):
exp: Annotated[int, ExpClaim(leeway=30)] # accept up to 30s after expiry
nbf: Annotated[int, NbfClaim(leeway=30)] # accept up to 30s early
iat: Annotated[int, IatClaim(leeway=30)] # tolerate a 30s-fast issuer
Keep it small — tens of seconds. Leeway on exp is extra lifetime granted to
every token, including revoked ones.
Opting out¶
A claim you want in the payload but do not want checked is just an int:
Issuer and audience¶
IssClaim and AudClaim take
the expected value as their argument. They have no aliases, because the value is
specific to your deployment.
from typing import Annotated
from pydantic_jwt import AudClaim, IssClaim, JWTModel
ISSUER = "https://auth.example.com"
class AccessToken(JWTModel):
sub: str
iss: Annotated[str, IssClaim(ISSUER)]
aud: Annotated[str, AudClaim("billing-api")]
iss is an exact string match — "https://auth.example.com/" with a trailing
slash fails.
aud follows RFC 7519 and accepts both shapes a token may use. Declare the
field as a union to allow either:
- a string — must equal the expected audience;
- a list of strings — the expected audience must be in the list.
Anything else raises jwt_type ("Value must be a string or a list of
strings").
One issuer, several services
Each service declares its own model with its own AudClaim, so a token
minted for billing-api is rejected by reports-api even though both trust
the same issuer and key.
Skipping claim checks¶
Passing validate_claims=False in the validation context disables every claim
marker for one call, while field types, extra handling and the signature check
all still run:
token = SessionToken.model_validate(raw, context={"validate_claims": False})
token = SessionToken.from_token(raw, context={"validate_claims": False})
Use it to inspect an expired token — logging a stale sub, deciding whether to
issue a refresh, writing a test around a fixed payload. It is not a way to
"accept" a token: nothing that comes out of such a call is safe to treat as a
live credential.
Any other context value leaves the checks on:
Custom claims¶
Subclass Claim, set __claim_name__ and implement
check(). Return True to accept; anything else raises jwt_claim_invalid with
the claim name and the offending value in the error context.
import time
from dataclasses import dataclass
from typing import Annotated, Any
from pydantic_core import PydanticCustomError
from pydantic_jwt import Claim
@dataclass(frozen=True)
class AuthTimeClaim(Claim):
"""Reject tokens whose authentication is older than `max_age` seconds."""
__claim_name__ = "auth_time"
max_age: float = 3600.0
def check(self, value: Any) -> bool:
if not isinstance(value, int):
raise PydanticCustomError("jwt_type", "Value must be an integer")
return time.time() - value <= self.max_age
class StepUpToken(JWTModel):
sub: str
auth_time: Annotated[int, AuthTimeClaim(max_age=300)]
Points worth copying from the built-ins:
- Keep it
@dataclass(frozen=True). Markers live inAnnotatedmetadata, which is shared between every instance of the model; a mutable marker is a shared mutable global. - Validate the type inside
check(). RaisePydanticCustomError("jwt_type", ...)for a value of the wrong shape and reserve theFalsereturn for a value of the right shape that fails the rule. The two produce different error types, which callers can tell apart. check()sees the value after the field's own validation. The marker is an after validator, so on anAnnotated[int, ...]field Pydantic has already produced anint. OnAnnotated[Any, ...]it has not, which is why the built-ins still check.- Respect the context for free.
validate_claims=Falseis handled by the base class, not bycheck().
A custom marker composes with everything else on the field:
API reference¶
Full signatures: Claims.