Protect a Cloudflare Workers API with AuthPI access tokens — JWT verification with jose, scope-based authorization, local testing, and deploy.
Last updated 2026-06-12
In this quickstart you’ll build a Cloudflare Worker that accepts AuthPI access tokens: public routes stay open, protected routes require a valid token, and sensitive routes additionally require a scope. Verification is local — the Worker checks signatures against your issuer’s published keys, adding no per-request call to AuthPI.
The Worker below was run and tested end-to-end (401/403/200 paths, including a forged-token attempt) before publishing.
Prerequisites:
wranglermkdir authpi-worker && cd authpi-worker
npm init -y
npm install jose
npm install -D wrangler
jose is the JWT library — it runs natively on Workers and handles JWKS caching for you.
Create wrangler.jsonc with your issuer URL and your API’s audience:
{
"name": "authpi-worker",
"main": "src/index.ts",
"compatibility_date": "2025-06-01",
"vars": {
"AUTHPI_ISSUER_URL": "https://idp.authpi.com/{issuer_id}",
"AUTHPI_AUDIENCE": "https://api.example.com"
}
}
AUTHPI_ISSUER_URL is your issuer’s URL (find it in the console, or use your custom domain). AUTHPI_AUDIENCE is the identifier your tokens carry in aud — the resource audience if your clients request one, otherwise the client ID (which applies to you).
Create src/index.ts:
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { JWTPayload } from 'jose';
interface Env {
AUTHPI_ISSUER_URL: string;
AUTHPI_AUDIENCE: string;
}
// Cache the remote key set across requests within the isolate. jose handles
// JWKS caching and re-fetches automatically when it sees an unknown kid.
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(env: Env) {
jwks ??= createRemoteJWKSet(new URL(`${env.AUTHPI_ISSUER_URL}/jwks.json`));
return jwks;
}
async function verifyAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
const auth = request.headers.get('Authorization');
if (!auth?.startsWith('Bearer ')) return null;
try {
const { payload } = await jwtVerify(auth.slice(7), getJwks(env), {
issuer: env.AUTHPI_ISSUER_URL,
audience: env.AUTHPI_AUDIENCE,
algorithms: ['ES256', 'EdDSA', 'RS256'],
typ: 'at+jwt',
});
return payload;
} catch {
return null; // signature, issuer, audience, expiry, or typ failed
}
}
function hasScope(claims: JWTPayload, scope: string): boolean {
return ((claims.scope as string) ?? '').split(' ').includes(scope);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Public route — no token required
if (url.pathname === '/') {
return Response.json({ status: 'ok' });
}
// Everything below requires a valid AuthPI access token
const claims = await verifyAccessToken(request, env);
if (!claims) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
if (url.pathname === '/me') {
return Response.json({ sub: claims.sub, scope: claims.scope });
}
if (url.pathname === '/reports') {
// Authenticated is not authorized: check the scope for this route
if (!hasScope(claims, 'read:reports')) {
return Response.json({ error: 'insufficient_scope' }, { status: 403 });
}
return Response.json({ reports: [], requested_by: claims.sub });
}
return Response.json({ error: 'not_found' }, { status: 404 });
},
};
Three details worth noticing:
typ: 'at+jwt' rejects anything that isn’t an access token. ID tokens and refresh tokens are signed by the same keys — without this check a stolen refresh token would pass your verifier.createRemoteJWKSet caches keys, honors the JWKS endpoint’s cache headers, and re-fetches on an unknown kid (with a built-in cooldown) — which is exactly what key rotation requires of a verifier.npx wrangler dev
In another terminal, the public route works without credentials:
curl http://localhost:8787/
# {"status":"ok"}
And the protected routes reject anonymous calls:
curl -i http://localhost:8787/me
# HTTP/1.1 401 Unauthorized
# {"error":"unauthorized"}
The quickest way to mint a real access token from the command line is an M2M client using the client_credentials grant (set one up in two minutes with the machine-to-machine guide). One prerequisite: read:reports must be in the client’s allowed scopes (settings.scopes when you register it) — requesting a scope the client wasn’t granted returns an invalid_scope error.
TOKEN=$(curl -s -X POST "https://idp.authpi.com/{issuer_id}/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials&scope=read:reports" | jq -r .access_token)
If your application authenticates users, any access token from your login flow works the same way.
Now exercise the matrix:
# Valid token → 200 with the caller's identity
curl -H "Authorization: Bearer $TOKEN" http://localhost:8787/me
# {"sub":"...","scope":"read:reports"}
# Valid token with the read:reports scope → 200
curl -H "Authorization: Bearer $TOKEN" http://localhost:8787/reports
# {"reports":[],"requested_by":"..."}
(With a user-flow token you’d also see OIDC scopes like openid in scope; client_credentials tokens never carry them — the grant issues no ID tokens, so openid is filtered out.)
A token without the route’s scope gets a 403 (insufficient_scope), and any tampered token — try editing one character of it — fails signature verification and gets a 401. The scopes a token carries are fixed at issuance and signed; a caller can’t grant themselves read:reports by editing the payload.
npx wrangler deploy
Re-run the same curls against your *.workers.dev URL (or your route). Nothing changes: verification is self-contained, keys are fetched from your issuer’s public JWKS, and there are no secrets to configure on the Worker — the two vars in wrangler.jsonc are not sensitive.
createRemoteJWKSetclaims, including organization memberships for multi-tenant authorization