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

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

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](https://console.authpi.com/signup?ref=/docs/quickstarts/cloudflare-workers))
- Node.js 18+ and a Cloudflare account for `wrangler`

## Step 1: Create the project

```bash
mkdir authpi-worker && cd authpi-worker
npm init -y
npm install jose
npm install -D wrangler
```

[`jose`](https://github.com/panva/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:

```jsonc
{
  "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](/docs/reference/token-claims)).

## Step 2: Write the Worker

Create `src/index.ts`:

```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](/docs/reference/jwks-key-rotation) requires of a verifier.

## Step 3: Run it locally

```bash
npx wrangler dev
```

In another terminal, the public route works without credentials:

```bash
curl http://localhost:8787/
# {"status":"ok"}
```

And the protected routes reject anonymous calls:

```bash
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](/docs/guides/m2m-auth)). 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.

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

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

```bash
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

- [Validate tokens guide](/docs/guides/validate-tokens) — the verification rules in depth, plus Python and generic-library variants
- [JWKS & key rotation](/docs/reference/jwks-key-rotation) — the key publication contract behind `createRemoteJWKSet`
- [Token claims reference](/docs/reference/token-claims) — everything inside `claims`, including organization memberships for multi-tenant authorization
- [Machine-to-machine auth](/docs/guides/m2m-auth) — choosing between M2M clients, org API keys, and agent identities