Skip to content

feat: add Cloudflare Zero Trust authentication layer for admin routes#2298

Merged
andrew-bierman merged 1 commit into
developmentfrom
feat/cf-zero-trust-admin-auth
Apr 26, 2026
Merged

feat: add Cloudflare Zero Trust authentication layer for admin routes#2298
andrew-bierman merged 1 commit into
developmentfrom
feat/cf-zero-trust-admin-auth

Conversation

@andrew-bierman
Copy link
Copy Markdown
Collaborator

Summary

  • Adds a new authentication layer for all /api/admin/* routes using Cloudflare Zero Trust
  • Admin SPA now detects CF Access sessions and forwards the appropriate credentials to the API
  • Strengthens the admin JWT validation with issuer/audience binding

Changes

  • packages/api/src/middleware/cfAccess.ts — new cryptographic request verifier backed by the team's public JWKS
  • packages/api/src/routes/admin/index.ts — updated auth guard; when CF Access env vars are present, only cryptographically verified requests are accepted with no password fallthrough
  • apps/admin/lib/cfAccess.ts — new client-side CF Access identity helper with safe concurrency handling
  • apps/admin/lib/api.ts — admin API client now includes the CF Access JWT assertion in requests
  • apps/admin/components/auth-guard.tsx — improved unmount safety and CF Access session detection
  • apps/admin/app/login/page.tsx — auto-redirect when a valid CF Access session is present

Configuration

Set these two optional env vars in Cloudflare Workers dashboard to activate the Zero Trust layer:

CF_ACCESS_TEAM_DOMAIN=https://<team>.cloudflareaccess.com
CF_ACCESS_AUD=<Application Audience tag>

When unset, existing local dev auth (Basic / Bearer JWT) continues to work unchanged.

Post-Deploy Monitoring & Validation

  • Logs: Watch Workers logs for verifyCFAccessRequest — a null return on a legitimate request indicates the CF Access app AUD tag is misconfigured
  • Expected healthy behavior: Admin routes return 200 for requests arriving through CF Access; 401 for direct requests without a valid JWT assertion
  • Failure signal / rollback trigger: 401s from authenticated CF users → verify CF_ACCESS_TEAM_DOMAIN includes https:// and CF_ACCESS_AUD matches the Access Application's audience tag; rollback by unsetting the env vars
  • Validation window: First 30 minutes after env vars are set in production

Compound Engineered 🤖 Generated with Claude Code

Introduces cryptographic request verification on all /api/admin/* routes
using Cloudflare Access JWT assertions validated against the team JWKS
endpoint. When CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are set, access
is gated exclusively on a valid signed JWT — no password fallthrough.

Also hardens the admin SPA authentication flow with improved concurrency
handling, cancellation safety, and CF Access identity forwarding.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 26, 2026 00:01
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cde97335-1e75-4457-b639-ee071e9bd13b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cf-zero-trust-admin-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the api label Apr 26, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Coverage Report for API Unit Tests Coverage (./packages/api)

Status Category Percentage Covered / Total
🔵 Lines 75% 582 / 776
🔵 Statements 75% (🎯 65%) 582 / 776
🔵 Functions 95.83% 46 / 48
🔵 Branches 88.19% 269 / 305
File CoverageNo changed files found.
Generated in workflow #756 for commit 9d67f5a by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

Coverage Report for Expo Unit Tests Coverage (./apps/expo)

Status Category Percentage Covered / Total
🔵 Lines 81.38% 516 / 634
🔵 Statements 81.38% (🎯 75%) 516 / 634
🔵 Functions 92.85% 52 / 56
🔵 Branches 92.55% 199 / 215
File CoverageNo changed files found.
Generated in workflow #756 for commit 9d67f5a by the Vitest Coverage Report Action

@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
packrat-admin 9d67f5a Apr 26 2026, 12:02 AM

@andrew-bierman andrew-bierman merged commit 61be211 into development Apr 26, 2026
11 of 13 checks passed
@andrew-bierman andrew-bierman deleted the feat/cf-zero-trust-admin-auth branch April 26, 2026 00:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional Cloudflare Zero Trust (Access) authentication layer for /api/admin/* by cryptographically verifying the CF-Access-JWT-Assertion, and updates the admin SPA to detect/forward CF Access credentials. Also tightens local-dev admin JWT verification by binding issuer/audience.

Changes:

  • Add verifyCFAccessRequest middleware backed by remote JWKS + issuer/audience validation.
  • Update admin route guard to require CF Access verification when configured; otherwise keep local-dev Bearer/Basic fallbacks.
  • Update admin SPA to detect CF Access sessions and send CF-Access-JWT-Assertion on API calls; improve route/login guard behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/api/wrangler.jsonc Documents new optional CF Access env vars for admin-route auth.
packages/api/src/utils/env-validation.ts Adds CF Access env vars to the validated env schema.
packages/api/src/routes/admin/index.ts Enforces CF Access verification (when configured) and strengthens admin JWT issuer/audience validation.
packages/api/src/middleware/index.ts Re-exports CF Access verifier/types from middleware barrel.
packages/api/src/middleware/cfAccess.ts Implements CF Access JWT assertion verification using remote JWKS.
apps/admin/lib/cfAccess.ts Adds client helper to fetch CF Access identity/JWT with concurrency memoization.
apps/admin/lib/api.ts Sends CF Access JWT assertion header when present; otherwise uses local session JWT.
apps/admin/components/auth-guard.tsx Adds CF Access detection path and improves unmount safety.
apps/admin/app/login/page.tsx Auto-redirects when CF Access session is detected; shows local-dev hint when not.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +33 to +37
export async function verifyCFAccessRequest(
request: Request,
teamDomain: string,
aud: string,
): Promise<CFAccessIdentity | null> {
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new verifier is security-critical and currently has no unit tests. Since packages/api/test/middleware/* already covers other auth middleware, consider adding tests for missing header → null, invalid JWT/issuer/audience → null, and a valid JWT signed by a JWKS key → identity returned.

Copilot uses AI. Check for mistakes.
// - Local development: .dev.vars file (not committed to git)
//
// Cloudflare Zero Trust / Access (optional — set to enable JWT verification on /api/admin/*):
// CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline config comment suggests CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com (no scheme), but verifyCFAccessRequest expects a full URL (e.g. https://<team>.cloudflareaccess.com) and will throw on an invalid URL when building the JWKS endpoint. Update the comment to include the required https:// prefix to avoid production misconfiguration.

Suggested change
// CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com
// CF_ACCESS_TEAM_DOMAIN=https://<team>.cloudflareaccess.com

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +25
CF_ACCESS_TEAM_DOMAIN: z.string().optional(), // e.g. "packrat.cloudflareaccess.com"
CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CF_ACCESS_TEAM_DOMAIN is currently z.string().optional() with an example lacking https://, but the CF Access verifier requires a valid URL string (and uses it for both JWKS URL construction and issuer matching). Consider validating it as z.string().url().optional() and updating the example to https://<team>.cloudflareaccess.com to prevent a misconfig that would 401 all admin requests.

Suggested change
CF_ACCESS_TEAM_DOMAIN: z.string().optional(), // e.g. "packrat.cloudflareaccess.com"
CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag
CF_ACCESS_TEAM_DOMAIN: z.string().url().optional(), // e.g. "https://packrat.cloudflareaccess.com"
CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +78
const env = getEnv();
const { CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD } = env;

const header = request.headers.get('authorization') ?? '';
if (header.startsWith('Bearer ')) {
return verifyAdminJwt(header.slice(7));
}
if (header.startsWith('Basic ')) {
return basicAuthGuard(request).authorized;
if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) {
// CF Access configured: cryptographic JWT verification only, no fallthrough.
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only one of CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD is set, this guard silently falls back to Bearer/Basic auth. That’s a risky failure mode for production because a partial CF Access rollout/misconfig would unintentionally keep the weaker local-dev auth paths enabled. Consider enforcing an “all-or-nothing” config and fail closed (401) when exactly one is set.

Copilot uses AI. Check for mistakes.
return () => {
canceled = true;
};
}, []); // router is stable in App Router
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect uses router.replace(...) but omits router from the dependency array. Elsewhere in the admin app (e.g. app/page.tsx, app/login/page.tsx) [router] is included; keeping that here avoids stale-closure risks and maintains consistent hooks usage.

Suggested change
}, []); // router is stable in App Router
}, [router]);

Copilot uses AI. Check for mistakes.
* Promise is memoized for the page lifetime — safe to call concurrently.
*/
export function getCFAccessIdentity(): Promise<CFAccessIdentityResponse | null> {
identityPromise ??= fetchIdentity();
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identityPromise memoizes the first identity fetch result even when it resolves to null (transient network error / temporary 5xx). That makes the app treat CF Access as “not present” for the rest of the page lifetime and can cause incorrect redirects/auth behavior. Consider only memoizing successful identities, or resetting identityPromise back to undefined when the fetch resolves to null so subsequent calls can retry.

Suggested change
identityPromise ??= fetchIdentity();
identityPromise ??= fetchIdentity().then((identity) => {
if (identity === null) {
identityPromise = undefined;
}
return identity;
});

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +36
useEffect(() => {
isBehindCFAccess().then((behind) => {
setCFAccess(behind);
if (behind) router.replace('/dashboard');
});
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect kicks off an async isBehindCFAccess() call and then sets state / navigates, but it doesn’t guard against the component unmounting during the redirect. Consider adding a cancellation flag (like components/auth-guard.tsx) to avoid setting state on an unmounted component.

Suggested change
useEffect(() => {
isBehindCFAccess().then((behind) => {
setCFAccess(behind);
if (behind) router.replace('/dashboard');
});
useEffect(() => {
let cancelled = false;
isBehindCFAccess().then((behind) => {
if (cancelled) return;
setCFAccess(behind);
if (behind) router.replace('/dashboard');
});
return () => {
cancelled = true;
};

Copilot uses AI. Check for mistakes.
andrew-bierman added a commit that referenced this pull request May 14, 2026
feat: add Cloudflare Zero Trust authentication layer for admin routes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants