Skip to content

Conversation

@elithrar
Copy link
Contributor

@elithrar elithrar commented Jan 6, 2026

Adds a validateAccessJwt() function to the cloudflare:workers module that validates Cloudflare Access JWTs against team-specific JWKs, providing a built-in alternative for users to validate Access JWTs directly vs. glueing it all together themselves.

  • Throws AccessJwtError with specific error codes (ERR_JWT_MISSING, ERR_JWT_EXPIRED, etc.) on validation failure - defaults to throwing exceptions that you must catch vs. some isValidJWT() type function that can be mishandled
  • No external dependencies - uses WebCrypto APIs
  • Retry logic for JWKS fetch (3 attempts, 5s backoff on 5xx errors)
  • Isolate-level JWKS caching (1 hour TTL, with cache invalidation on key rotation)
  • Team domain normalization (accepts both "myteam" and "myteam.cloudflareaccess.com")
  • 60s clock skew tolerance for expiration

Usage

import { validateAccessJwt, AccessJwtError } from 'cloudflare:workers';

export default {
  async fetch(request, env) {
    try {
      // env.TEAM_DOMAIN is their CF1 Team to fetch the JWKSet from
      // env.AUDIENCE is the ID from their Access app (per the Access docs)
      const payload = await validateAccessJwt(request, env.TEAM_DOMAIN, env.AUDIENCE);
      return new Response(`Hello ${payload.email}!`);
    } catch (err) {
      if (err instanceof AccessJwtError) {
        return new Response(`Auth failed: ${err.code}`, { status: 403 });
      }
      throw err;
    }
  }
};

Error Codes

These will be added to the public docs under https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/ + new Workers /examples as well

Code Description
ERR_JWT_MISSING No cf-access-jwt-assertion header
ERR_JWT_MALFORMED Invalid JWT structure or unsupported algorithm
ERR_JWT_INVALID_SIGNATURE Signature verification failed
ERR_JWT_EXPIRED Token exp claim is in the past
ERR_JWT_NOT_YET_VALID Token nbf claim is in the future
ERR_JWT_AUDIENCE_MISMATCH Token aud doesn't match expected audience
ERR_JWT_ISSUER_MISMATCH Token iss doesn't match team domain
ERR_JWKS_FETCH_FAILED Failed to fetch JWKS after retries
ERR_JWKS_NO_MATCHING_KEY No key with matching kid found
ERR_INVALID_TEAM_DOMAIN Empty team domain provided
ERR_INVALID_AUDIENCE Empty audience provided

Test Plan

  • 20 test cases covering all error codes and edge cases
  • Run: just test '//src/cloudflare/internal/test/access-jwt:access-jwt-test@'

@github-actions
Copy link

github-actions bot commented Jan 6, 2026

The generated output of @cloudflare/workers-types has been changed by this PR. If this is intentional, run just generate-types to update the snapshot. Alternatively, you can download the full generated types:

