Guide · MCP Cloudflare Zero Trust Integration

MCP Server Cloudflare Access — JWT validation, access policies, and session revocation

Cloudflare Access is the Zero Trust access control layer in Cloudflare One — it sits in front of internal applications and enforces identity-based policies without a VPN. This guide covers building TypeScript MCP tools that interact with the Cloudflare API: authenticating with Account ID + API token pairs, listing and inspecting Access applications, reading and creating access policies, immediately revoking user sessions (invalidating JWTs in real-time), validating Cloudflare-issued JWTs locally, and wiring a /health/cloudflare endpoint that calls GET /user/tokens/verify so AliveMCP catches token expiry and permission changes before your automation loses access.

TL;DR

Cloudflare API uses Authorization: Bearer <token> plus X-Auth-Email for legacy keys, but modern API tokens use Bearer only. All Access endpoints require both the token and the Account ID in the URL path: /accounts/:account_id/access/.... The global rate limit is 1200 requests per 5 minutes — operations against specific zones have separate per-zone limits. Session revocation (POST /access/organizations/:org_id/revoke_user) immediately invalidates all JWTs for a user across all Access applications — this is the critical emergency access revocation operation. Validate Cloudflare-issued JWTs locally using the public key from https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certs — do not call the API on every request.

HTTP client setup and authentication

Cloudflare's API supports two credential types: legacy API keys (email + global key — avoid these) and modern API tokens (scoped, rotatable — use these). API tokens are created in the Cloudflare dashboard under My Profile → API Tokens. For Cloudflare Access management, the token needs the Access: Apps and Policies permission on the target account.

import axios, { AxiosInstance } from 'axios';

const CF_API_TOKEN = process.env.CF_API_TOKEN!;
const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID!;

const cfHttp: AxiosInstance = axios.create({
  baseURL: 'https://api.cloudflare.com/client/v4',
  headers: {
    'Authorization': `Bearer ${CF_API_TOKEN}`,
    'Content-Type': 'application/json'
  },
  timeout: 15_000
});

// Helper to construct Access endpoint paths
function accessPath(path: string): string {
  return `/accounts/${CF_ACCOUNT_ID}/access${path}`;
}

// Cloudflare API responses always wrap results in { result, success, errors, messages }
function unwrap(response: any): T {
  if (!response.data.success) {
    const errors = response.data.errors?.map((e: any) => `${e.code}: ${e.message}`).join(', ');
    throw new Error(`Cloudflare API error: ${errors}`);
  }
  return response.data.result as T;
}
Cloudflare rate limit Value Notes
Global API token 1200 req / 5 min Across all API calls for the token
Zone-specific endpoints 1200 req / 5 min Separate per-zone limit
Token verify endpoint Included in global Use for health checks only — not per-request auth

list_access_apps and get_access_policies tools

import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';

server.tool(
  'list_cloudflare_access_apps',
  {},
  async () => {
    const res = await cfHttp.get(accessPath('/apps'));
    const apps = unwrap(res);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          count: apps.length,
          apps: apps.map(app => ({
            id: app.id,
            name: app.name,
            domain: app.domain,
            type: app.type,    // 'self_hosted', 'saas', 'ssh', 'vnc', etc.
            session_duration: app.session_duration,
            created_at: app.created_at,
            updated_at: app.updated_at
          }))
        }, null, 2)
      }]
    };
  }
);

