Quickback Docs

Cloudflare Access

Put Cloudflare Access in front of the Worker, a hostname, or a route group. The generated Worker verifies the Access JWT itself and maps the identity to a Better Auth user and role.

Set auth.cloudflareAccess to put Cloudflare Access in front of operator or staff routes. Inside an application's scope, the generated Worker authenticates the request only from the Cf-Access-Jwt-Assertion header, and verifies it on every request. It never trusts the header just because it is present.

quickback.config.ts
export default defineConfig({
  // ...
  auth: {
    cloudflareAccess: {
      teamDomain: 'acme.cloudflareaccess.com',
      aud: { env: 'CF_ACCESS_AUD' },
      hosts: ['ops.example.com'],
      roles: ['sysadmin'],
      serviceTokens: { '88bf3b6d86161464f6509f7219099e57.access': 'deploy-bot@example.com' },
    },
  },
});

Requires the cloudflare runtime, the better-auth auth provider, and the cloudflare-d1 database. Anything else is a compile error. Postgres is refused for now: the lane's user lookup carries no RLS claims, so under RLS it would find no user.

What the Worker verifies

For a request inside an application's scope:

  1. Cf-Access-Jwt-Assertion is present. Otherwise 401 CF_ACCESS_REQUIRED.
  2. The token is an application token (type: "app"), RS256-signed by a key in https://<teamDomain>/cdn-cgi/access/certs. iss equals https://<teamDomain>, aud contains the configured AUD tag, and exp is present and in the future. Otherwise 401 CF_ACCESS_INVALID.
  3. The identity maps to an existing Better Auth user, as described in Identity mapping. Otherwise 403 CF_ACCESS_UNMAPPED.
  4. The resulting userRole is in roles. Otherwise 403 CF_ACCESS_ROLE_NOT_ADMITTED.

The JWKS is fetched once per isolate and cached. An unknown kid triggers a refetch, which covers Access key rotation.

Inside the scope, the Access assertion is the only credential. A session cookie, a bearer JWT, or an API key does not authenticate the request, and a refused assertion never falls back to them.

On success, ctx has authMethod: 'cloudflare-access', the user's userId, userRole, and user, plus ctx.cloudflareAccess: { type: 'user' | 'service-token', email?, commonName?, groups? }. Gates that require authMethod: 'session', such as GET /auth/v1/token, refuse Access callers.

Scope

Keys setThe application covers
neither hosts nor pathsevery request the Worker serves
hostsrequests whose hostname is in the list (exact, case-insensitive)
pathsrequests whose path equals a prefix or sits below it (/api/v1/ops covers /api/v1/ops/*, not /api/v1/opsx)
bothrequests matching a host and a path

Pass an array to declare several applications. The first match wins.

A hosts-scoped application covers only those hostnames. The same routes on the Worker's other hostnames stay reachable through the normal cookie, bearer, and API-key lanes. If a route must never be reachable without Access, scope it by paths, or cover the whole Worker.

A host in hosts that the config does not claim never reaches the Access check. The host allowlist 404s it first.

Identity mapping

Access identities map to existing Better Auth users. Nothing is provisioned. Create the user first, and set user.role (admin plugin) unless groups supplies the role.

People

Access puts the IdP email in the email claim. The Worker looks up the user with that email (lowercased). It refuses the user if the account's email is not verified (emailVerified), or if the user is banned. Without the verification check, anyone who pre-registers an unverified account with a colleague's address would inherit that colleague's Access identity. userRole is the user's user.role (default 'user'), and it must be in roles.

Groups

Set groups to take the role from the IdP instead of user.role:

cloudflareAccess: {
  teamDomain: 'acme.cloudflareaccess.com',
  aud: 'd8a1f0c7…',
  paths: ['/api/v1/staff'],
  roles: ['admin', 'sysadmin'],
  groups: { 'it-admins': 'sysadmin', 'event-staff': 'admin' },
}

The Worker reads groups from the token's custom.groups claim. Access includes it only when you add groups as a custom OIDC claim or SAML attribute on the identity provider. The first groups entry the caller holds sets userRole. Every groups value must also be in roles, or the compile fails. When groups is set, a caller who holds none of the listed groups, or whose token lacks the claim, is refused with 403. Access trims custom claims to about 1 KB, so keep group names short.

With groups, whoever administers groups in your identity provider controls these roles. The custom claim is best-effort, and Access may trim it. Use groups only with an IdP where users cannot edit their own group membership or attributes.

Service tokens

A request with CF-Access-Client-Id and CF-Access-Client-Secret reaches the Worker with a token that has no email. Its common_name is the Client ID (<id>.access) and its sub is empty. Map each Client ID to the email of the Better Auth user it acts as:

serviceTokens: { '88bf3b6d86161464f6509f7219099e57.access': 'deploy-bot@example.com' },

The token then authenticates as that user, and the user's user.role must be in roles. Unlisted Client IDs, and a common_name that does not end in .access, are refused. Keys in serviceTokens must have that form, or the compile fails. The email-verification check does not apply, because the mapping is your own config. groups does not apply to service tokens.

Organizations

Access carries no active-organization selection. With features.pinnedOrganizationId, an Access caller who is a member of the pinned org gets activeOrgId and the member role (expanded through auth.roleHierarchy). Without a pinned org, an Access caller has no active org. userRole gates and the sysadmin bypass still apply.

Per-environment values

teamDomain and aud each accept { env: 'VAR_NAME' }. The Worker reads the var at request time, so every named environment can point at its own Access team from one bundle. This is how a client's own Access fronts their private deployment:

quickback.config.ts
export default defineConfig({
  // ...
  auth: {
    cloudflareAccess: {
      teamDomain: { env: 'CF_ACCESS_TEAM' },
      aud: { env: 'CF_ACCESS_AUD' },
      paths: ['/api/v1/staff'],
      roles: ['admin'],
    },
  },
  environments: {
    'client-a': {
      domains: { api: 'api.client-a.example' },
      worker: {
        vars: {
          CF_ACCESS_TEAM: 'client-a.cloudflareaccess.com',
          CF_ACCESS_AUD: '4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2',
        },
      },
    },
  },
});

An unset var, or a team domain that is not <team>.cloudflareaccess.com, gives 500 CF_ACCESS_MISCONFIGURED. It never gives a pass.

Browser requests and CSRF

Access adds the assertion to every browser request that carries its CF_Authorization cookie, so the credential is ambient. A mutating request from a person is accepted only when Sec-Fetch-Site is same-origin or none. cross-site and same-site get 403 CSRF_ORIGIN_REJECTED, because sibling subdomains carry CF_Authorization too. When the browser sends no Sec-Fetch-Site, the request is refused if Origin is neither the request's own origin nor a trusted origin (TRUSTED_ORIGINS or BETTER_AUTH_URL). Service-token requests skip this check.

Operator MFA

auth.operatorMfa downgrades operator sessions that lack a second factor. It does not apply to the Access lane. Enforce MFA in the Access policy instead.

Setting up the Access application

  1. In Zero Trust, create a self-hosted application for the hostname (and path) you scoped.
  2. Copy its Application Audience (AUD) tag into aud.
  3. Set teamDomain to your team domain, <team>.cloudflareaccess.com.
  4. For machines, create a service token and add a Service Auth policy. Then map its Client ID in serviceTokens.

On this page