Full Type Diff
diff -r types/generated-snapshot/latest/index.d.ts bazel-bin/types/definitions/latest/index.d.ts
11602a11603,11643
>   // Access JWT validation
>   export type AccessJwtErrorCode =
>     | "ERR_JWT_MISSING"
>     | "ERR_JWT_MALFORMED"
>     | "ERR_JWT_INVALID_SIGNATURE"
>     | "ERR_JWT_EXPIRED"
>     | "ERR_JWT_NOT_YET_VALID"
>     | "ERR_JWT_AUDIENCE_MISMATCH"
>     | "ERR_JWT_ISSUER_MISMATCH"
>     | "ERR_JWKS_FETCH_FAILED"
>     | "ERR_JWKS_NO_MATCHING_KEY"
>     | "ERR_INVALID_TEAM_DOMAIN"
>     | "ERR_INVALID_AUDIENCE";
>   export class AccessJwtError extends Error {
>     readonly code: AccessJwtErrorCode;
>     constructor(code: AccessJwtErrorCode, message: string);
>   }
>   export interface AccessJwtPayload {
>     aud: string[];
>     email?: string;
>     exp: number;
>     iat: number;
>     nbf?: number;
>     iss: string;
>     sub: string;
>     [key: string]: unknown;
>   }
>   /**
>    * Validates a Cloudflare Access JWT from an incoming request.
>    *
>    * @param req - The incoming Request containing the cf-access-jwt-assertion header
>    * @param teamDomain - The Cloudflare One team domain (e.g., "mycompany" or "mycompany.cloudflareaccess.com")
>    * @param audience - The Application Audience (AUD) tag
>    * @throws {AccessJwtError} If validation fails for any reason
>    * @returns The decoded JWT payload on success
>    */
>   export function validateAccessJwt(
>     req: Request,
>     teamDomain: string,
>     audience: string,
>   ): Promise<AccessJwtPayload>;
diff -r types/generated-snapshot/latest/index.ts bazel-bin/types/definitions/latest/index.ts
11572a11573,11613
>   // Access JWT validation
>   export type AccessJwtErrorCode =
>     | "ERR_JWT_MISSING"
>     | "ERR_JWT_MALFORMED"
>     | "ERR_JWT_INVALID_SIGNATURE"
>     | "ERR_JWT_EXPIRED"
>     | "ERR_JWT_NOT_YET_VALID"
>     | "ERR_JWT_AUDIENCE_MISMATCH"
>     | "ERR_JWT_ISSUER_MISMATCH"
>     | "ERR_JWKS_FETCH_FAILED"
>     | "ERR_JWKS_NO_MATCHING_KEY"
>     | "ERR_INVALID_TEAM_DOMAIN"
>     | "ERR_INVALID_AUDIENCE";
>   export class AccessJwtError extends Error {
>     readonly code: AccessJwtErrorCode;
>     constructor(code: AccessJwtErrorCode, message: string);
>   }
>   export interface AccessJwtPayload {
>     aud: string[];
>     email?: string;
>     exp: number;
>     iat: number;
>     nbf?: number;
>     iss: string;
>     sub: string;
>     [key: string]: unknown;
>   }
>   /**
>    * Validates a Cloudflare Access JWT from an incoming request.
>    *
>    * @param req - The incoming Request containing the cf-access-jwt-assertion header
>    * @param teamDomain - The Cloudflare One team domain (e.g., "mycompany" or "mycompany.cloudflareaccess.com")
>    * @param audience - The Application Audience (AUD) tag
>    * @throws {AccessJwtError} If validation fails for any reason
>    * @returns The decoded JWT payload on success
>    */
>   export function validateAccessJwt(
>     req: Request,
>     teamDomain: string,
>     audience: string,
>   ): Promise<AccessJwtPayload>;

@elithrar elithrar force-pushed the importable-access-verify-jwt branch 2 times, most recently from 55d2823 to 5ee0f15 Compare January 6, 2026 15:46
@elithrar elithrar self-assigned this Jan 6, 2026
@elithrar
Copy link
Contributor Author

elithrar commented Jan 6, 2026

The tests (after building...) fail locally — unclear (to me) why:

$ cat /private/var/tmp/_bazel_matt/2a80ed57af32aa0a2e438bf225f1030d/execroot/_main/bazel-out/darwin_arm64-fastbuild/testlogs/src/cloudflare/internal/test/access-jwt/access-jwt-test@/test.log
Executing tests from //src/cloudflare/internal/test/access-jwt:access-jwt-test@
-----------------------------------------------------------------------------
kj/exception.c++:357: warning: llvm-symbolizer was not found. To symbolize stack traces, install it in your $PATH or set $LLVM_SYMBOLIZER to the location of the binary. When running tests under bazel, use `--test_env=LLVM_SYMBOLIZER=<path>`.
*** Received signal #11: Segmentation fault: 11
stack: 10b8c7907 10b8c6a3f 10b8c650b 107c8ea5f 107c8fb1b 1071b9fcf 1071b8b63 1071b8a17 1071b8983 1074fcdc3 1074fd943 106c8020b 106c8014f 106c8011b 106c7c79f 106c7c3f7 104fe02ab 104fe01f3 104fdf173 104fdec37 104fdeac7 104984d1b 104984b9b 104984e63 10477d91b 1048d8cb7 10bb5b4ab 10bb5b477 10bb52f87

