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