Cloudflare Access Integration
Cloudflare Access Integration
Cloudflare Access (part of Cloudflare Zero Trust) provides network-level authentication for admin endpoints. It acts as a defense-in-depth layer — admin routes require both a valid X-Admin-Key header AND a verified Cloudflare Access JWT.
Note: Cloudflare Access protects admin routes only. Regular API authentication is handled by Clerk JWTs and API keys.
Table of Contents
- Architecture
- How It Works
- Setting Up Cloudflare Access
- Service Tokens for CI/CD
- Local Development
- Integration with Clerk Auth
- Technical Implementation
- Troubleshooting
Architecture
flowchart LR
ADMIN["Admin User\n(Browser)"] --> CF_ACCESS["Cloudflare Access\n(Zero Trust Proxy)\n✓ Identity check\n✓ Injects JWT header"]
CF_ACCESS --> WORKER1["Worker\n1. X-Admin-Key\n2. CF Access JWT verify\n3. Handler"]
CICD["CI/CD Pipeline\nCF-Access-Client-Id\nCF-Access-Client-Secret"] --> WORKER2["Worker\nVerified via\nCF Access"]
Defense-in-Depth Layers
Admin routes (/admin/storage/*) are protected by two independent layers:
| Layer | Mechanism | Header | Purpose |
|---|---|---|---|
| 1 | Admin Key | X-Admin-Key | Application-level secret — constant-time comparison |
| 2 | CF Access JWT | CF-Access-JWT-Assertion | Network-level identity — JWT signature verification |
Both layers must pass for the request to proceed. An attacker who compromises one secret still cannot access admin endpoints.
How It Works
When a request reaches an admin endpoint:
verifyAdminAuth()checks theX-Admin-Keyheader againstenv.ADMIN_KEYusing constant-time comparison (timingSafeCompareWorker)verifyCfAccessJwt()verifies theCF-Access-JWT-Assertionheader:- Fetches the JWKS from
https://<team>.cloudflareaccess.com/cdn-cgi/access/certs - Verifies signature, audience (
CF_ACCESS_AUD), issuer, and expiration usingjose - Extracts
emailandsubclaims from the payload
- Fetches the JWKS from
- If both pass, the request reaches the admin handler
JWT Verification Flow
flowchart TD
REQ["Request"] --> EXTRACT["Extract CF-Access-JWT-Assertion header"]
EXTRACT --> PRESENT{Token present?}
PRESENT -->|No| REJECT1["Return 403"]
PRESENT -->|Yes| JWKS_FETCH["Fetch JWKS\n(cached per Worker isolate)"]
JWKS_FETCH --> VERIFY["Verify JWT\n• Signature (RS256)\n• Audience = CF_ACCESS_AUD\n• Issuer = CF Access team URL\n• Not expired"]
VERIFY --> VALID{Valid?}
VALID -->|Valid| SUCCESS["Return email + identity"]
VALID -->|Invalid| REJECT2["Return 403 + error message"]
Setting Up Cloudflare Access
Step 1: Enable Cloudflare Zero Trust
- Go to Cloudflare Zero Trust
- If not already set up, create your team name (e.g.,
mycompany)- This becomes your team domain:
mycompany.cloudflareaccess.com - Choose carefully — this is used in JWT verification
- This becomes your team domain:
- You need at least the Free Zero Trust plan (50 users)
Step 2: Create a Self-Hosted Application
- In Zero Trust dashboard, go to Access → Applications
- Click Add an application → Self-hosted
- Configure the application:
| Field | Value | Notes |
|---|---|---|
| Application name | Bloqr Compiler Admin | Display name |
| Session Duration | 24h | How long before re-auth |
| Application domain | bloqr-backend.jk-com.workers.dev | Your Worker domain |
| Path | /admin/* | Restrict to admin routes only |
Important: Set the path to
/admin/*so that only admin endpoints are behind Access. Regular API and frontend routes should NOT go through Access.
- Click Next to configure policies
Step 3: Configure Access Policies
Create at least one Allow policy:
Policy: Admin Team (Email-based)
| Field | Value |
|---|---|
| Policy name | Admin Email Allow |
| Action | Allow |
| Include rule | Emails — admin@yourdomain.com |
Alternative: Identity Provider
If you use an IdP (Google Workspace, Okta, GitHub, etc.):
| Field | Value |
|---|---|
| Policy name | GitHub Org Members |
| Action | Allow |
| Include rule | Login Methods — GitHub |
| Require rule | GitHub Organization — your-org |
Adding Multiple Admins
You can combine multiple rules in a single policy:
- Emails:
admin1@example.com,admin2@example.com - Email domain:
@yourdomain.com(allows entire domain) - GitHub org: Requires membership in a specific GitHub organization
- IP ranges: Allow from specific IPs (e.g., office network)
Step 4: Get the Application AUD Tag
After creating the application:
- Go to Access → Applications
- Click on your
Bloqr Compiler Adminapplication - Find the Application Audience (AUD) Tag in the application overview
- It looks like:
4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d
- It looks like:
- Copy this value — you’ll need it for the Worker secret
Step 5: Configure Worker Secrets
Set the two required secrets using Wrangler:
# Set the team domain (just the subdomain, not the full URL)wrangler secret put CF_ACCESS_TEAM_DOMAIN# Enter: mycompany
# Set the application audience tagwrangler secret put CF_ACCESS_AUD# Enter: 4a1b2c3d... (the full AUD tag from Step 4)For local development with .dev.vars:
# .dev.vars (never commit this file)CF_ACCESS_TEAM_DOMAIN=mycompanyCF_ACCESS_AUD=4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7dService Tokens for CI/CD
Cloudflare Access supports Service Tokens for non-interactive access (CI/CD pipelines, cron jobs, monitoring).
Creating a Service Token
- In Zero Trust dashboard, go to Access → Service Auth → Service Tokens
- Click Create Service Token
- Name it (e.g.,
CI/CD Pipeline) - Copy the Client ID and Client Secret — the secret is shown only once
Using Service Tokens in CI/CD
# GitHub Actions examplecurl -X POST "https://bloqr-backend.jk-com.workers.dev/admin/storage/stats" \ -H "X-Admin-Key: ${{ secrets.ADMIN_KEY }}" \ -H "CF-Access-Client-Id: ${{ secrets.CF_ACCESS_CLIENT_ID }}" \ -H "CF-Access-Client-Secret: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}"Adding Service Token Policy
Add a Service Auth policy to your Access application:
| Field | Value |
|---|---|
| Policy name | CI/CD Service Token |
| Action | Service Auth |
| Include rule | Service Token — CI/CD Pipeline |
Local Development
When CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD are not set, the CF Access middleware gracefully skips verification and returns { valid: true }. This means:
- Local dev (
wrangler dev): Works without CF Access — onlyX-Admin-Keyis required for admin routes - Production: Both layers are enforced when both secrets are configured
Testing CF Access Locally
If you need to test CF Access locally:
- Set
CF_ACCESS_TEAM_DOMAINandCF_ACCESS_AUDin.dev.vars - Obtain a valid CF Access JWT:
- Visit your Access-protected URL in a browser
- After authenticating, the
CF_Authorizationcookie contains the JWT - Copy the JWT value
- Include it in requests:
curl -X GET "http://localhost:8787/admin/storage/stats" \ -H "X-Admin-Key: your-admin-key" \ -H "CF-Access-JWT-Assertion: eyJhbGciOiJS..."Integration with Clerk Auth
CF Access and Clerk serve different purposes in the auth stack:
| Aspect | Clerk | Cloudflare Access |
|---|---|---|
| Scope | All API routes | Admin routes only |
| Users | API consumers, frontend users | Administrators, CI/CD |
| Identity | Clerk user ID, email, tier | CF email, identity |
| Keys | JWT + API keys | JWT (auto-injected by Access proxy) |
| Management | Clerk Dashboard | CF Zero Trust Dashboard |
How They Coexist
Regular API request: User → Clerk JWT/API Key → authenticateRequestUnified() → Handler
Admin request (browser): Admin → CF Access (injects JWT) → verifyAdminAuth() → verifyCfAccessJwt() → Handler
Admin request (CI/CD): Pipeline → CF Service Token → verifyAdminAuth() → verifyCfAccessJwt() → HandlerFuture: Clerk-Based Admin Auth
The admin access system will eventually migrate from X-Admin-Key to Clerk tier-based authorization:
Future admin request: Admin → CF Access → authenticateRequestUnified() → requireTier(Admin) → HandlerIn this model, CF Access remains as the network-level gate, while Clerk handles identity and tier verification. See Admin Access for the migration plan.
Technical Implementation
Source File
worker/middleware/cf-access.ts
Key Types
interface CfAccessVerificationResult { valid: boolean; email?: string; identity?: string; error?: string;}Environment Variables
// In worker/types.ts → Env interfaceCF_ACCESS_TEAM_DOMAIN?: string; // e.g., 'mycompany'CF_ACCESS_AUD?: string; // Application audience tagJWKS Caching
The JWKS resolver is cached at module level per Worker isolate:
const cfAccessJwksCache = new Map<string, JWTVerifyGetKey>();- Cached per
certsUrl(derived from team domain) - Persists for the lifetime of the Worker isolate
- Automatically refreshes when the isolate is recycled
- Uses the same
joselibrary as Clerk JWT verification
Usage in Worker
// worker/worker.ts — admin route protectionif (pathname.startsWith('/admin/storage/')) { const adminAuth = verifyAdminAuth(request, env); if (!adminAuth.valid) { return JsonResponse.unauthorized(adminAuth.error); }
const cfAccess = await verifyCfAccessJwt(request, env); if (!cfAccess.valid) { return JsonResponse.forbidden(cfAccess.error); }
// Both layers passed — proceed to handler}Troubleshooting
”Missing CF-Access-JWT-Assertion header”
Cause: Request reached the Worker without passing through CF Access.
Fix:
- Verify the Access application path matches your admin route (
/admin/*) - Ensure you’re accessing the Worker through its public domain (not directly via
wrangler devwhen Access is required) - For CI/CD, use Service Token headers instead
”CF Access JWT verification failed: JWTExpired”
Cause: The Access session has expired.
Fix:
- Re-authenticate through the CF Access login page
- Increase the session duration in the Access application settings
- For Service Tokens, tokens don’t expire unless revoked
”CF Access JWT verification failed: JWSSignatureVerificationFailed”
Cause: JWT signature doesn’t match the JWKS keys.
Fix:
- Verify
CF_ACCESS_TEAM_DOMAINmatches your Zero Trust team name exactly - Verify
CF_ACCESS_AUDmatches the application’s audience tag exactly - The JWKS cache may be stale — the Worker isolate will eventually recycle
Admin route works locally but fails in production
Cause: CF Access is not configured or secrets are missing.
Fix:
# Verify secrets are setwrangler secret list
# Re-set if neededwrangler secret put CF_ACCESS_TEAM_DOMAINwrangler secret put CF_ACCESS_AUDCF Access blocks non-admin routes
Cause: The Access application path is too broad.
Fix:
- In Zero Trust dashboard, edit the application
- Ensure the path is set to
/admin/*(not/*or/) - Only admin routes should be behind Access
Further Reading
- Cloudflare Access Documentation
- Validating JWTs
- Service Tokens
- Zero Trust Pricing (Free plan: up to 50 users)
- Admin Access Guide — Migration plan to Clerk-based admin auth