Guides

Validate AuthPI tokens in your API

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.

What a correct verifier checks

In order:

  1. Signature — the token’s kid header names a key in the issuer’s JWKS; the signature must verify against it.
  2. 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.
  3. iss — exactly your issuer URL (https://idp.authpi.com/{issuer_id}, or your custom domain).
  4. 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.
  5. 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.

TypeScript / Node with jose

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.

Python with PyJWT

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.

Using any other library

If your stack has a different JWT library, configure it to these values:

CheckExpected value
Signature keysFetched from {issuer_url}/jwks.json, selected by the token’s kid header
Allowed algorithmsES256, EdDSA, RS256 — an explicit allowlist, never “whatever the token says”
typ headerat+jwt (reject anything else at an API)
iss claimYour issuer URL, exact string match
aud claimYour API’s audience (resource audience or client ID)
expEnforced (default everywhere; don’t turn it off)
scope claimSpace-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.

Unknown kid: refetch once, then reject

AuthPI 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:

  1. Re-fetch the JWKS and retry the lookup once.
  2. Still missing → the token is invalid. Reject with 401.

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).

Mistakes that pass code review

  • Decoding without verifying. 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.
  • Skipping the 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.
  • Trusting the token’s own alg. Always pass an explicit algorithms allowlist. Libraries are good about rejecting alg: none these days, but the allowlist also blocks cross-algorithm confusion.
  • Checking scopes before the signature. Authorization decisions on unverified claims are decisions an attacker makes for you. Verify first, authorize second — and return 401 for verification failures, 403 for missing scopes.
  • Fetching the JWKS on every request. Construct the remote key set once at module scope. Per-request fetching adds a network hop to every call and melts under load.

Next steps