server.tool(
  'get_cloudflare_access_policies',
  {
    app_id: z.string().uuid()
  },
  async ({ app_id }) => {
    try {
      const res = await cfHttp.get(accessPath(`/apps/${app_id}/policies`));
      const policies = unwrap(res);

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            app_id,
            count: policies.length,
            policies: policies.map(p => ({
              id: p.id,
              name: p.name,
              decision: p.decision,    // 'allow', 'deny', 'bypass', 'non_identity'
              precedence: p.precedence,
              include: p.include,
              exclude: p.exclude,
              require: p.require
            }))
          }, null, 2)
        }]
      };
    } catch (err: any) {
      if (err.response?.status === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Access app not found: ${app_id}`);
      }
      throw err;
    }
  }
);

create_access_rule and revoke_user_sessions tools

Session revocation is the most operationally critical Access operation — it immediately invalidates all JWTs for a user across every Access application in your organization. This is distinct from removing a policy or deleting an app: those changes don't affect active sessions until the JWT expires. Revocation is instant.

server.tool(
  'create_cloudflare_access_rule',
  {
    app_id: z.string().uuid(),
    name: z.string().min(1),
    decision: z.enum(['allow', 'deny', 'bypass', 'non_identity']),
    // At least one include rule
    include: z.array(z.object({
      email: z.object({ email: z.string() }).optional(),
      email_domain: z.object({ domain: z.string() }).optional(),
      everyone: z.object({}).optional(),
      group: z.object({ id: z.string() }).optional(),
      ip_range: z.object({ ip: z.string() }).optional()
    })).min(1),
    confirm: z.literal(true)
  },
  async ({ app_id, name, decision, include }) => {
    const res = await cfHttp.post(accessPath(`/apps/${app_id}/policies`), {
      name,
      decision,
      include,
      precedence: 1   // highest precedence; existing policies shift down
    });

    const policy = unwrap(res);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          created: true,
          policy_id: policy.id,
          name: policy.name,
          decision: policy.decision,
          app_id
        }, null, 2)
      }]
    };
  }
);

server.tool(
  'revoke_cloudflare_user_sessions',
  {
    // Email of the user to revoke across ALL Access applications
    user_email: z.string().email(),
    confirm: z.literal(true)   // immediate effect — invalidates all JWTs for this user NOW
  },
  async ({ user_email }) => {
    // First get the organization ID (needed for the revoke endpoint)
    const orgRes = await cfHttp.get(accessPath('/organizations'));
    const org = (unwrap(orgRes))[0];
    if (!org) {
      throw new McpError(ErrorCode.InvalidRequest,
        'No Cloudflare Access organization found for this account');
    }

    // Revoke all sessions — immediate JWT invalidation
    await cfHttp.post(
      `/accounts/${CF_ACCOUNT_ID}/access/organizations/${org.id}/revoke_user`,
      { email: user_email }
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          user_email,
          sessions_revoked: true,
          effect: 'All active JWTs for this user are immediately invalid across all Access apps',
          note: 'The user must re-authenticate to regain access'
        }, null, 2)
      }]
    };
  }
);

JWT validation: check_user_access

Cloudflare Access issues signed JWTs for authenticated users. Applications should validate these JWTs locally using the public keys from the team's JWKS endpoint — never call the Cloudflare API on every request to validate a JWT. The JWKS endpoint is stable and the keys rotate infrequently; cache them for at least 5 minutes.

import * as jose from 'jose';

const CF_TEAM_DOMAIN = process.env.CF_TEAM_DOMAIN!;  // e.g. 'yourteam.cloudflareaccess.com'
const CF_APP_AUD = process.env.CF_APP_AUD!;          // Application Audience tag from Access dashboard

let cachedJwks: jose.JWTVerifyGetKey | null = null;
let jwksCachedAt = 0;

async function getJwks(): Promise {
  // Cache JWKS for 5 minutes
  if (cachedJwks && Date.now() - jwksCachedAt < 5 * 60 * 1000) {
    return cachedJwks;
  }

  const jwksUrl = `https://${CF_TEAM_DOMAIN}/cdn-cgi/access/certs`;
  cachedJwks = jose.createRemoteJWKSet(new URL(jwksUrl));
  jwksCachedAt = Date.now();
  return cachedJwks;
}

server.tool(
  'validate_cloudflare_jwt',
  {
    jwt: z.string().min(1)
  },
  async ({ jwt }) => {
    try {
      const jwks = await getJwks();
      const { payload } = await jose.jwtVerify(jwt, jwks, {
        issuer: `https://${CF_TEAM_DOMAIN}`,
        audience: CF_APP_AUD
      });

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            valid: true,
            email: payload.email,
            sub: payload.sub,
            name: (payload as any).name,
            identity_nonce: (payload as any).identity_nonce,
            expires_at: new Date((payload.exp ?? 0) * 1000).toISOString(),
            issued_at: new Date((payload.iat ?? 0) * 1000).toISOString()
          }, null, 2)
        }]
      };
    } catch (err: any) {
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            valid: false,
            error: err.code ?? err.message,
            reason: err.code === 'ERR_JWT_EXPIRED'
              ? 'JWT has expired — user must re-authenticate'
              : err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED'
                ? 'Signature invalid — JWT may be forged or issued by wrong team'
                : 'JWT validation failed'
          }, null, 2)
        }]
      };
    }
  }
);

Health endpoint: /health/cloudflare

import express from 'express';

const app = express();

app.get('/health/cloudflare', async (_req, res) => {
  const start = Date.now();
  try {
    // GET /user/tokens/verify — lightweight check; validates token without side effects
    const tokenRes = await cfHttp.get('/user/tokens/verify');

    if (!tokenRes.data.success) {
      return res.status(401).json({
        status: 'error',
        error: 'Token verification failed',
        errors: tokenRes.data.errors,
        latency_ms: Date.now() - start
      });
    }

    const token = tokenRes.data.result;
    res.status(200).json({
      status: 'ok',
      token_id: token.id,
      token_status: token.status,    // 'active', 'disabled', 'expired'
      not_before: token.not_before,
      expires_on: token.expires_on,  // null if no expiry
      latency_ms: Date.now() - start
    });
  } catch (err: any) {
    res.status(503).json({
      status: 'error',
      error: err.message,
      latency_ms: Date.now() - start
    });
  }
});

app.listen(3001);

Frequently asked questions

What's the difference between Cloudflare Access applications and policies?

An Access application defines what is being protected: a hostname and path pattern, session duration, and the identity providers allowed for authentication. An Access policy defines who can access it: include rules (who is allowed), exclude rules (who is explicitly blocked even if they match include), and require rules (conditions that must be true in addition to include). A single application can have multiple policies evaluated in precedence order — the first matching policy's decision (allow, deny, bypass) wins. Changes to policies take effect on the next authentication request; existing JWTs are not affected until they expire or are revoked.

How does JWT revocation work and when does it take effect?

Cloudflare Access JWTs are short-lived by default (1 hour) and are verified locally by your application using Cloudflare's public keys — there's no per-request API call. When you call revoke_user, Cloudflare marks the user's active session as revoked in their infrastructure. For applications that verify JWTs locally using the JWKS endpoint, revocation only takes effect when the JWT expires or when the application re-fetches the JWKS and encounters a key rotation (which Cloudflare triggers on revocation events). For immediate revocation in your own application, you need to implement a token revocation check — either by calling Cloudflare's API or by using a short JWT TTL with your application's session management to force re-authentication.

How do I restrict Access to specific IP ranges?

Use an ip_range include rule in your Access policy: { "ip_range": { "ip": "203.0.113.0/24" } }. You can combine multiple include rules — users match if they satisfy any of the include conditions AND all require conditions. For corporate office access, combine an IP range with an identity group: include corporate IPs (no auth needed for known ranges) and also include your email domain (for remote workers who authenticate via IdP). Use the exclude rules to block specific IP ranges even if they match another include rule — useful for blocking known-malicious ranges that happen to be in your allowed subnet.

What permissions does the API token need for Access management?

For read-only Access management: Access: Apps and Policies Read on the Account level. For full management (create/update/delete apps and policies): Access: Apps and Policies Edit. For user session management (revocation): Access: Organizations Write. For the health check (/user/tokens/verify): any valid token — this endpoint only verifies the token itself, no additional permissions required. Create separate tokens with minimal scopes for each MCP server function — avoid using a single high-privilege token for both read and write operations.

Further reading

Know when your Cloudflare API token expires before it silently stops working

AliveMCP monitors your /health/cloudflare endpoint and alerts you the moment the API token is revoked, expires, or the Cloudflare API becomes unreachable.

Start monitoring free