Encoding and decoding¶
SecretKey and PrivateKey can encode and decode; PublicKey and JWKSClient can only
decode. The arguments are the same everywhere.
Encoding¶
encode(claims, *, algorithm=None, header=None) signs claims and returns the token as a str.
import secrets
import ryjwt
key = ryjwt.SecretKey(secrets.token_bytes(64), algorithms=["HS256", "HS512"])
token = key.encode({"sub": "user-1"}, algorithm="HS512", header={"kid": "key-1"})
claims: a dict, a msgspecStructor a pydanticBaseModel. A Struct or model is serialised by its own library and must give a JSON object. Models use field aliases unless their config setsserialize_by_alias=False.algorithm: one of the key'salgorithms. Optional if the key has only one.header: extra header fields, such as akid(must be a str). Singular, as a token has one header (PyJWT saysheaders).
ryjwt sets alg itself, and "typ": "JWT" unless header sets typ. It won't make a token decode would
reject: alg, crit or b64 in header, or over 64 fields, is a ValueError.
Decoding¶
decode(token, *, into=None, audience=None, issuer=None, leeway=0) verifies token (str or
bytes) and returns its claims. audience and issuer usually go on the
key instead.
import secrets
import time
import msgspec
import ryjwt
class Claims(msgspec.Struct):
sub: str
aud: str
exp: int
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"], audience="my-api")
token = key.encode({"sub": "user-1", "aud": "my-api", "exp": int(time.time()) + 900})
claims = key.decode(token, into=Claims)
assert claims.sub == "user-1"
import secrets
import time
import pydantic
import ryjwt
class Claims(pydantic.BaseModel):
sub: str
aud: str
exp: int
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"], audience="my-api")
token = key.encode({"sub": "user-1", "aud": "my-api", "exp": int(time.time()) + 900})
claims = key.decode(token, into=Claims)
assert claims.sub == "user-1"
decode checks the header's alg is one of the key's algorithms, then the signature. Only then
does it read the payload, into a dict or your into class, and check the claims
below. Any failure is an InvalidTokenError (Errors lists
the kinds).
Claims checks¶
| claim | checked | rejected when | raises |
|---|---|---|---|
exp (expiry) |
if present | it has passed | ExpiredSignatureError |
nbf ("not before") |
if present | it's still to come | ImmatureSignatureError |
aud (who it's for) |
always | it doesn't match audience |
InvalidAudienceError |
iss (who issued it) |
only if you set issuer |
it doesn't match issuer |
InvalidIssuerError |
exp and nbf are seconds since 1970 (UTC); a non-number is rejected too. Neither is
required, so a token without exp never expires. To require them, make them required fields of your
into class.
aud is a string or list of strings. With an audience, the token must have a matching aud.
Without one, a token with an aud is rejected: it's meant for some service, and ryjwt won't
assume it's yours.
iss is a string. audience and issuer may also be lists (any iterable): any one may match.
leeway allows for clock skew between the issuer and you: seconds or a timedelta, added to
both exp and nbf. Negative or infinite is a ValueError.
import secrets
import time
from datetime import timedelta
import ryjwt
key = ryjwt.SecretKey(
secrets.token_bytes(32),
algorithms=["HS256"],
audience="my-api",
issuer=["https://issuer.example/", "https://old-issuer.example/"],
)
token = key.encode({
"sub": "user-1",
"aud": ["my-api", "other-api"],
"iss": "https://issuer.example/",
"exp": int(time.time()) - 5, # expired 5 seconds ago
})
claims = key.decode(token, leeway=timedelta(seconds=30)) # so still accepted
Other claims, such as sub, iat or jti, aren't checked: check the ones you rely on.
On the key, or for one call¶
audience and issuer set on the key, as above, apply to every decode. Passed to decode, they
replace the key's for that call; they aren't added:
import secrets
import ryjwt
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"], audience="my-api")
token = key.encode({"sub": "user-1", "aud": "admin-api"})
claims = key.decode(token, audience="admin-api") # checked against "admin-api" only
Leaving them out, or passing None, uses the key's. You can't switch a check off for one call: use
a second key object for tokens that need different checks.
Why issuer is optional, but aud is strict¶
The JWT standard requires rejecting a token whose aud doesn't name you, but leaves iss up to
you. A trusted key usually belongs to one issuer, so a valid signature already says who made the
token.
Not always: some identity providers serve many customers (tenants), each its own issuer, with one
set of keys. A valid signature then proves only the provider made the token, not which tenant. Set
issuer whenever you know it, and always when one key or JWKS serves several issuers.
Headers¶
decode reads two header fields: alg, and kid (which picks the key in a
JWKS). It ignores the rest, including typ. It rejects a header that:
- isn't a JSON object, repeats a field, or has over 64 fields;
- has no
alg, or a non-string one; - has a non-string
kid, when the keys havekids to pick from; - has
crit, which lists extensions the reader must understand. ryjwt understands none, so the standard says to reject the token.
Inspecting a token without verifying it¶
unverified_header, unverified_claims and unverified_token read a token without checking the
signature, exp, nbf, aud or iss. Anyone can make a token say anything, so what they return
is a claim, not a fact.
import secrets
import ryjwt
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
token = key.encode({"sub": "user-1", "iss": "https://tenant-1.example/"}, header={"kid": "key-1"})
header, claims = ryjwt.unverified_token(token)
assert header["kid"] == "key-1"
assert claims["iss"] == "https://tenant-1.example/"
claims = key.decode(token) # verified: now you can trust it
Use them for debugging and logging, or to pick which key or tenant to verify with: say, reading
iss when each tenant has its own keys. Then decode with that key, issuer set, so the iss
you picked by is checked too. Never use them to decide, say, who the user is from sub: only
decode tells you that. To pick a key by kid, PublicKey.from_jwks
and JWKSClient already do it.
The token must still be well formed: three base64url parts, with a JSON object as header and
payload. If not, they raise DecodeError. They return dicts (no into).
Typed claims¶
Pass a msgspec Struct or pydantic BaseModel class as into, and decode returns an instance
of it instead of a dict:
import secrets
import time
import msgspec
import ryjwt
class Claims(msgspec.Struct):
sub: str
exp: int
roles: list[str] = []
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
token = key.encode(Claims(sub="user-1", exp=int(time.time()) + 900, roles=["admin"]))
claims = key.decode(token, into=Claims)
assert claims.roles == ["admin"]
import secrets
import time
import pydantic
import ryjwt
class Claims(pydantic.BaseModel):
sub: str
exp: int
roles: list[str] = []
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
token = key.encode(Claims(sub="user-1", exp=int(time.time()) + 900, roles=["admin"]))
claims = key.decode(token, into=Claims)
assert claims.roles == ["admin"]
A parametrised generic Struct works too (into=Claims[int]). Any other into is a TypeError.
If the claims don't fit (a missing required field, or a value of the wrong type or out of range),
decode raises ClaimsValidationError. The message names the
claim; __cause__ is the msgspec or pydantic ValidationError:
import secrets
import msgspec
import ryjwt
class Claims(msgspec.Struct):
sub: str
exp: int
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
token = key.encode({"sub": "user-1"}) # no exp
try:
key.decode(token, into=Claims)
except ryjwt.ClaimsValidationError as e:
print(e) # Claims don't match Claims: Object missing required field `exp`
assert isinstance(e.__cause__, msgspec.ValidationError)
import secrets
import pydantic
import ryjwt
class Claims(pydantic.BaseModel):
sub: str
exp: int
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
token = key.encode({"sub": "user-1"}) # no exp
try:
key.decode(token, into=Claims)
except ryjwt.ClaimsValidationError as e:
print(e) # Claims don't match Claims: Field required - at `exp`
assert isinstance(e.__cause__, pydantic.ValidationError)
Your class's own code (a msgspec __post_init__, a pydantic validator) runs only after the exp,
nbf, aud and iss checks pass.
Datetime claims¶
A Struct or model can declare exp, nbf and iat as datetime (or datetime | None) rather
than seconds since 1970:
import secrets
from datetime import UTC, datetime, timedelta
import msgspec
import ryjwt
class Claims(msgspec.Struct):
sub: str
exp: datetime
iat: datetime
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
now = datetime.now(UTC)
token = key.encode(Claims(sub="user-1", exp=now + timedelta(minutes=15), iat=now))
claims = key.decode(token, into=Claims)
assert claims.exp.tzinfo is not None # a datetime, in UTC
import secrets
from datetime import UTC, datetime, timedelta
import pydantic
import ryjwt
class Claims(pydantic.BaseModel):
sub: str
exp: datetime
iat: datetime
key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
now = datetime.now(UTC)
token = key.encode(Claims(sub="user-1", exp=now + timedelta(minutes=15), iat=now))
claims = key.decode(token, into=Claims)
assert claims.exp.tzinfo is not None # a datetime, in UTC
Encoding. A datetime in exp, nbf or iat (in a Struct, a model, or at a dict's top
level) is written as whole seconds.
- A naive
datetimeis aValueError: ryjwt won't guess its timezone. datetimes in other fields are written as usual: a date string from msgspec and pydantic, aTypeErrorin a dict.Noneis written asnull, whichdecoderejects forexpandnbf. To omit it, useomit_defaults=Trueon a Struct, orField(exclude_if=lambda v: v is None)in pydantic.
Decoding. A datetime field gets a UTC datetime, fraction included. A dict keeps numbers.
pydantic and very large times
pydantic alone reads numbers from 2e10 on (past the year 2603) as milliseconds, or all numbers
if the model's val_temporal_unit says so. ryjwt passes times to datetime fields in a form
pydantic can't misread, so they're always seconds. That covers datetime, AwareDatetime and
the like, optional or Annotated.
Decoding to a dict: msgspec or jiter¶
Dicts are read with msgspec if it's installed
(uv add 'ryjwt[msgspec]'), as it's faster for typical tokens, else with the built-in
jiter. Each key picks when created; unverified_claims and
unverified_token pick at each call.
Both give the same dict for any valid payload and reject the same malformed ones. They differ only on payloads no real token contains:
- a number too big for a float (
1e400): msgspec rejects it, jiter readsinf; - nesting over about 200 levels: jiter rejects it, msgspec reads it (up to Python's recursion limit).