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-07-19

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 exact identifier. One selected resource produces a string; several selected resources produce an array that must contain your identifier. Without a resource selection, the legacy audience or client ID applies. 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.

Writing the verifier

Pick your language below. The TypeScript path uses jose — it runs on Node 18+, Cloudflare Workers, Deno, and Bun, and handles JWKS caching and unknown-kid refetching for you. The Python path uses PyJWT (pip install "pyjwt[crypto]"), whose PyJWKClient fetches and caches the JWKS and resolves the right key from the token's kid.

In both languages, construct the remote key set once at module scope so it caches the key set between requests instead of refetching per call.

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;
}
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,
    )

The typ check differs between the two libraries: jose verifies it for you via the typ: 'at+jwt' option, while PyJWT does not check the typ header — so the Python verifier reads the unverified header and rejects anything that isn't at+jwt explicitly (RFC 9068).

Both libraries' audience option handles either JWT representation: it accepts a scalar aud equal to the configured audience or an aud array containing it. If you inspect claims manually, normalize them first—const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]—and compare exact strings. Audience-array order is not meaningful.

Both verifiers re-fetch the JWKS when they encounter an unknown kid, but their throttling differs: jose's createRemoteJWKSet applies a built-in cooldown, whereas PyJWT's PyJWKClient refetches with no cooldown — see Unknown kid below.

Authorizing on scope

Once verification passes, split the scope claim and check the scopes the caller needs. Verification failures map to 401; a verified token that lacks the required scope maps to 403.

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 });
}
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

In Python, the failures you'll see are precise: InvalidSignatureError for tampering, InvalidAudienceError/InvalidIssuerError for misdirected tokens, ExpiredSignatureError past exp. Treat them all as 401.

Deploying on Cloudflare Workers? The Workers quickstart wraps exactly the jose verifier into a complete deployable project.

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 claimA string equal to your API's exact audience, or an array containing it
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.
  • Assuming aud is always a string. Repeated RFC 8707 resources produce an audience array. Use your JWT library's audience option or normalize the claim and check exact membership; do not depend on array order.
  • 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