Skip to content

Getting started

Pick a key class

Each kind of key has a class. It takes the key and the algorithms it may use, and checks them once, when created. Create it at startup and reuse it for every token.

class key algorithms can
SecretKey a shared secret, str or bytes HS256 HS384 HS512 encode, decode
PrivateKey a private key PEM, str or bytes RS* PS* ES256 ES256K ES384 ES512 ES521 EdDSA encode, decode
PublicKey a public key PEM, str or bytes, or a JWKS as PrivateKey decode
JWKSClient a JWKS URL as PrivateKey decode

Keys and algorithms has the details.

Encode and decode

A SecretKey signs and verifies. The claims can be a Struct, a BaseModel (typed claims) or a dict:

import secrets
from datetime import UTC, datetime, timedelta

import msgspec
import ryjwt


class Claims(msgspec.Struct):
    sub: str
    exp: datetime


key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])

# Tokens store exp in whole seconds, so round it for the round trip to compare equal.
expires = datetime.now(UTC).replace(microsecond=0) + timedelta(minutes=15)
claims_in = Claims(sub="user-1", exp=expires)

token = key.encode(claims_in)
claims_out = key.decode(token, into=Claims)
assert claims_in == claims_out
import secrets
from datetime import UTC, datetime, timedelta

import pydantic
import ryjwt


class Claims(pydantic.BaseModel):
    sub: str
    exp: datetime


key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])

# Tokens store exp in whole seconds, so round it for the round trip to compare equal.
expires = datetime.now(UTC).replace(microsecond=0) + timedelta(minutes=15)
claims_in = Claims(sub="user-1", exp=expires)

token = key.encode(claims_in)
claims_out = key.decode(token, into=Claims)
assert claims_in == claims_out
import secrets
import time

import ryjwt

key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])

token = key.encode({"sub": "user-1", "exp": int(time.time()) + 900})
claims = key.decode(token)  # a dict

A real service loads the secret from its configuration: at least 32 bytes for HS256 (HMAC secrets).

decode checks the signature, then that the token hasn't expired (exp) and isn't used too early (nbf). Set audience and issuer to also check who it's for (aud) and who issued it (iss):

import secrets
import time

import ryjwt

key = ryjwt.SecretKey(
    secrets.token_bytes(32),
    algorithms=["HS256"],
    audience="my-api",
    issuer="https://issuer.example/",
)
token = key.encode({
    "sub": "user-1",
    "aud": "my-api",
    "iss": "https://issuer.example/",
    "exp": int(time.time()) + 900,
})

claims = key.decode(token)

A token with an aud claim is rejected unless you set audience: ryjwt won't assume it's for you. decode also takes audience and issuer, replacing the key's for that call. Encoding and decoding covers every check.

Handle invalid tokens

Every rejection raises an InvalidTokenError. Respond 401:

import secrets
import time

import ryjwt

key = ryjwt.SecretKey(secrets.token_bytes(32), algorithms=["HS256"])
expired = key.encode({"sub": "user-1", "exp": int(time.time()) - 60})

try:
    key.decode(expired)
except ryjwt.InvalidTokenError as e:
    print(f"401: {e}")  # 401: Signature has expired

Its subclasses, in Errors, tell the reasons apart.

Typed claims

With into=Claims (above), decode returns a Claims, and your type checker knows it.

The class also validates the claims, raising ClaimsValidationError if they don't fit. So a required field makes its claim required: here, a token without exp is rejected. As a dict, it would be accepted, and never expire.

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`
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`

Public-key signatures

When one service issues tokens and others verify them, use a key pair. For instance, with OpenSSL:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

The issuer signs with the PrivateKey. Verifiers get only the PublicKey, which can't sign:

import ryjwt

signer = ryjwt.PrivateKey.from_path("private.pem", algorithms=["ES256"])
token = signer.encode({"sub": "user-1"})

verifier = ryjwt.PublicKey.from_path("public.pem", algorithms=["ES256"])
claims = verifier.decode(token)

To let other services fetch your public keys, rather than copy public.pem, publish them as a JWKS: see Publishing your keys.

For tokens from an identity provider (Auth0, Okta, Entra ID, Google, Keycloak, ...), use a JWKSClient. It fetches the provider's public keys and keeps them up to date.

Let the type checker help

algorithms takes Literals, so a type checker catches a typo or an algorithm that doesn't fit the key. Use HMACAlgorithm and AsymmetricAlgorithm in your own annotations. PublicKey has no encode, and decode(token, into=Claims) returns a Claims. Untyped code gets runtime errors instead.