Verify AuthPI access tokens in your backend with jose or PyJWT — signature checks via JWKS, issuer/audience validation, the at+jwt header, scope checks, and unknown-kid handling.
Last updated 2026-06-12
Your API receives Authorization: Bearer <token> and must decide whether to trust it. AuthPI access tokens are signed JWTs, so the decision is local: verify the signature against the issuer’s published keys and check a handful of claims. No call to AuthPI per request, no shared secrets to distribute — just the JWKS endpoint and a JWT library.
Every snippet on this page was run against real AuthPI-issued tokens before publishing.
In order:
kid header names a key in the issuer’s JWKS; the signature must verify against it.typ header is at+jwt — access tokens declare it (RFC 9068). This single check keeps ID tokens and refresh tokens out of your API: a stolen 7-day refresh token can’t be replayed where a 30-minute access token belongs.iss — exactly your issuer URL (https://idp.authpi.com/{issuer_id}, or your custom domain).aud — your API’s identifier: the resource audience if the client requested one, otherwise the client ID. See aud precedence for which value to expect.exp — every library checks expiry by default; just don’t disable it.Only after all five pass do you look at scope (a space-separated string) for authorization.
jose runs on Node 18+, Cloudflare Workers, Deno, and Bun, and handles JWKS caching and unknown-kid refetching for you:
import { createRemoteJWKSet, jwtVerify, errors } from 'jose';
const ISSUER = 'https://idp.authpi.com/{issuer_id}';
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/jwks.json`));
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: 'https://api.example.com',
algorithms: ['ES256', 'EdDSA', 'RS256'],
typ: 'at+jwt',
});
return payload;
}
Construct JWKS once at module scope — it caches the key set and re-fetches (with a built-in cooldown) when it sees an unknown kid.
Using it in a handler, with authorization on scope:
async function handleRequest(req: Request): Promise<Response> {
const auth = req.headers.get('Authorization');
if (!auth?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
let claims;
try {
claims = await verifyAccessToken(auth.slice(7));
} catch (err) {
// Signature, issuer, audience, expiry, or typ failed — all are 401s.
return new Response('Unauthorized', { status: 401 });
}
const scopes = (claims.scope as string ?? '').split(' ');
if (!scopes.includes('read:reports')) {
return new Response('Forbidden', { status: 403 }); // authenticated, not authorized
}
return Response.json({ reports: [], requested_by: claims.sub });
}
Deploying on Cloudflare Workers? The Workers quickstart wraps exactly this into a complete deployable project.
pip install "pyjwt[crypto]". PyJWKClient fetches and caches the JWKS and resolves the right key from the token’s kid:
import jwt
from jwt import PyJWKClient
ISSUER = "https://idp.authpi.com/{issuer_id}"
AUDIENCE = "https://api.example.com"
# Module scope: caches keys between calls
jwks_client = PyJWKClient(f"{ISSUER}/jwks.json")
def verify_access_token(token: str) -> dict:
# PyJWT does not check the typ header for you — do it explicitly (RFC 9068)
header = jwt.get_unverified_header(token)
if header.get("typ") != "at+jwt":
raise jwt.InvalidTokenError("not an access token")
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
key=signing_key.key,
algorithms=["ES256", "EdDSA", "RS256"],
audience=AUDIENCE,
issuer=ISSUER,
)
And the authorization layer:
claims = verify_access_token(token) # raises jwt.InvalidTokenError family on any failure → 401
scopes = claims.get("scope", "").split()
if "read:reports" not in scopes:
abort(403) # authenticated, not authorized
The failures you’ll see are precise: InvalidSignatureError for tampering, InvalidAudienceError/InvalidIssuerError for misdirected tokens, ExpiredSignatureError past exp. Treat them all as 401.
If your stack has a different JWT library, configure it to these values:
| Check | Expected value |
|---|---|
| Signature keys | Fetched from {issuer_url}/jwks.json, selected by the token’s kid header |
| Allowed algorithms | ES256, EdDSA, RS256 — an explicit allowlist, never “whatever the token says” |
typ header | at+jwt (reject anything else at an API) |
iss claim | Your issuer URL, exact string match |
aud claim | Your API’s audience (resource audience or client ID) |
exp | Enforced (default everywhere; don’t turn it off) |
scope claim | Space-separated string — split on spaces before checking |
Caching: honor the JWKS endpoint’s Cache-Control: public, max-age=3600 and its ETag for cheap revalidation. The full caching and rotation contract is in the JWKS reference.
kid: refetch once, then rejectAuthPI rotates signing keys monthly, and superseded keys stay published for weeks afterward — so a valid token’s key is always in the current JWKS, but maybe not in your cached copy from before a rotation. When kid lookup misses:
Rate-limit the refetch (once per minute per process is plenty): tokens with garbage kid values must not let an attacker turn your verifier into a request amplifier. jose’s createRemoteJWKSet implements the full pattern, cooldown included. PyJWT’s PyJWKClient refetches on an unknown kid but with no cooldown — every garbage-kid token triggers a cache-bypassing fetch to the issuer. If your Python API accepts tokens from untrusted callers, wrap the unknown-kid path with your own throttle (track when you last forced a refresh and skip re-fetching within a minute of it).
jwt.decode(token, options={"verify_signature": False}) and friends read claims from an unverified token. Anyone can mint one. If you never constructed a key, you never verified anything.typ check. Without it your API accepts ID tokens and refresh tokens as if they were access tokens. jose checks it via the typ option; PyJWT needs the explicit header check shown above.alg. Always pass an explicit algorithms allowlist. Libraries are good about rejecting alg: none these days, but the allowlist also blocks cross-algorithm confusion.organizations claim shapes