Skip to main content
Cloudflare

Cloudflare Access Authentication

OAuth/SAML authentication proxy, JWT validation, role system, and protected route configuration via Cloudflare Access

February 23, 2026

Cloudflare Access Authentication

Cloudflare Access provides the authentication layer for all non-public routes on Arcturus-Prime. It acts as a reverse proxy that intercepts requests to protected paths, redirects unauthenticated users to an identity provider login, and attaches a signed JWT to authenticated requests. The Arcturus-Prime backend then validates that JWT and resolves the user’s role from KV.

Authentication Chain

When a user hits a protected route, the authentication flows through multiple layers:

Browser hits /admin/dashboard


Cloudflare Access proxy

    ├── No valid session ──→ Redirect to IdP login
    │                         (Google OAuth, GitHub, etc.)

    └── Valid session ──→ Attach JWT headers + cookies


                     Arcturus-Prime Worker

                           ├── Read Cf-Access-Jwt-Assertion header
                           │   (RS256 signed JWT)

                           ├── Fallback: CF_Authorization cookie

                           ├── Fallback: CF_AppSession cookie

                           └── Validate JWT → Extract email → Lookup role in KV

The middleware checks for authentication credentials in this order:

  1. Cf-Access-Jwt-Assertion header — The primary authentication mechanism. Cloudflare Access sets this header on every proxied request. Contains a JWT signed with RS256.
  2. CF_Authorization cookie — Set by Cloudflare Access in the browser. Used when the header is not present (direct browser navigation).
  3. CF_AppSession cookie — Application-level session cookie. Fallback when the above are not available.

The first valid credential found wins. If none are present or valid, the request is treated as unauthenticated.

JWT Validation

Token Structure

The Cloudflare Access JWT contains:

{
  "aud": ["32-character-hex-audience-tag"],
  "email": "[email protected]",
  "exp": 1708732800,
  "iat": 1708646400,
  "iss": "https://Arcturus-Prime.cloudflareaccess.com",
  "sub": "unique-user-id",
  "type": "app",
  "identity_nonce": "random-nonce",
  "country": "US"
}

Validation Steps

The JWT is validated in src/lib/auth.ts with these checks:

  1. Decode the JWT — Split into header, payload, signature. Base64url decode each part.
  2. Fetch JWKS — Retrieve the JSON Web Key Set from Cloudflare’s well-known endpoint. The JWKS is cached in memory for 5 minutes to avoid hitting the endpoint on every request.
  3. Verify signature — Confirm the JWT was signed by Cloudflare using the RS256 algorithm and the public key from JWKS.
  4. Check audience — The aud claim must contain the configured CF_ACCESS_AUD value. This ensures the token was issued for this specific Access application.
  5. Check expiration — The exp claim must be in the future.
  6. Check issuer — The iss claim must match the configured CF_ACCESS_ISSUER.
// Simplified JWT validation flow
async function validateAccessJWT(token: string, env: Env): Promise<UserIdentity | null> {
  const { header, payload, signature } = decodeJWT(token);

  // Fetch and cache JWKS
  const jwks = await getJWKS(env.CF_ACCESS_TEAM_DOMAIN);

  // Find the signing key
  const key = jwks.keys.find(k => k.kid === header.kid);
  if (!key) return null;

  // Verify RS256 signature
  const valid = await verifyRS256(token, key);
  if (!valid) return null;

  // Validate claims
  if (!payload.aud.includes(env.CF_ACCESS_AUD)) return null;
  if (payload.exp < Date.now() / 1000) return null;
  if (payload.iss !== env.CF_ACCESS_ISSUER) return null;

  return {
    email: payload.email,
    sub: payload.sub,
  };
}

JWKS Caching

The JWKS endpoint is:

https://<CF_ACCESS_TEAM_DOMAIN>/cdn-cgi/access/certs

Fetching this on every request would be slow and unnecessary since the keys rotate infrequently. The JWKS response is cached in the Worker’s global scope for 5 minutes:

let jwksCache: { keys: JsonWebKey[]; cachedAt: number } | null = null;
const JWKS_TTL = 5 * 60 * 1000; // 5 minutes

