Quickstarts

Add authentication to a Cloudflare Worker

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:

  • An AuthPI account with an issuer (create one here)
  • Node.js 18+ and a Cloudflare account for wrangler

Step 1: Create the project

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

Step 2: Write the Worker

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.
  • Verification failures are 401, missing scopes are 403. The token either proves who’s calling (or doesn’t — 401), or proves it but without the right permission (403). Keeping these distinct makes client-side error handling sane.
  • The key set is constructed once per isolate, not per request. 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.

Step 3: Run it locally

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"}

Step 4: Get a test token

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.

Step 5: Deploy

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.

Where to go next