DEV Community

Dmitry
Dmitry

Posted on

Stop passing JWTs around as dicts

Every Python codebase I have read that deals with JWTs ends up in the same
place. A helper decodes the token, a few if statements check exp and maybe
iss, and what comes out the other side is a dict[str, Any]. From then on the
claims are string keys. payload["sub"] works, payload["user_id"] returns a
KeyError at three in the morning, and nothing in your editor knows the
difference.

Meanwhile the same project has Pydantic models for every request body, because
of course it does. The token — the one piece of data that arrives from an
untrusted source and decides who the caller is — is the only thing still typed
as a dictionary.

So I wrote pydantic-jwt, which lets you
declare a token the way you declare everything else:

from pydantic_jwt import ConfigDict, Exp, JWTModel, after, uuid


class AccessToken(JWTModel):
    model_config = ConfigDict(
        algorithm="HS256",
        encoding_key=SECRET,
        decoding_key=SECRET,
    )

    sub: str
    scopes: list[str] = []
    exp: Exp = after(minutes=15)
    jti: str = uuid()
Enter fullscreen mode Exit fullscreen mode

That one class is both ends of the flow.

raw = AccessToken(sub="user-42", scopes=["read"]).generate()

token = AccessToken.from_token(raw)
token.sub  # 'user-42' — a str, and your editor knows it
Enter fullscreen mode Exit fullscreen mode

from_token() parses the token, validates the claims, and verifies the
signature. If any of that fails you get an exception, not a dict you have to
remember to check.

Claims that check themselves

Exp, Nbf and Iat are annotated int types that compare against the
current clock. IssClaim and AudClaim compare against a value you expect:

from typing import Annotated

from pydantic_jwt import AudClaim, Exp, IssClaim, JWTModel


class IncomingToken(JWTModel):
    model_config = ConfigDict(algorithm="RS256", decoding_key=PUBLIC_KEY)

    sub: str
    exp: Exp
    iss: Annotated[str, IssClaim("https://auth.example.com")]
    aud: Annotated[str | list[str], AudClaim("billing-api")]
Enter fullscreen mode Exit fullscreen mode

They are plain Annotated metadata, so they compose with anything else Pydantic
can do to a field, and you can write your own by subclassing Claim.

iss and aud stop mattering the moment more than one service shares a signing
key — without aud, a token minted for your low-privilege service is accepted
by the high-privilege one. It is the kind of check everybody knows about and
half of us skip because it is one more if.

Failures are just ValidationErrors

This is the part that turned out to matter most in practice. An expired token, a
forged token and a token with a missing claim all fail through Pydantic's normal
error path. Which means a FastAPI dependency is four lines:

def current_token(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
) -> AccessToken:
    try:
        return AccessToken.from_token(credentials.credentials)
    except ValueError:
        raise HTTPException(401, "Invalid or expired token") from None


CurrentToken = Annotated[AccessToken, Depends(current_token)]


@app.get("/me")
def me(token: CurrentToken) -> dict[str, object]:
    return {"user": token.sub, "scopes": token.scopes}
Enter fullscreen mode Exit fullscreen mode

ValueError catches the whole family, because both ValidationError and
PydanticCustomError subclass it. If you want to tell "expired" from "forged" —
the first means go refresh, the second means log in again — the error type
carries it, and there is a
worked example
in the docs.

The endpoint body gets a typed object. token.scopes autocompletes. mypy
catches the typo that payload["scopes"] would have discovered in production.

The bug I shipped, and how it got fixed

Here is the part I would rather not write, and the part that is actually worth
reading.

The model accepts a token string and a claims dict — it has to, or the
constructor could not build a token you are about to sign. Which means this
looks completely reasonable and is a hole:

@app.post("/admin")
def admin(token: AccessToken) -> None: ...   # DANGEROUS
Enter fullscreen mode Exit fullscreen mode

FastAPI parses the JSON body straight into the model. A client sends
{"sub": "admin"} and gets an AccessToken instance that no signature was ever
checked against. No key needed — the attacker just does not send a string.

Version 0.2.0 documented this loudly and left it. Documenting a hole is not
fixing it, so 1.0.0 added an off switch:

class IncomingToken(JWTModel):
    model_config = ConfigDict(
        algorithm="HS256",
        decoding_key=SECRET,
        verified_only=True,
    )

    sub: str
    exp: Exp
Enter fullscreen mode Exit fullscreen mode

With verified_only=True the model accepts nothing but a token string whose
signature was verified. The dict path is refused with a
jwt_unverified_payload error, so a JWTModel is safe as a field type.

Getting there took three attempts. The two that failed are instructive if you
ever write custom Pydantic schemas: defining __init__ on a model flips
pydantic-core's custom_init flag, after which all validation routes through
your __init__ — including model_validate(). Any validation context the
caller passed is replaced by whatever your __init__ supplies. The signal I was
using to mark "this came from the constructor" was destroyed by the act of
sending it. Reading the config in the validator instead of threading a flag
through the context sidesteps the whole problem.

What it deliberately does not do

  • Revocation. A JWT is valid until it expires. Give tokens a jti, keep lifetimes short, check a denylist yourself.
  • JWKS. No key-fetching client. Pass the right key per call; JWTStr lets you read the kid header before verifying.
  • Encryption. Tokens are signed, not encrypted — anyone holding one can read the claims.
  • FastAPI. There is no FastAPI dependency in the package. It works with anything built on Pydantic; the framework integration is an example, not a coupling.

One thing it does do by construction: the algorithm comes from your
configuration and never from the token's alg header. That is what prevents
algorithm confusion, where an attacker re-signs an RS256 token as HS256
using your public key as the HMAC secret.

The full list of sharp edges lives on the
security notes page, which I
would rather you read than my marketing.

Links

pip install pydantic-jwt
Enter fullscreen mode Exit fullscreen mode

Python 3.10+, Pydantic 2.10+, PyJWT under the hood.

I am the author, and this is the first release I consider stable. If you try it
and something is awkward, open an issue — at this stage the design is still
cheap to change.

Top comments (0)