@elithrar elithrar marked this pull request as ready for review January 6, 2026 16:06
@elithrar elithrar requested review from a team as code owners January 6, 2026 16:06
@elithrar elithrar force-pushed the importable-access-verify-jwt branch from 5ee0f15 to 2dda59e Compare January 6, 2026 22:00
@elithrar elithrar force-pushed the importable-access-verify-jwt branch 3 times, most recently from b678274 to 962eade Compare January 6, 2026 22:05
anonrig
anonrig previously approved these changes Jan 6, 2026
@danlapid danlapid requested a review from anonrig January 6, 2026 22:11
@anonrig anonrig dismissed their stale review January 6, 2026 22:15

Dismissing as requested from Dan.

Copy link
Contributor

@mhart mhart left a comment

Choose a reason for hiding this comment

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

Some minor suggestions, just to use some slightly more modern/idiomatic methods, but looks great overall

@elithrar elithrar force-pushed the importable-access-verify-jwt branch from 962eade to c343bd7 Compare January 7, 2026 14:55
@elithrar
Copy link
Contributor Author

elithrar commented Jan 7, 2026

@mhart @anonrig I think I got this right — addressed all the comments:

  • replaced atob/btoa with Uint8Array.fromBase64/toBase64 w/ base64url alphabet
  • used FixedLengthArray<string, 3>
  • switched from setTimeout to globalThis.scheduler.wait() for retry delays
  • module-level TextEncoder/TextDecoder for reuse

Adds a new validateAccessJwt() function that validates Cloudflare Access
JWTs against team-specific JWKs. The function throws AccessJwtError with
specific error codes on validation failure, making error handling explicit.

Key features:
- No external dependencies (uses WebCrypto APIs)
- Retry logic for JWKS fetch (3 attempts, 5s backoff on 5xx)
- Isolate-level JWKS caching (1 hour TTL)
- Team domain normalization (accepts both short and full forms)
- 60s clock skew tolerance for expiration
@elithrar elithrar force-pushed the importable-access-verify-jwt branch from a8cc773 to 5d87ec9 Compare January 7, 2026 15:06
@codspeed-hq

This comment was marked as outdated.

@anonrig
Copy link
Member

anonrig commented Jan 7, 2026

Except the failing tests, the implementation looks really good.

Comment on lines +196 to +197
async function fetchJwks(normalizedDomain: string): Promise<JwkSet> {
const url = `https://${normalizedDomain}/cdn-cgi/access/certs`;
Copy link
Member

Choose a reason for hiding this comment

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

Nit: best way to do this is by doing:

const url = `https://cloudflare.com/cdn-cgi/access/certs`
url.hostname = normalizedDomain;

Copy link
Collaborator

Choose a reason for hiding this comment

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

shouldn't that be const url = new URL('https://cloudflare.com/cdn-cgi/access/certs);` ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The domain has to include the team name: e.g. https://opencode-devtools.cloudflareaccess.com/cdn-cgi/access/certs - as the certs are scoped to the Access team/domain

@elithrar
Copy link
Contributor Author

elithrar commented Jan 7, 2026

@anonrig unfortunately getting the tests to run locally in any reliable way is beyond me, and I can't grok why the tests can't see the access-jwt module:

FAIL: //src/cloudflare/internal/test/access-jwt:access-jwt-test@ (Exit 1) (see /home/runner/.cache/bazel/_bazel_runner/be54bd8ac890d1817c772d91eb8f6cb4/execroot/_main/bazel-out/aarch64-dbg/testlogs/src/cloudflare/internal/test/access-jwt/access-jwt-test@/test.log)
INFO: From Testing //src/cloudflare/internal/test/access-jwt:access-jwt-test@:
==================== Test output for //src/cloudflare/internal/test/access-jwt:access-jwt-test@:
service access-jwt-test: Uncaught Error: No such module "cloudflare-internal:access-jwt".
  imported from "worker" 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants