Cloudflare Access (Zero Trust) identity for Lunora — feed the verified Access identity into ctx.auth and RLS via a resolveIdentity adapter, from a Worker-scoped Access policy or a hostname-scoped application's Cf-Access-Jwt-Assertion JWT.
@lunora/cloudflare-access turns a Cloudflare Access (Cloudflare One / Zero Trust) request into a Lunora identity. A verified SSO user or service token becomes ctx.auth for every query, mutation, and action, and feeds row-level security with no per-procedure wiring.
Cloudflare Access is an identity-aware proxy: it authenticates the caller before the request reaches your Worker. This package is therefore not a better-auth plugin. It is a resolveIdentity adapter. The single integration point is the runtime's resolveIdentity hook, which already feeds ctx.auth, RLS policies, serverDefault columns, and live subscriptions.
Which one you get depends on where the Access policy is attached. createAccessResolver handles both and prefers the first, so you configure only what your deployment actually uses.
An Access policy attached to the Worker protects every hostname that Worker answers on at once: custom domains, routes, workers.dev, and preview URLs. Cloudflare then hands the Worker the caller's identity directly on the execution context, and the runtime forwards that context to resolveIdentity.
import { createAccessResolver } from "@lunora/cloudflare-access";import { defineApp } from "./_generated/server";export default defineApp<Env>() .shard((env) => env.SHARD) .access() // no team domain, no AUD tag, no JWKS fetch .build();
Nothing is verified on this path because nothing can be forged: the platform authenticated the caller before your code ran, and the identity is absent unless it did. No JWKS round-trip and no audience to get wrong.
An Access application scoped to a hostname does not populate the execution context. It stamps a signed Cf-Access-Jwt-Assertion header (and a CF_Authorization cookie on browser navigations) instead, which the resolver verifies against your team's JWKS. Pass teamDomain + aud:
teamDomain and aud are all-or-nothing, and construction throws when only one is present. aud is the only claim that scopes a token to your Access application — a token minted for any other app in the same Cloudflare team shares the issuer and JWKS — so an unset env.CF_ACCESS_AUD must fail loudly rather than quietly disabling the check. Omitting both is the legal "Worker-scoped policy" mode above.
Either way the resolver is fail-closed → anonymous: no identity, or a token that fails verification, resolves to null, the request proceeds unauthenticated, and RLS denies.
Socket expiry differs between the two. An Access JWT always carries exp, which is forwarded so a live WebSocket subscription is dropped when the credential lapses. The platform-supplied identity may not, and a socket opened without one stays authenticated for as long as it stays connected — past the Access session expiring, past the user leaving the policy. If you hold long-lived subscriptions under a Worker-scoped policy, mint a bound yourself:
Both paths produce the same claim shape, so nothing downstream — ctx.auth, ctx.access, accessRoles(), your RLS policies — branches on how the caller was authenticated. The one wire difference is group membership, which the platform may emit as { id, name } objects; those are normalized to their names to match the JWT's string[].
wrangler dev (and the Vite plugin) simulate a Worker-scoped Access identity from a wrangler.jsonc block, which reaches ctx.auth and ctx.access exactly like the deployed identity does:
.access() is the generated builder's method. To wire the resolver onto a worker you compose yourself, use the extend escape hatch (in your worker entry, e.g. _generated/server is consumed by lunora/server.ts):
When you also run @lunora/auth, both want the resolveIdentity hook, and a bare extend would clobber the better-auth resolver (extend merges over the derived options). Compose them so Access wins when it authenticated the caller and everyone else falls through to the app session:
Composition is for the hostname-scoped shape, which is why the example configures the JWT fallback. An Access policy attached to the Worker authenticates every request that reaches your code — there is no "everyone else" left to fall through to the app session, so the second resolver would be unreachable. Use one or the other, not a Worker-scoped policy in front of an app that also signs users in itself.
The codegen integration (below) wires this composition for you when it detects an Access configuration, so you rarely write it by hand.
userId is derived from sub (SSO) or common_name (service tokens), identically on both paths — so a deployment that moves from a hostname-scoped application to a Worker-scoped policy keeps resolving each user to the same id rather than re-keying them and orphaning their rows behind RLS. The verified claims (email, groups, commonName, and the full raw set under access) are available via ctx.auth.getIdentity(), so RLS policies can branch on them:
@lunora/cloudflare-access/roles ships accessRoles(), a procedure middleware that lifts the verified groups claim into ctx.auth.roles, the per-request role list rls() unions permissions over. Place it beforerls(...) in the chain; with no token (anonymous) it forwards the context unchanged, so RLS still fails closed.
import { accessRoles } from "@lunora/cloudflare-access/roles";export const listInvoices = query // groups used verbatim, or remap with a table / function .use(accessRoles({ map: { "idp-admins": "admin", "idp-billing": ["billing", "viewer"] } })) .use(rls(policies, { roles })) .query(async ({ ctx }) => ctx.db.from("invoices").all());
Omit map to use each group name as a role verbatim. A group with no mapping contributes no role (fail-closed). Roles are unioned with any already on ctx.auth.roles, then deduped. Pass readGroups when your IdP nests groups somewhere other than the groups claim.
A typed, synchronousctx.access facade exposes the verified identity directly (ctx.access.email, ctx.access.groups, ctx.access.hasGroup("ops"), or the full ctx.access.claims) with no await and no cast off the generic ctx.auth.getIdentity() envelope.
Just read ctx.access in a handler. Codegen detects the use and wires the facade onto every ctx (query, mutation, and action), with no import and no middleware:
The facade is built synchronously from the same resolved identity ctx.auth uses, so a global ctx.access adds only one small object construction per request, with no extra I/O and no JWT re-verification (that happened once at the edge in resolveIdentity). The type seam is emitted only when a handler reads ctx.access, so a project that never touches it carries no extra surface.
When you want it attached explicitly on chosen procedures (or you aren't relying on codegen discovery), @lunora/cloudflare-access/context ships accessContext(), a middleware that attaches the same facade:
The middleware imports the /context subpath, so it never trips the global wiring; the two paths don't collide.
Either way, an anonymous request gets the anonymous facade (authenticated: false, empty groups, hasGroup always false), so reads stay safe with no null check and authorization still fails closed. ctx.access only surfaces the identity; pair it with rls(...) (or accessRoles(...) → rls(...)) when you need enforcement.
ctx.access surfaces only Access identities. Under composeResolvers(access, …), a request authenticated by another adapter (e.g. a better-auth session) resolves an identity with no verified Access claim set, so ctx.access reads anonymous (authenticated: false) for it. Read that caller off ctx.auth instead. This keeps ctx.access.authenticated an honest "is there a verified Access identity" check rather than "is there any identity".
@lunora/cloudflare-access/admin ships accessAdminGate(), which builds a value for the runtime's WorkerOptions.adminGate, an async authorization gate for the /_lunora/admin/* plane (the Studio's HTTP + WebSocket endpoints), OR-ed with the static adminToken bearer. It applies your isAdmin(claims) predicate, so an Access identity in the right group can reach the Studio without a shared token. It is fail-closed: no identity, a token that fails verification, or an isAdmin returning false all deny (the bearer stays the only other path). It runs only on admin routes, never on the RPC/WebSocket data path.
Wire it on the generated app builder via .extend((env) => …):
The admin gate accepts one proof, and the precedence is the reverse of the resolver's. Configure teamDomain + aud and the JWT is the only thing accepted; omit both and the Worker's Access identity is. That is deliberate. A policy attached to the Worker is typically broad — "anyone at the company", covering every route and preview URL — while an admin aud is deliberately narrow, and its whole purpose is proving the caller came through that application. If the broad identity could satisfy the gate, attaching a Worker-scoped policy would silently widen the admin plane to everyone it admits. Under a Worker-scoped policy, isAdmin is the entire boundary, so write it at least as strictly as the dedicated application's policy would have been.
createAccessResolver(options?): a resolveIdentity adapter — reads the platform-supplied identity when the Access policy is attached to the Worker, otherwise verifies the header/cookie JWT (teamDomain + aud, required together).
composeResolvers(...resolvers): first non-null identity wins.
verifyAccessJwt(token, options): low-level; verify a token, return its claims (throws on failure).
accessIssuer(teamDomain): normalize a team domain to its canonical issuer URL.
ctx.access(codegen-wired): read it in a handler and codegen attaches a typed, synchronous facade over the verified identity to every ctx.
accessContext()(@lunora/cloudflare-access/context): the explicit per-procedure middleware form of the same ctx.access facade.
accessFacade(identity, userId)(@lunora/cloudflare-access/context): the pure factory both forms build on; returns the anonymous facade when no identity is present.
accessRoles(options)(@lunora/cloudflare-access/roles): middleware mapping the verified groups claim into ctx.auth.roles for RLS.
accessAdminGate(options)(@lunora/cloudflare-access/admin): a WorkerOptions.adminGate that authorizes the /_lunora/admin/* plane from a verified Access identity. isAdmin is required; supplying the JWT config makes the JWT the only accepted proof, omitting it accepts the Worker's Access identity.