Skip to content

Cloudflare Workers Integration

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 tools

The Worker never implements any OAuth endpoints. It only verifies tokens that Authgent issued.

ConcernHosted auth (WorkOS, Auth0)Authgent (self-hosted)
Data residencyTokens transit third-party serversTokens never leave your perimeter
Regulated industriesMay not meet compliance requirementsFull control for HIPAA, SOC 2, FedRAMP
Vendor lock-inDependent on provider availabilitySingle Go binary you own
Cost at scalePer-MAU pricingFixed infrastructure cost
CustomizationLimited to provider’s feature setFull 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.

// src/worker.ts — Cloudflare Worker with Authgent JWT verification
import { 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 });
}
}

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 above
if (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:

  1. Fetches /.well-known/oauth-protected-resource from your Worker
  2. Discovers that Authgent is the authorization server
  3. Fetches Authgent’s /.well-known/oauth-authorization-server metadata
  4. Completes the OAuth flow with Authgent
  5. Sends authenticated requests to your Worker with the JWT
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):

Terminal window
# Set secrets that shouldn't be in wrangler.toml
wrangler secret put MY_API_KEY
# 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" }

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 handler
if (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.

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

Run Authgent and your Worker locally:

Terminal window
# Terminal 1 — Start Authgent locally
docker 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 locally
wrangler dev --var AUTHGENT_ISSUER:http://localhost:8080 --var MCP_AUDIENCE:http://localhost:8787

Wrangler dev starts on http://localhost:8787 by default. Test with MCP Inspector:

Terminal window
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
  1. Authgent running and accessible — your Worker must be able to reach Authgent’s JWKS endpoint
  2. TLS everywhere — both Authgent and your Worker should serve over HTTPS
  3. Environment variables setAUTHGENT_ISSUER and MCP_AUDIENCE configured in wrangler.toml or via wrangler secret
  4. JWKS caching configuredcacheTtl set appropriately for your key rotation schedule
  5. Protected Resource Metadata served/.well-known/oauth-protected-resource returns correct authorization server URL
  6. Scopes defined — tool-level scope requirements match what Authgent is configured to issue
  7. Durable Objects migrations applied — if using stateful agents, ensure migrations are in wrangler.toml
  8. Custom domain configured — set up a custom domain in Cloudflare Dashboard for your Worker

Deploy:

Terminal window
# Deploy to production
wrangler deploy
# Deploy to staging
wrangler deploy --env staging
# Verify the deployment
curl https://mcp.yourcompany.com/.well-known/oauth-protected-resource | jq .