Cloudflare Workers Integration
Architecture overview
Section titled “Architecture overview”Authgent runs as a standalone authorization server on your infrastructure (VPS, Kubernetes, Docker). Cloudflare Workers acts as the MCP resource server — it validates JWTs at the edge using Authgent’s JWKS endpoint.
MCP Client (Claude, Cursor) │ ├─ OAuth flow ──→ Authgent (your VPS / K8s) │ ├─ Issues JWTs signed with your keys │ └─ JWKS at /.well-known/jwks.json │ └─ MCP calls ──→ Cloudflare Worker (edge) ├─ Fetches JWKS (cached at edge) ├─ Verifies JWT with Web Crypto API └─ Handles MCP toolsThe Worker never implements any OAuth endpoints. It only verifies tokens that Authgent issued.
Why self-hosted Authgent for Cloudflare
Section titled “Why self-hosted Authgent for Cloudflare”| Concern | Hosted auth (WorkOS, Auth0) | Authgent (self-hosted) |
|---|---|---|
| Data residency | Tokens transit third-party servers | Tokens never leave your perimeter |
| Regulated industries | May not meet compliance requirements | Full control for HIPAA, SOC 2, FedRAMP |
| Vendor lock-in | Dependent on provider availability | Single Go binary you own |
| Cost at scale | Per-MAU pricing | Fixed infrastructure cost |
| Customization | Limited to provider’s feature set | Full source access, custom claims |
If you operate in healthcare, finance, or government — or simply want full control over your auth infrastructure — self-hosted Authgent with Cloudflare Workers gives you edge performance without giving up data sovereignty.
Complete Worker implementation
Section titled “Complete Worker implementation”// src/worker.ts — Cloudflare Worker with Authgent JWT verificationimport { DurableObject } from "cloudflare:workers";
interface Env { AUTHGENT_ISSUER: string; MCP_AUDIENCE: string; MCP_OBJECT: DurableObjectNamespace;}
interface JWTPayload { iss: string; sub: string; aud: string | string[]; exp: number; iat: number; jti: string; scope: string; email?: string; name?: string;}
// ── JWKS Fetching (cached at Cloudflare edge) ──────────────
async function fetchJWKS(issuer: string): Promise<JsonWebKey[]> { const url = `${issuer}/.well-known/jwks.json`;
const response = await fetch(url, { cf: { // Cache the JWKS at Cloudflare's edge for 1 hour cacheTtl: 3600, cacheEverything: true, }, });
if (!response.ok) { throw new Error(`Failed to fetch JWKS: ${response.status}`); }
const jwks = (await response.json()) as { keys: JsonWebKey[] }; return jwks.keys;}
async function importKey(jwk: JsonWebKey): Promise<CryptoKey> { const algorithm = jwk.kty === "EC" ? { name: "ECDSA", namedCurve: jwk.crv || "P-256" } : { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
return crypto.subtle.importKey("jwk", jwk, algorithm, false, ["verify"]);}
// ── JWT Verification (Web Crypto API) ───────────────────────
function base64UrlDecode(str: string): Uint8Array { const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4; const padded = pad ? base64 + "=".repeat(4 - pad) : base64; const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes;}
async function verifyJWT( token: string, issuer: string, audience: string): Promise<JWTPayload> { const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid JWT format"); }
const [headerB64, payloadB64, signatureB64] = parts;
// Decode header to find key ID const header = JSON.parse( new TextDecoder().decode(base64UrlDecode(headerB64)) ) as { kid?: string; alg: string };
// Fetch JWKS from Authgent (cached at edge) const keys = await fetchJWKS(issuer);
// Find the matching key const jwk = header.kid ? keys.find((k) => k.kid === header.kid) : keys[0];
if (!jwk) { throw new Error("No matching key found in JWKS"); }
// Import the key for Web Crypto const cryptoKey = await importKey(jwk);
// Verify signature const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const signature = base64UrlDecode(signatureB64);
const algorithm = jwk.kty === "EC" ? { name: "ECDSA", hash: "SHA-256" } : { name: "RSASSA-PKCS1-v1_5" };
const valid = await crypto.subtle.verify(algorithm, cryptoKey, signature, data);
if (!valid) { throw new Error("Invalid JWT signature"); }
// Decode and validate claims const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(payloadB64)) ) as JWTPayload;
// Validate issuer if (payload.iss !== issuer) { throw new Error(`Invalid issuer: expected ${issuer}, got ${payload.iss}`); }
// Validate audience const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(audience)) { throw new Error(`Invalid audience: expected ${audience}`); }
// Validate expiration const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { throw new Error("Token expired"); }
return payload;}
// ── Scope checking ──────────────────────────────────────────
function hasScope(payload: JWTPayload, required: string): boolean { const scopes = (payload.scope || "").split(" "); return scopes.includes(required);}
function requireScope(payload: JWTPayload, scope: string): void { if (!hasScope(payload, scope)) { throw new Error(`insufficient_scope: required "${scope}" not present`); }}
// ── Auth middleware ──────────────────────────────────────────
async function authenticate( request: Request, env: Env): Promise<JWTPayload> { const authHeader = request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { throw new Error("missing_token"); }
const token = authHeader.slice(7); return verifyJWT(token, env.AUTHGENT_ISSUER, env.MCP_AUDIENCE);}
// ── MCP Tool handlers ───────────────────────────────────────
async function handleListTools(): Promise<Response> { return Response.json({ tools: [ { name: "hello", description: "Say hello. Requires mcp:read scope.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name to greet" }, }, required: ["name"], }, }, { name: "get_data", description: "Get user data. Requires mcp:read scope.", inputSchema: { type: "object", properties: {} }, }, { name: "write_data", description: "Write data. Requires mcp:write scope.", inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" }, }, required: ["key", "value"], }, }, ], });}
async function handleToolCall( toolName: string, args: Record<string, unknown>, claims: JWTPayload): Promise<Response> { switch (toolName) { case "hello": requireScope(claims, "mcp:read"); return Response.json({ content: [{ type: "text", text: `Hello, ${args.name}! (user: ${claims.sub})` }], });
case "get_data": requireScope(claims, "mcp:read"); return Response.json({ content: [ { type: "text", text: JSON.stringify({ user: claims.sub, email: claims.email, data: "your application data here", }), }, ], });
case "write_data": requireScope(claims, "mcp:write"); return Response.json({ content: [ { type: "text", text: `Wrote ${args.key}=${args.value} for user ${claims.sub}`, }, ], });
default: return Response.json({ error: "unknown_tool" }, { status: 400 }); }}
// ── Worker entry point ──────────────────────────────────────
export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
// OAuth Protected Resource Metadata (RFC 9728) if (url.pathname === "/.well-known/oauth-protected-resource") { return Response.json({ resource: env.MCP_AUDIENCE, authorization_servers: [env.AUTHGENT_ISSUER], }); }
// MCP endpoint — all requests require authentication if (url.pathname === "/mcp") { let claims: JWTPayload; try { claims = await authenticate(request, env); } catch (err) { const message = err instanceof Error ? err.message : "unauthorized"; return Response.json({ error: message }, { status: 401 }); }
// Route MCP JSON-RPC methods if (request.method === "POST") { const body = (await request.json()) as { method: string; params?: Record<string, unknown>; };
if (body.method === "tools/list") { return handleListTools(); }
if (body.method === "tools/call") { const params = body.params as { name: string; arguments: Record<string, unknown>; }; try { return handleToolCall(params.name, params.arguments, claims); } catch (err) { const message = err instanceof Error ? err.message : "tool_error"; return Response.json({ error: message }, { status: 403 }); } }
return Response.json({ error: "unknown_method" }, { status: 400 }); }
return Response.json({ error: "method_not_allowed" }, { status: 405 }); }
return new Response("Not Found", { status: 404 }); },};
// ── Durable Object for stateful MCP agents ──────────────────
export class AuthgentMCPAgent extends DurableObject<Env> { private sessions: Map<string, JWTPayload> = new Map();
async fetch(request: Request): Promise<Response> { const url = new URL(request.url);
if (url.pathname === "/session/create") { const claims = (await request.json()) as JWTPayload; const sessionId = crypto.randomUUID(); this.sessions.set(sessionId, claims);
// Store session in durable storage for persistence await this.ctx.storage.put(`session:${sessionId}`, claims);
return Response.json({ sessionId }); }
if (url.pathname === "/session/get") { const { sessionId } = (await request.json()) as { sessionId: string }; const claims = this.sessions.get(sessionId) || (await this.ctx.storage.get<JWTPayload>(`session:${sessionId}`));
if (!claims) { return Response.json({ error: "session_not_found" }, { status: 404 }); }
return Response.json({ claims }); }
return Response.json({ error: "not_found" }, { status: 404 }); }}OAuth Protected Resource Metadata
Section titled “OAuth Protected Resource Metadata”Your Worker must serve the /.well-known/oauth-protected-resource endpoint (RFC 9728). This tells MCP clients where to authenticate:
// Already included in the worker aboveif (url.pathname === "/.well-known/oauth-protected-resource") { return Response.json({ resource: env.MCP_AUDIENCE, authorization_servers: [env.AUTHGENT_ISSUER], });}When an MCP client connects to your Worker, it:
- Fetches
/.well-known/oauth-protected-resourcefrom your Worker - Discovers that Authgent is the authorization server
- Fetches Authgent’s
/.well-known/oauth-authorization-servermetadata - Completes the OAuth flow with Authgent
- Sends authenticated requests to your Worker with the JWT
Wrangler configuration
Section titled “Wrangler configuration”wrangler.toml
Section titled “wrangler.toml”name = "my-mcp-server"main = "src/worker.ts"compatibility_date = "2024-01-01"
[durable_objects]bindings = [ { name = "MCP_OBJECT", class_name = "AuthgentMCPAgent" }]
[[migrations]]tag = "v1"new_classes = ["AuthgentMCPAgent"]
[vars]AUTHGENT_ISSUER = "https://auth.yourcompany.com"MCP_AUDIENCE = "https://mcp.yourcompany.com"For secrets (if you need to store any API keys your tools use):
# Set secrets that shouldn't be in wrangler.tomlwrangler secret put MY_API_KEYEnvironment-specific configuration
Section titled “Environment-specific configuration”# wrangler.toml — environment overrides[env.staging]name = "my-mcp-server-staging"vars = { AUTHGENT_ISSUER = "https://auth-staging.yourcompany.com", MCP_AUDIENCE = "https://mcp-staging.yourcompany.com" }
[env.production]name = "my-mcp-server"vars = { AUTHGENT_ISSUER = "https://auth.yourcompany.com", MCP_AUDIENCE = "https://mcp.yourcompany.com" }Durable Objects for stateful MCP agents
Section titled “Durable Objects for stateful MCP agents”The AuthgentMCPAgent Durable Object in the worker above provides session persistence. Use it when your MCP tools need:
- Conversation state — maintain context across multiple tool calls
- Rate limiting per user — track request counts in durable storage
- Cached API responses — store upstream API results scoped to authenticated users
Route requests to a Durable Object by user ID:
// In your main fetch handlerif (url.pathname.startsWith("/mcp/stateful")) { const claims = await authenticate(request, env); const id = env.MCP_OBJECT.idFromName(claims.sub); const stub = env.MCP_OBJECT.get(id); return stub.fetch(request);}Each user gets their own Durable Object instance, automatically isolated and persisted.
JWKS caching at the edge
Section titled “JWKS caching at the edge”The fetchJWKS function uses Cloudflare’s cf.cacheTtl to cache the JWKS response at the edge:
const response = await fetch(url, { cf: { cacheTtl: 3600, // Cache for 1 hour cacheEverything: true, // Cache even without Cache-Control headers },});This means:
- The first request from each Cloudflare PoP fetches the JWKS from Authgent
- Subsequent requests use the cached JWKS (no round-trip to your origin)
- After 1 hour, the cache is refreshed — aligning with typical key rotation intervals
For faster key rotation, reduce cacheTtl to 300 (5 minutes).
Local development
Section titled “Local development”Run Authgent and your Worker locally:
# Terminal 1 — Start Authgent locallydocker run -d \ --name authgent \ -p 8080:8080 \ -v $(pwd)/ec-private.pem:/keys/ec-private.pem:ro \ -e AUTHGENT_ISSUER=http://localhost:8080 \ -e AUTHGENT_SIGNING_KEY=/keys/ec-private.pem \ -e AUTHGENT_SIGNING_ALG=ES256 \ -e AUTHGENT_DB_DSN=sqlite:///data/authgent.db \ authgent/authgent:latest
# Terminal 2 — Start the Worker locallywrangler dev --var AUTHGENT_ISSUER:http://localhost:8080 --var MCP_AUDIENCE:http://localhost:8787Wrangler dev starts on http://localhost:8787 by default. Test with MCP Inspector:
npx @modelcontextprotocol/inspector http://localhost:8787/mcpProduction deployment checklist
Section titled “Production deployment checklist”- Authgent running and accessible — your Worker must be able to reach Authgent’s JWKS endpoint
- TLS everywhere — both Authgent and your Worker should serve over HTTPS
- Environment variables set —
AUTHGENT_ISSUERandMCP_AUDIENCEconfigured inwrangler.tomlor viawrangler secret - JWKS caching configured —
cacheTtlset appropriately for your key rotation schedule - Protected Resource Metadata served —
/.well-known/oauth-protected-resourcereturns correct authorization server URL - Scopes defined — tool-level scope requirements match what Authgent is configured to issue
- Durable Objects migrations applied — if using stateful agents, ensure migrations are in
wrangler.toml - Custom domain configured — set up a custom domain in Cloudflare Dashboard for your Worker
Deploy:
# Deploy to productionwrangler deploy
# Deploy to stagingwrangler deploy --env staging
# Verify the deploymentcurl https://mcp.yourcompany.com/.well-known/oauth-protected-resource | jq .