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 | api.bloqr.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://api.bloqr.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
Bloqr AI™ — The privacy you didn't know you needed.
© 2026 Bloqr AI™, a trademark of Bloqr Systems™. Created by Bloqr Systems™, founded by Jayson Knight.
Internet Hygiene (n.) — the ongoing practices that keep your digital life clean, private, and safe.
Our product repos live in the BloqrAI org, part of the Bloqr Systems GitHub Enterprise. Product repos are internal-visibility — enterprise membership is required to view them.