JWT
A caller presents a bearer token. JWTVerifier decides whether to believe it.
grelmicro never issues tokens. It checks the one that arrives: the signature, the key it was signed with, and the claims that bound when and where the token is good for.
Install
Verification runs in a compiled core, which ships as its own wheel.
pip install "grelmicro[jwt]"
Verify a token
Give the verifier the public key and the claim policy once. It parses every key at construction, never per request.
import os
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel
from grelmicro.security import (
JWTConfig,
JWTKey,
JWTVerifier,
TokenRejectedError,
)
app = FastAPI()
# Naming an audience or an issuer requires that claim on every token, so a
# token that simply omits it does not slip past the check.
verifier = JWTVerifier(
JWTConfig(
keys=[
JWTKey(
algorithm="RS256",
key=os.environ["JWT_PUBLIC_KEY"].encode(),
kid="2026-09",
)
],
audience=["grelmicro-api"],
issuer=["https://auth.example.com/"],
)
)
class Caller(BaseModel):
subject: str
scopes: list[str]
def current_caller(
authorization: Annotated[str, Header()] = "",
) -> Caller:
try:
claims = verifier.verify_header(authorization)
except TokenRejectedError as error:
# The reason is a stable tag and never quotes the token, so it is
# safe to hand back and safe to log.
raise HTTPException(
status_code=401,
detail=error.reason,
headers={"WWW-Authenticate": "Bearer"},
) from None
return Caller(subject=claims.subject or "", scopes=sorted(claims.scopes))
@app.get("/orders")
async def list_orders(
caller: Annotated[Caller, Depends(current_caller)],
) -> Caller:
return caller
verify is a plain synchronous call. A verification takes about 12
microseconds, so moving it to a thread costs more than it saves. The
architecture notes have the measurements.
Reject a token
Anything that fails raises TokenRejectedError. Its reason is a stable tag,
so you branch on it instead of matching message text.
from grelmicro.security import TokenRejectedError
try:
claims = verifier.verify_header(request.headers.get("authorization"))
except TokenRejectedError as error:
if error.reason == "expired":
...
The reasons are algorithm, audience, expired, invalid, malformed,
missing-claim, not-yet-valid, scheme, signature, subject, issuer
and unknown-key.
Neither the tag nor the message quotes the token. It is a live credential and the message reaches your logs.
What gets checked
Every verification checks the signature against the key the kid names, then
exp, nbf, and any aud and iss you configured.
exp is always required. A token with no expiry is rejected rather than
trusted forever, and there is no setting that turns that off. Set leeway to
allow clock skew between the issuer and your service.
The algorithm is pinned to the key. A token asking for a different one is
rejected before its signature is checked, so none and the HMAC confusion
attacks have nothing to reach.
Audience and issuer
Naming an audience or an issuer requires that claim. A token that simply
omits aud does not slip past a configured audience, because a check that
applied only to the tokens carrying the claim would be the wrong way round.
JWTConfig(keys=[...], audience=["my-api"], issuer=["https://auth.example.com/"])
That rejects a token with no aud, a token with no iss, and a token naming
either differently.
Leave audience empty for a provider whose tokens carry none. An AWS Cognito
access token names the application in client_id instead, so declaring an
audience would reject every one of them.
RFC 7519 also
requires refusing a token whose aud the service does not answer to, so an
empty audience rejects any token that does carry one.
Required claims
required covers any claim, not only the ones the RFC registers. It only ever
adds: exp is enforced whether or not you name it, and so is any audience
or issuer you configured.
JWTConfig(keys=[...], audience=["my-api"], required=["tenant"])
That rejects a token with no tenant, a token whose tenant is null, and
a token with no exp.
Keys
List one JWTKey per key you accept. A key with a kid serves tokens whose
header names it, and a key without one serves tokens that carry no kid.
JWTConfig(
keys=[
JWTKey(algorithm="RS256", key=current_pem, kid="2026-09"),
JWTKey(algorithm="RS256", key=previous_pem, kid="2026-06"),
],
audience=["grelmicro-api"],
)
Both keys stay live, so a rotation does not reject the tokens issued before it.
From a JWKS document
An OIDC provider publishes its keys as a JWKS document. Hand one you already
have to from_jwks, which reads the kid and alg off each key and skips the
ones published for encryption.
verifier = JWTVerifier(
JWTConfig.from_jwks(jwks, audience=["my-api"], issuer=[issuer])
)
From a JWKS URL
JWKSVerifier fetches the document itself and follows the provider when it
rotates. refresh is a coroutine, verify is not, so no request ever waits on
the provider.
from fastapi import FastAPI
from grelmicro.security import JWKSConfig, JWKSVerifier
from grelmicro.task import Tasks
app = FastAPI()
tasks = Tasks()
# The keys come from the provider, and the provider rotates them.
verifier = JWKSVerifier(
JWKSConfig(
url="https://auth.example.com/.well-known/jwks.json",
audience=["grelmicro-api"],
issuer=["https://auth.example.com/"],
)
)
# Refreshing is the only part that talks to the network, and it happens here
# rather than on a request. It fetches only when the keys are stale, so
# calling it often costs nothing.
@tasks.every(seconds=60)
async def reload_signing_keys() -> None:
await verifier.refresh()
refresh fetches only when the keys are stale, so a task calling it every
minute costs nothing and bounds how long a rotation takes to reach you. Call
it once before serving too, so the first request does not arrive before the
keys do.
Nothing fetches on the request path. A token naming a key the verifier does
not hold is refused and marks the key set stale, so the next scheduled refresh
picks the new keys up. retry_interval puts a floor under how often that can
happen, so a caller inventing kid values cannot make your service hammer
your provider.
A refresh that fails raises JWKSUnavailableError and leaves the loaded keys
in place, so a provider outage does not take authentication down with it.
The endpoint must be https. Bodies are read in chunks and abandoned past
max_bytes, redirects are not followed, and a document with more than
max_keys keys is refused.
| Setting | Default | What it bounds |
|---|---|---|
ttl |
3600 |
How long a fetched document is current |
retry_interval |
60 |
Least time between two fetches |
timeout |
5 |
Wait on the endpoint |
max_bytes |
1048576 |
Largest document accepted |
max_keys |
32 |
Most keys accepted from one document |
The default fetcher uses httpx, and takes either httpx or httpx2,
whichever your application already has. Pass fetch= to use your own client
instead, which is how you reuse a proxy, a certificate authority, mutual TLS,
or your existing tracing and retry policy:
async def fetch(url: str, *, timeout: float, max_bytes: int) -> bytes:
response = await my_client.get(url, timeout=timeout)
response.raise_for_status()
return response.content
verifier = JWKSVerifier(config, fetch=fetch)
A fetcher that goes through your own client keeps the request inside whatever OpenTelemetry instrumentation that client already has, so a slow or failing provider shows up in your traces.
AWS Cognito
Cognito serves its keys at
https://cognito-idp.{region}.amazonaws.com/{pool}/.well-known/jwks.json and
publishes alg on every key.
An access token carries no aud claim. It names the application in client_id
instead, so check that yourself and name aud in required only for ID
tokens.
issuer = f"https://cognito-idp.{region}.amazonaws.com/{pool}"
verifier = JWKSVerifier(
JWKSConfig(
url=f"{issuer}/.well-known/jwks.json",
issuer=[issuer],
required=["token_use"],
)
)
await verifier.refresh()
claims = verifier.verify(token)
if claims.raw["token_use"] != "access" or claims.raw["client_id"] != client_id:
raise TokenRejectedError("audience")
Microsoft Entra ID
Entra serves its keys at
https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys and publishes
signing keys with no alg. from_jwks reads the algorithm the key type
implies, and algorithm= pins one explicitly.
tenant_issuer = f"https://login.microsoftonline.com/{tenant}/v2.0"
verifier = JWKSVerifier(
JWKSConfig(
url=f"https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
algorithm="RS256",
audience=[client_id],
issuer=[tenant_issuer],
)
)
await verifier.refresh()
Repeated tokens
A client resends one token until it expires, so most verifications are repeats. The cache answers a repeat in about 320 nanoseconds against 12 microseconds for a full verification.
An entry expires at whichever comes first, the token's own exp or
cache_ttl. The TTL is what bounds a long-lived token: without it a token with
a 24 hour lifetime would keep being accepted from memory for 24 hours after it
was withdrawn upstream.
JWTConfig(keys=[...], audience=["my-api"], cache_size=1024, cache_ttl=300)
Set cache_size=0 to turn the cache off.
Sizing it
cache_size decides more than any other setting here. Size it above the number
of tokens in flight at once, which is roughly your active callers.
| Traffic | cache_size | hit rate | ns per request |
|---|---|---|---|
| 20 internal callers | 1024 | 100% | 330 |
| 500 users | 1024 | 99.5% | 395 |
| 5,000 users | 1024 | 61.7% | 4,850 |
| 5,000 users | 8192 | 92.1% | 1,268 |
The same traffic costs 3.8 times less on a cache that fits it. Entries are small, 65 bytes per key, so a cache of 8192 holds well under a megabyte of keys.
Tokens are not held in memory
The cache keys on a SHA-256 digest of the token, so a process holds no live bearer token beyond the request that presented it. The digest is computed in the core and costs about 94 nanoseconds on a hit, under 1% of a verification.
Set cache_key="token" to key on the encoded token instead, which is faster by
that 94 nanoseconds and is what an in-process cache normally does.
JWTConfig(keys=[...], audience=["my-api"], cache_key="token")
Shedding a caller that keeps forging
Verifying a forged token costs about what verifying a real one costs, because
the signature has to be checked before any claim can be trusted. A caller
sending forged tokens therefore buys real work per request. ClientBans
counts those failures and refuses the caller for a while, which turns that
cost into a dictionary lookup.
It is off unless you ask for it. Pass a ClientBans to the verifier, and
give every call the address to hold responsible:
from grelmicro.security import ClientBans, ClientBannedError
verifier = JWTVerifier(config, bans=ClientBans())
try:
claims = verifier.verify_header(authorization, client=client_ip)
except ClientBannedError:
raise HTTPException(status_code=429) from None
except TokenRejectedError as error:
raise HTTPException(status_code=401, detail=error.reason) from None
Counting the failure and refusing the client happen for you, so the
protection cannot be half wired. A verifier built with bans and then called
without a client raises rather than quietly counting nothing.
ClientBannedError is not a TokenRejectedError. It says nothing about the
token, so answer it with 429 and not 401: a fresh token would not change
the answer.
JWKSVerifier takes the same argument and behaves the same way.
The address has to be one the caller cannot choose. Pass what
resolve_client_address returns, never a raw
X-Forwarded-For, or an attacker sets a header and gets somebody else
refused.
The table is also usable on its own, through banned() and record(), for
an authentication scheme this module does not handle.
Why not rate limit instead
Rate limiting every request ahead of verification also sheds the load, and costs more. Measured on one machine, per request:
| Step | ns |
|---|---|
banned() on an honest caller |
55 |
| Verify a token already seen | 299 |
| Rate limiter, in memory | 906 |
| Verify a token for the first time | 11,619 |
| Rate limiter, over Redis | 243,059 |
A limiter in front of verification charges every honest request to shed traffic that is usually not there, and a distributed one charges twenty times what the verification it protects costs. Counting failures charges nothing until a caller has already proven itself, and then charges 88 ns to refuse it.
What counts as abuse
Only signature, malformed and algorithm are counted by default. Each
means the token was never issued by anyone the service trusts.
The reasons left out matter more. unknown-key is what every client sees for
a moment when the provider rotates its signing keys. Counting it bans a
service's own users on every rotation: with five hundred clients retrying
while a rotation lands, counting rejections by reason bans none of them, and
counting every 401 bans all five hundred.
expired is a client that needs to refresh. not-yet-valid is a clock that
disagrees. audience and issuer are a token meant for a neighbouring
service. None of them is an attack.
Pass reasons= to choose a different set, and keep duration short. An
address is shared behind NAT, so a ban reaches more people than the one caller
that earned it.
Routing on an untrusted header
unverified_header reads alg and kid without checking the signature. Use
it to route a token to the right verifier, never to decide whether a token is
valid.
kid = verifier.unverified_header(token)["kid"]