Cloudflare Access Authentication
OAuth/SAML authentication proxy, JWT validation, role system, and protected route configuration via Cloudflare Access
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
Header and Cookie Priority
The middleware checks for authentication credentials in this order:
Cf-Access-Jwt-Assertionheader — The primary authentication mechanism. Cloudflare Access sets this header on every proxied request. Contains a JWT signed with RS256.CF_Authorizationcookie — Set by Cloudflare Access in the browser. Used when the header is not present (direct browser navigation).CF_AppSessioncookie — 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:
- Decode the JWT — Split into header, payload, signature. Base64url decode each part.
- 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.
- Verify signature — Confirm the JWT was signed by Cloudflare using the RS256 algorithm and the public key from JWKS.
- Check audience — The
audclaim must contain the configuredCF_ACCESS_AUDvalue. This ensures the token was issued for this specific Access application. - Check expiration — The
expclaim must be in the future. - Check issuer — The
issclaim must match the configuredCF_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:
| Variable | Description | Example |
|---|---|---|
CF_ACCESS_TEAM_DOMAIN | Your Cloudflare Access team domain | Arcturus-Prime.cloudflareaccess.com |
CF_ACCESS_AUD | The audience tag for the Access application | a1b2c3d4e5f6... (32-char hex) |
CF_ACCESS_ISSUER | The JWT issuer URL | https://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
| Role | Permissions | Description |
|---|---|---|
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 Pattern | Required Role | Description |
|---|---|---|
/admin/* | admin | Full admin panel — settings, monitoring, content management |
/auth/* | any authenticated | Login/logout flows |
/user/* | member or higher | User portal — preferences, files, deployments |
/dashboard/* | member or higher | Personalized dashboards |
/api/auth/* | varies | Auth API endpoints (some public, some admin) |
/api/admin/* | admin | Admin-only API operations |
/api/user/* | member or higher | User-specific API operations |
/api/dashboard/* | member or higher | Dashboard 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.
Service Token Bypass (Server-to-Server)
When Arcturus-Prime’s CF Pages worker needs to call services behind CF Access (like oc.Arcturus-Prime.com), it uses a service token to bypass the login flow. This requires three things:
- Service token exists in Zero Trust → Access → Service Auth → Service Tokens
- Service Auth policy on the target CF Access Application (Action:
non_identity, Include: the service token) - Env vars set in CF Pages:
OPENCLAW_SERVICE_TOKEN_ID(Client ID) andOPENCLAW_SERVICE_TOKEN_SECRET(Client Secret)
The openclawHeaders() function in src/lib/openclaw-helpers.ts adds CF-Access-Client-Id and CF-Access-Client-Secret headers when both env vars are present. Without the Service Auth policy on the target application, these headers are silently ignored and the request gets a 302 redirect to the login page.
Managing via CF API
Service token policies can be managed programmatically. Use the Global API Key (X-Auth-Email + X-Auth-Key) — the worker-scoped CF_API_TOKEN does NOT have Zero Trust permissions.
# List service tokens
GET /accounts/{account_id}/access/service_tokens
# List policies for an application
GET /accounts/{account_id}/access/apps/{app_id}/policies
# Create a Service Auth policy
POST /accounts/{account_id}/access/apps/{app_id}/policies
{
"name": "Service Token - Arcturus-Prime Proxy",
"decision": "non_identity",
"include": [{"service_token": {"token_id": "<token-uuid>"}}]
}
# Check CF Pages env vars
GET /accounts/{account_id}/pages/projects/Arcturus-Prime
# → result.deployment_configs.production.env_vars
# Set CF Pages env vars (no rebuild needed — runtime injected)
PATCH /accounts/{account_id}/pages/projects/Arcturus-Prime
Current Service Token Applications
| Application | Domain | Service Auth Policy |
|---|---|---|
| OC | oc.Arcturus-Prime.com | ”Service Token - Arcturus-Prime Proxy” (added 2026-03-02) |
Troubleshooting
Common Auth Issues
| Symptom | Cause | Fix |
|---|---|---|
| 401 on all admin routes | JWT validation failing | Check CF_ACCESS_AUD matches the application audience |
| 403 after successful login | User not in KV roles | Add user to data:user-roles via /api/auth/roles |
| Auth works locally but not in production | Dev mode bypass active locally | Expected behavior — test against preview deployment |
| JWT expired errors | Clock skew or stale session | Clear CF_Authorization cookie and re-authenticate |
| JWKS fetch failures | Team domain misconfigured | Verify CF_ACCESS_TEAM_DOMAIN is correct |
| 502 on OpenClaw proxy endpoints | CF Access blocking service token | Verify: 1) Service Auth policy exists on target app, 2) OPENCLAW_SERVICE_TOKEN_SECRET is set in CF Pages |
| Plain text “Not Found” on all admin routes | DEMO_MODE=true in production | Set DEMO_MODE=false in CF Pages dashboard |