> Markdown version of https://authpi.com/docs/guides/validate-tokens/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

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

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](/docs/reference/jwks-key-rotation) 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](/docs/reference/token-claims) 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`](https://github.com/panva/jose) runs on Node 18+, Cloudflare Workers, Deno, and Bun, and handles JWKS caching and unknown-`kid` refetching for you:

```ts
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`:

```ts
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](/docs/quickstarts/cloudflare-workers) 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`:

```python
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:

```python
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:

| 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](/docs/reference/jwks-key-rotation).

## 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

- [JWKS & key rotation](/docs/reference/jwks-key-rotation) — the key publication contract your verifier relies on
- [Token claims reference](/docs/reference/token-claims) — every claim in the tokens you just verified, including both `organizations` claim shapes
- [Cloudflare Workers quickstart](/docs/quickstarts/cloudflare-workers) — this guide's jose verifier as a deployable Worker
- [Machine-to-machine auth](/docs/guides/m2m-auth) — issuing the tokens your services will validate