async function getJWKS(teamDomain: string): Promise<{ keys: JsonWebKey[] }> {
  const now = Date.now();
  if (jwksCache && now - jwksCache.cachedAt < JWKS_TTL) {
    return jwksCache;
  }

  const response = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`);
  const data = await response.json();
  jwksCache = { keys: data.keys, cachedAt: now };
  return jwksCache;
}

Required Secrets

These environment variables must be set in the Cloudflare Pages dashboard:

VariableDescriptionExample
CF_ACCESS_TEAM_DOMAINYour Cloudflare Access team domainArcturus-Prime.cloudflareaccess.com
CF_ACCESS_AUDThe audience tag for the Access applicationa1b2c3d4e5f6... (32-char hex)
CF_ACCESS_ISSUERThe JWT issuer URLhttps://Arcturus-Prime.cloudflareaccess.com

These values are found in the Cloudflare Zero Trust dashboard under Access > Applications > your application > Overview.

Role System

After JWT validation extracts the user’s email, the role is resolved from the data:user-roles KV key.

Role Definitions

RolePermissionsDescription
admin['*']Full access to everything — all admin pages, all API endpoints, all features
member['view:dashboard', 'use:chat', 'view:status', 'edit:content']Can view dashboards, use AI chat, check status, edit content. Cannot access system controls.
demo['view:dashboard', 'view:status']Read-only tour of the platform. Can see dashboards and status but cannot interact or modify anything.

Permission Checking

Permissions are checked with a simple wildcard-aware matcher:

// src/lib/roles.ts
function hasPermission(user: UserRecord, permission: string): boolean {
  if (user.role === 'admin') return true; // Admin has wildcard

  const roleConfig = roles[user.role];
  if (!roleConfig) return false;

  return roleConfig.permissions.some(p =>
    p === '*' || p === permission || permission.startsWith(p.replace('*', ''))
  );
}

UserRecord Structure

Each user in KV has the following structure:

interface UserRecord {
  email: string;
  role: 'admin' | 'member' | 'demo';
  displayName: string;
  services: string[];       // Which infrastructure services they can access
  features: string[];       // Which site features they can use
  sites: string[];          // Which sites/domains they can manage
  dashboardProfiles: DashboardProfile[];
}

interface DashboardProfile {
  id: string;
  name: string;
  widgets: string[];        // Widget IDs visible in this profile
  layout?: 'grid' | 'list'; // Dashboard layout preference
}

The services, features, and sites arrays support wildcards. An admin with ["*"] can access everything. A member might have ["gitea", "vaultwarden"] for services and ["Arcturus-Prime.com"] for sites.

Protected Routes

The middleware in src/middleware.ts enforces authentication on these route patterns:

Route PatternRequired RoleDescription
/admin/*adminFull admin panel — settings, monitoring, content management
/auth/*any authenticatedLogin/logout flows
/user/*member or higherUser portal — preferences, files, deployments
/dashboard/*member or higherPersonalized dashboards
/api/auth/*variesAuth API endpoints (some public, some admin)
/api/admin/*adminAdmin-only API operations
/api/user/*member or higherUser-specific API operations
/api/dashboard/*member or higherDashboard data endpoints

Public routes (/, /blog/*, /about, /contact, etc.) do not require authentication and are accessible to everyone.

Middleware Flow

// src/middleware.ts (simplified)
export const onRequest = defineMiddleware(async (context, next) => {
  const url = new URL(context.request.url);
  const path = url.pathname;

  // Public routes -- skip auth
  if (isPublicRoute(path)) {
    return next();
  }

  // Dev mode -- always return admin auth
  if (import.meta.env.DEV) {
    context.locals.user = {
      email: '[email protected]',
      role: 'admin',
      displayName: 'Dev Mode',
      services: ['*'],
      features: ['*'],
      sites: ['*'],
      dashboardProfiles: [],
    };
    return next();
  }

  // Production -- validate CF Access JWT
  const identity = await validateAccessJWT(context.request, context.locals.runtime.env);
  if (!identity) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Resolve role from KV
  const user = await getUserRecord(identity.email, context.locals.runtime.env);
  if (!user) {
    return new Response('Forbidden', { status: 403 });
  }

  // Check route permissions
  if (!hasRouteAccess(path, user)) {
    return new Response('Forbidden', { status: 403 });
  }

  context.locals.user = user;
  return next();
});

Dev Mode

When running locally with npm run dev, all authentication is bypassed. The middleware detects import.meta.env.DEV and automatically injects an admin user into the request context. This means:

  • No Cloudflare Access headers are needed
  • No JWT validation occurs
  • All routes return as if the user is an admin
  • KV role lookups are skipped

This makes local development frictionless — every admin page, every API endpoint, everything works without configuring Access locally.

// Dev mode always returns this user
{
  email: '[email protected]',
  role: 'admin',
  displayName: 'Dev Mode',
  services: ['*'],
  features: ['*'],
  sites: ['*'],
  dashboardProfiles: []
}

Preview Deployment Access

For Cloudflare Pages preview deployments (PR branches), admin access can be enabled with the ?preview=true query parameter. This allows PR reviewers to test admin features on the preview URL without needing a Cloudflare Access policy for the preview subdomain.

https://abc123.Arcturus-Prime.pages.dev/admin/dashboard?preview=true

The middleware checks for preview mode only on non-production hostnames. The production domain (Arcturus-Prime.com) always requires full authentication regardless of query parameters.

Troubleshooting

Common Auth Issues

SymptomCauseFix
401 on all admin routesJWT validation failingCheck CF_ACCESS_AUD matches the application audience
403 after successful loginUser not in KV rolesAdd user to data:user-roles via /api/auth/roles
Auth works locally but not in productionDev mode bypass active locallyExpected behavior — test against preview deployment
JWT expired errorsClock skew or stale sessionClear CF_Authorization cookie and re-authenticate
JWKS fetch failuresTeam domain misconfiguredVerify CF_ACCESS_TEAM_DOMAIN is correct
cloudflareaccessauthenticationjwtsecurityroles