Sfoglia il codice sorgente

feat(skills): Verify and deepen the Access + Better Auth references

Harden both new auth-ops references against live documentation
(Cloudflare docs + better-auth.com, 2026-08):

cloudflare-access.md
- "What's in the Token" section: full identity-JWT claims table (aud is
  an ARRAY; sub regenerates on org remove/re-add), the service-token
  payload (common_name, empty sub) so Service Auth callers can be
  JWT-verified too, and the ~1KB `custom`-claim trim that silently drops
  IdP groups - never authorize on custom claims; use
  /cdn-cgi/access/get-identity or your own user store.
- "Sessions, Logout, and the SPA Problem": global vs application
  CF_Authorization tokens, /cdn-cgi/access/logout, the expired-session
  302-to-IdP CORS trap with detect-and-reload guidance, and the newer
  Managed OAuth (401 + WWW-Authenticate, opaque tokens) for API clients.
- Internal invocation paths (cron/queues/service bindings) carry no
  Access header - explicit system identity, not request middleware.
- Terraform note, tunnel defense-in-depth note, four new gotcha rows.

better-auth.md
- Session model pinned to verified options: expiresIn 7d / updateAge 1d
  sliding window, cookieCache shape (incl. version bulk-invalidation),
  revocation API granularities, secondary-storage behaviour.
- Plugin table corrected + extended from the official catalog: sso/scim
  (enterprise SSO no longer forces a hosted IdP - decision tree updated),
  oidcProvider/oauthProvider/mcp, emailOTP, genericOAuth, 40+ note.
- Hono snippet verified; CORS-before-routes + credentials, and
  crossSubDomainCookies vs sameSite:none cross-domain guidance.
- New "Extending the User Model" and "Migrating In" sections (official
  Auth0/Clerk/NextAuth/Supabase/WorkOS guides, hash import, sessions
  do not migrate); llms.txt pointer in the freshness note.

SKILL.md: compact Identity-Aware Proxy Quick Reference in the body,
two new gotcha rows (unverified proxy header, middleware-only session
checks), reference index updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 mese fa
parent
commit
46ee89a34c

+ 9 - 3
CHANGELOG.md

@@ -66,9 +66,15 @@ feature releases live in the README "Recent Updates" section.
   adapters and CLI schema generation, DB-backed sessions with cookie caching,
   adapters and CLI schema generation, DB-backed sessions with cookie caching,
   email/password + social providers, passkey/2FA/organization plugins, Hono
   email/password + social providers, passkey/2FA/organization plugins, Hono
   and framework mounting, and choosing it vs hand-rolled vs a hosted IdP.
   and framework mounting, and choosing it vs hand-rolled vs a hosted IdP.
-  Frontmatter triggers extended (cloudflare access, zero trust, identity-aware
-  proxy, AUD tag, service auth, better auth), decision tree gains an IAP
-  branch, and cloudflare-ops cross-points to the new Access reference.
+  Facts verified against live Cloudflare docs and better-auth.com (2026-08):
+  Access token claims incl. the ~1KB `custom`-claim trim, service-token
+  `common_name` verification, `/cdn-cgi/access/logout`, the SPA
+  302-to-IdP/CORS trap, and Better Auth's session options
+  (`expiresIn`/`updateAge`/`cookieCache`), plugin catalog (incl. sso/scim),
+  and migration guides. Frontmatter triggers extended (cloudflare access,
+  zero trust, identity-aware proxy, AUD tag, service auth, better auth),
+  the decision tree and body gain an IAP branch + quick reference, and
+  cloudflare-ops cross-points to the new Access reference.
 
 
 
 
 ### Removed
 ### Removed

+ 20 - 2
skills/auth-ops/SKILL.md

@@ -298,6 +298,22 @@ Set-Cookie: __Host-session=abc123;
 - [ ] Handle platform vs cross-platform authenticators
 - [ ] Handle platform vs cross-platform authenticators
 - [ ] Provide fallback auth method
 - [ ] Provide fallback auth method
 
 
+## Identity-Aware Proxy Quick Reference
+
+When authn is delegated to a proxy edge (Cloudflare Access, Google IAP, oauth2-proxy), two invariants carry the whole model:
+
+1. **Verify the assertion.** The proxy's identity header is a signed JWT — verify signature + issuer + per-application audience against the proxy's JWKS on every request. Never trust the plain email convenience headers.
+2. **Close every path around the proxy.** The header is only meaningful if the proxy is the *only* way to reach the origin (`workers_dev = false`, firewalled origin, or tunnel). An open origin makes any header forgeable.
+
+```
+Proxy edge (authn) ──JWT header──> Origin verifies JWT ──> app user lookup ──> role/scope binding
+     │                                  │ 403 on any failure     │ 403 if no row     (server-side)
+     └ IdP / OTP login, sessions        └ cached JWKS,           └ proxy admits ≠ app authorizes
+       rate limits, bot defense           refetch on unknown kid
+```
+
+Machine routes (webhooks, ingest) get Service-Auth/Bypass at the edge + bearer keys at the origin, mounted outside the human-auth middleware. Full treatment: `references/cloudflare-access.md`.
+
 ## Common Gotchas
 ## Common Gotchas
 
 
 | Gotcha | Why It's Dangerous | Fix |
 | Gotcha | Why It's Dangerous | Fix |
@@ -318,6 +334,8 @@ Set-Cookie: __Host-session=abc123;
 | Not validating JWT `aud` claim | Token meant for Service A accepted by Service B | Always validate `aud` matches your service identifier |
 | Not validating JWT `aud` claim | Token meant for Service A accepted by Service B | Always validate `aud` matches your service identifier |
 | Session fixation | Attacker sets session ID before login, then hijacks it | Regenerate session ID after authentication |
 | Session fixation | Attacker sets session ID before login, then hijacks it | Regenerate session ID after authentication |
 | Hardcoded secrets in code | Secrets leak via source control | Use environment variables or secret managers (Vault, AWS SSM) |
 | Hardcoded secrets in code | Secrets leak via source control | Use environment variables or secret managers (Vault, AWS SSM) |
+| Trusting an identity-aware proxy's plain email header | Headers are attacker-settable on any unproxied path | Verify the proxy's signed JWT (sig + issuer + audience); close every path around the proxy |
+| Auth-library middleware as the only session check | Framework middleware can be bypassed (Next.js CVE-2025-29927 class) | Re-check the session in the data-access layer / route handlers |
 
 
 ## Reference Files
 ## Reference Files
 
 
@@ -327,8 +345,8 @@ Set-Cookie: __Host-session=abc123;
 | `references/oauth2-oidc.md` | OAuth2 flows, OIDC, provider integration, social login | ~700 |
 | `references/oauth2-oidc.md` | OAuth2 flows, OIDC, provider integration, social login | ~700 |
 | `references/authorization.md` | RBAC, ABAC, ReBAC, RLS, multi-tenant, audit logging | ~600 |
 | `references/authorization.md` | RBAC, ABAC, ReBAC, RLS, multi-tenant, audit logging | ~600 |
 | `references/implementation.md` | Password hashing, MFA, rate limiting, API keys, reset flows | ~550 |
 | `references/implementation.md` | Password hashing, MFA, rate limiting, API keys, reset flows | ~550 |
-| `references/cloudflare-access.md` | Identity-aware proxies via Cloudflare Access: app/policy anatomy, JWT verification, closed-origin precondition, service auth, local dev | ~290 |
-| `references/better-auth.md` | Better Auth library: server/client setup, adapters, sessions, social login, passkey/2FA/organization plugins, Hono integration | ~220 |
+| `references/cloudflare-access.md` | Identity-aware proxies via Cloudflare Access: app/policy anatomy, token claims, JWT verification, closed-origin precondition, service auth, sessions/logout/SPA, local dev | ~330 |
+| `references/better-auth.md` | Better Auth library: server/client setup, adapters, session model, social login, plugin catalog (passkey/2FA/org/SSO), Hono integration, migration | ~240 |
 
 
 ## See Also
 ## See Also
 
 

+ 37 - 12
skills/auth-ops/references/better-auth.md

@@ -2,7 +2,7 @@
 
 
 Deep-dive reference for [Better Auth](https://www.better-auth.com) — the framework-agnostic TypeScript authentication library: owned auth (your database, your users table) with batteries included (social login, passkeys, 2FA, organizations) via a plugin system.
 Deep-dive reference for [Better Auth](https://www.better-auth.com) — the framework-agnostic TypeScript authentication library: owned auth (your database, your users table) with batteries included (social login, passkeys, 2FA, organizations) via a plugin system.
 
 
-> **Freshness note:** Better Auth moves fast — plugin names, option shapes, and adapter APIs change between minor versions. The patterns below are architectural and stable; **verify exact API signatures against the current Better Auth docs (better-auth.com/docs) before applying specifics.** Where this file and the live docs disagree, the live docs win.
+> **Freshness note:** Better Auth moves fast — plugin names, option shapes, and adapter APIs change between minor versions. The patterns below are architectural and stable, and specifics were verified against better-auth.com/docs as of 2026-08 — but **re-verify exact API signatures against the current docs before applying them.** Where this file and the live docs disagree, the live docs win. The docs ship an `llms.txt` index (`better-auth.com/llms.txt`) — fetch it to enumerate current pages before deep-diving.
 
 
 ## Where It Sits
 ## Where It Sits
 
 
@@ -30,11 +30,13 @@ Who are your users, and who should own the credential risk?
 │     ├─ Your DB, your schema, no per-MAU bill
 │     ├─ Your DB, your schema, no per-MAU bill
 │     └─ You own uptime and patching of the auth path
 │     └─ You own uptime and patching of the auth path
-├─ Compliance/enterprise-sales pressure (SOC2 checklists name the IdP),
-│  or a team with no capacity to own auth code?
+├─ Compliance/enterprise-sales pressure (the buyer's checklist names a
+│  vendor), or a team with no capacity to own auth code?
 │  └─ Hosted IdP (Auth0/Clerk/WorkOS)
 │  └─ Hosted IdP (Auth0/Clerk/WorkOS)
-│     ├─ Fastest to enterprise SSO (SAML, SCIM)
-│     └─ Costs scale per-MAU; user data lives with the vendor
+│     ├─ Someone else's pager owns the auth path
+│     ├─ Costs scale per-MAU; user data lives with the vendor
+│     └─ Note: enterprise SSO alone no longer forces this — Better Auth
+│        ships sso (SAML/OIDC) + SCIM plugins; the trade is ownership
 └─ Unusual auth model no library expresses (exotic tokens, research)?
 └─ Unusual auth model no library expresses (exotic tokens, research)?
    └─ Hand-rolled on the primitives in jwt-sessions.md / implementation.md
    └─ Hand-rolled on the primitives in jwt-sessions.md / implementation.md
@@ -111,9 +113,10 @@ Schema management is CLI-driven: the Better Auth CLI can **generate** the schema
 Better Auth's default is **database-backed sessions with a cookie** — the `jwt-sessions.md` "session" column, not the JWT column:
 Better Auth's default is **database-backed sessions with a cookie** — the `jwt-sessions.md` "session" column, not the JWT column:
 
 
 - Sign-in creates a session row; the browser holds an httpOnly, secure session cookie.
 - Sign-in creates a session row; the browser holds an httpOnly, secure session cookie.
-- Every request resolves cookie → session row → user. Revocation is immediate (delete the row); there's no stateless-token revocation problem.
-- Expiration is a sliding window (long-lived session, refreshed while active) — bounded by configurable session/expiry options.
-- **Cookie cache**: an optional short-lived signed cookie carrying the session data, so most requests skip the DB read and only re-validate against the database every few minutes. This is the latency escape hatch for serverless/edge — with immediate-revocation traded down to "within the cache window."
+- Every request resolves cookie → session row → user. Revocation is immediate (delete the row); there's no stateless-token revocation problem. The API ships revocation at three granularities — one session (`revokeSession`), all-but-current (`revokeOtherSessions`), and all (`revokeSessions`) — plus `revokeOtherSessions: true` on password change, which should be your default there.
+- Expiration is a sliding window: sessions live `expiresIn` (default 7 days) and are re-extended once older than `updateAge` (default 1 day) — so an active user never logs in again, an idle one ages out.
+- **Cookie cache** (`session.cookieCache`): an optional short-lived signed cookie carrying the session data (`enabled`, `maxAge`, an encoding `strategy`, auto-`refreshCache`, and a `version` string that bulk-invalidates all cached sessions when bumped). Most requests skip the DB read and only re-validate on cache expiry. This is the latency escape hatch for serverless/edge — with immediate-revocation traded down to "within the cache window."
+- **Secondary storage** takes over session reads by default when configured; `storeSessionInDatabase` keeps the DB copy too, and `preserveSessionInDatabase` retains revoked-session rows for audit.
 - A JWT plugin exists for handing tokens to *other* services (a separate API consuming identity), not as a replacement for the cookie session between your SPA and your server.
 - A JWT plugin exists for handing tokens to *other* services (a separate API consuming identity), not as a replacement for the cookie session between your SPA and your server.
 
 
 Server-side session access is the integration point for everything else in your app:
 Server-side session access is the integration point for everything else in your app:
@@ -149,8 +152,13 @@ Plugins are the differentiating layer. Each has a server half (routes + schema)
 | **twoFactor** | TOTP + backup codes (OTP-on-login) | Enable/verify flows, recovery codes; gate it on your risk model |
 | **twoFactor** | TOTP + backup codes (OTP-on-login) | Enable/verify flows, recovery codes; gate it on your risk model |
 | **organization** | Orgs/teams, membership, roles, invitations | The multi-tenant building block — org rows, member rows with roles, invitation email hooks; pair with your data-layer tenant scoping (`authorization.md`) — the plugin manages *membership*, your queries must still enforce *scope* |
 | **organization** | Orgs/teams, membership, roles, invitations | The multi-tenant building block — org rows, member rows with roles, invitation email hooks; pair with your data-layer tenant scoping (`authorization.md`) — the plugin manages *membership*, your queries must still enforce *scope* |
 | **admin** | User administration (list, ban, impersonate) | Impersonation should stay audited — log the real admin identity on writes |
 | **admin** | User administration (list, ban, impersonate) | Impersonation should stay audited — log the real admin identity on writes |
-| **magicLink** | Email magic-link sign-in | You send the email; the library handles token issue/verify |
-| API keys / bearer / JWT plugins | Machine callers and token handoff to other services | Keep machine routes structurally separate from human session routes (same doctrine as `cloudflare-access.md` service-auth section) |
+| **magicLink** / **emailOTP** | Email magic-link or emailed-code sign-in | You send the email; the library handles token issue/verify |
+| **sso** / **scim** | Enterprise SAML/OIDC SSO and SCIM user provisioning | The plugins that let a self-hosted Better Auth answer enterprise-IT checklists — the capability that used to force a hosted IdP |
+| **oidcProvider** / **oauthProvider** / **mcp** | Your app *issues* tokens — act as an OIDC/OAuth provider (including for MCP clients) | Turns the app into the IdP for its own satellite services |
+| **apiKey** / **bearer** / **jwt** | Machine callers and token handoff to other services | Keep machine routes structurally separate from human session routes (same doctrine as `cloudflare-access.md` service-auth section) |
+| **genericOAuth** | Any OIDC-conformant provider not built in | For long-tail IdPs |
+
+The full catalog is considerably larger (40+ official plugins: username, anonymous, phoneNumber, multiSession, oneTap, oneTimeToken, deviceAuthorization, captcha, haveIBeenPwned breached-password checks, siwe, payments integrations like stripe/polar, openAPI, test-utils, …) — enumerate the current list via the docs' llms.txt rather than from memory.
 
 
 Plugin doctrine: add the server plugin, add its client counterpart, re-run schema generation, and let the plugin own its flow end-to-end — don't hand-build a parallel 2FA/passkey path beside it.
 Plugin doctrine: add the server plugin, add its client counterpart, re-run schema generation, and let the plugin own its flow end-to-end — don't hand-build a parallel 2FA/passkey path beside it.
 
 
@@ -159,14 +167,17 @@ Plugin doctrine: add the server plugin, add its client counterpart, re-run schem
 Better Auth speaks fetch-standard `Request`/`Response`, so any framework that exposes those integrates the same way: **route `/api/auth/*` to `auth.handler`, and read the session in middleware for everything else.**
 Better Auth speaks fetch-standard `Request`/`Response`, so any framework that exposes those integrates the same way: **route `/api/auth/*` to `auth.handler`, and read the session in middleware for everything else.**
 
 
 ```typescript
 ```typescript
-// Hono (Workers/Node/Bun) — the shape, verify signatures against current docs
+// Hono (Workers/Node/Bun) — verified against the official integration docs 2026-08
 import { Hono } from 'hono';
 import { Hono } from 'hono';
 import { auth } from './auth';
 import { auth } from './auth';
 
 
 const app = new Hono();
 const app = new Hono();
 
 
+// 0. If the frontend is on another origin: CORS middleware BEFORE the routes,
+//    with credentials: true (and credentials: 'include' on the client fetch).
+
 // 1. Mount the auth routes — GET and POST both reach the handler
 // 1. Mount the auth routes — GET and POST both reach the handler
-app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw));
+app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
 
 
 // 2. Session middleware for your app routes
 // 2. Session middleware for your app routes
 app.use('/api/*', async (c, next) => {
 app.use('/api/*', async (c, next) => {
@@ -177,6 +188,8 @@ app.use('/api/*', async (c, next) => {
 });
 });
 ```
 ```
 
 
+Cross-origin cookie shape, when the SPA and API live on different hosts: same-site subdomains → enable `crossSubDomainCookies` and keep `SameSite=Lax`; genuinely different domains → `sameSite: "none"` + `secure: true` cookie attributes (and accept the third-party-cookie fragility that entails — a shared parent domain is the saner architecture, per `jwt-sessions.md`).
+
 Framework notes (details per current docs):
 Framework notes (details per current docs):
 
 
 - **Next.js**: a catch-all route handler (`app/api/auth/[...all]/route.ts`) exporting the handler's GET/POST; session via `auth.api.getSession({ headers: headers() })` in server components/actions. Treat proper session checks in data-access code — not just in `middleware.ts` — as the real gate (Next middleware alone has been bypassable; CVE-2025-29927).
 - **Next.js**: a catch-all route handler (`app/api/auth/[...all]/route.ts`) exporting the handler's GET/POST; session via `auth.api.getSession({ headers: headers() })` in server components/actions. Treat proper session checks in data-access code — not just in `middleware.ts` — as the real gate (Next middleware alone has been bypassable; CVE-2025-29927).
@@ -186,6 +199,18 @@ Framework notes (details per current docs):
 
 
 One rule regardless of framework: the auth config object lives in **one** module; handler mounting and session reads both import it. Two `betterAuth()` instances with drifted config is a subtle way to break sessions.
 One rule regardless of framework: the auth config object lives in **one** module; handler mounting and session reads both import it. Two `betterAuth()` instances with drifted config is a subtle way to break sessions.
 
 
+## Extending the User Model
+
+The user/session tables are extensible from config (`user.additionalFields`-style options): declare extra fields, re-run schema generation, and the server types pick them up; the client can infer them via the type-inference plugin so `session.user` stays end-to-end typed. Use this for *identity-adjacent* fields (display name, locale, onboarding flags). Keep *authorization* data (roles, tenant membership) in your own domain tables keyed by user id — mixing authz into the auth library's schema couples your permission model to its migrations.
+
+## Migrating In
+
+Official migration guides exist for Auth0, Clerk, NextAuth/Auth.js, Supabase Auth, and WorkOS — start there. The architectural points that make migrations tractable:
+
+- **Password hashes import.** The password hashing functions are configurable, so existing bcrypt/argon2 hashes can be verified as-is (or verified-then-rehashed on first login) instead of forcing a global reset.
+- **Users/accounts map cleanly**: exported users become `user` rows; per-provider identities become `account` rows. Social-login users need no secret material at all — only the provider linkage.
+- **Sessions don't migrate.** Plan for a one-time global re-login at cutover; communicate it.
+
 ## Operational Notes
 ## Operational Notes
 
 
 - **Secrets**: a `BETTER_AUTH_SECRET`-style signing secret plus per-provider OAuth credentials — platform secret store, never committed (see `implementation.md` on secret handling).
 - **Secrets**: a `BETTER_AUTH_SECRET`-style signing secret plus per-provider OAuth credentials — platform secret store, never committed (see `implementation.md` on secret handling).

+ 47 - 3
skills/auth-ops/references/cloudflare-access.md

@@ -1,6 +1,6 @@
 # Identity-Aware Proxies (Cloudflare Access)
 # Identity-Aware Proxies (Cloudflare Access)
 
 
-Deep-dive reference for identity-aware proxy (IAP) authentication, worked through Cloudflare Access (Zero Trust). The patterns — verify the proxy's signed identity assertion, close every path around the proxy, layer app authorization on top — generalize to any IAP (Google IAP, AWS Verified Access, Pomerium, oauth2-proxy).
+Deep-dive reference for identity-aware proxy (IAP) authentication, worked through Cloudflare Access (Zero Trust). The patterns — verify the proxy's signed identity assertion, close every path around the proxy, layer app authorization on top — generalize to any IAP (Google IAP, AWS Verified Access, Pomerium, oauth2-proxy). Access-specific facts (claims, endpoints) verified against Cloudflare docs as of 2026-08.
 
 
 ## The Model
 ## The Model
 
 
@@ -62,6 +62,33 @@ Setup sequence (order matters — the AUD tag doesn't exist until the app does):
 
 
 One application per hostname is the natural multi-tenant shape: each tenant hostname gets its own Access app, its own AUD, and its own policy set — onboarding a tenant is dashboard config plus data, no code change.
 One application per hostname is the natural multi-tenant shape: each tenant hostname gets its own Access app, its own AUD, and its own policy set — onboarding a tenant is dashboard config plus data, no code change.
 
 
+Access apps and policies are also manageable as infrastructure-as-code (Terraform `cloudflare_zero_trust_access_application` / `cloudflare_zero_trust_access_policy`) — worth it once you have more than a couple of apps, so the policy set is reviewable and reproducible.
+
+## What's in the Token
+
+Two token shapes arrive at the origin, depending on how the caller authenticated (verified against Cloudflare docs, 2026-08):
+
+**Identity-based login** (IdP or one-time PIN):
+
+| Claim | Contents |
+|-------|----------|
+| `aud` | **Array** of application AUD tags |
+| `email` | The authenticated email, verified by the IdP / OTP flow |
+| `iss` | `https://<team-domain>` |
+| `exp` / `iat` / `nbf` | Standard timing claims |
+| `type` | `app` (application token) or `org` (global session token) |
+| `sub` | Access user UUID — unique per email per account, but **regenerated** if the user is removed and re-added to the Zero Trust org |
+| `identity_nonce` | Cache key for the identity endpoint (below) |
+| `country` | Country the user authenticated from |
+| `custom` | Custom SAML attributes / OIDC claims, **best-effort only** (see warning) |
+
+**Service-token authentication** (Service Auth policy): same envelope, but `common_name` carries the service token's Client ID and `sub` is an **empty string** — there is no user. Origins can verify service-auth callers with the same JWKS + issuer + AUD check, then branch on `common_name` instead of `email`.
+
+Two claims deserve suspicion:
+
+- **`custom` is trimmed.** Access drops configured custom claims once the serialized `custom` claim exceeds roughly 1 KB — groups first, since they're usually largest. A user in many IdP groups can silently receive a token *without* their groups while colleagues keep theirs. **Never make authorization decisions on `custom`/groups claims from the JWT**; if you need full identity (all groups), call `GET /cdn-cgi/access/get-identity` on the protected hostname with the user's `CF_Authorization` cookie. Better: keep roles in your own user store (next section) and ignore `custom` entirely.
+- **`email` vs `sub` as the join key.** Email is human-meaningful and survives org remove/re-add; `sub` is opaque and doesn't. Most apps key their user store on canonicalized email — fine, as long as you canonicalize (trim + lower-case) everywhere.
+
 ## Verifying the JWT at the Origin
 ## Verifying the JWT at the Origin
 
 
 The proxy injects `Cf-Access-Jwt-Assertion` (also available as the `CF_Authorization` cookie). Verify it on **every request** — signature, issuer, and audience — against the team's public JWKS at `https://<team-domain>/cdn-cgi/access/certs`.
 The proxy injects `Cf-Access-Jwt-Assertion` (also available as the `CF_Authorization` cookie). Verify it on **every request** — signature, issuer, and audience — against the team's public JWKS at `https://<team-domain>/cdn-cgi/access/certs`.
@@ -166,7 +193,7 @@ Generalized, for any IAP:
 |------------|---------------------------|
 |------------|---------------------------|
 | Cloudflare Workers | `workers_dev = false`; routes only on protected hostnames |
 | Cloudflare Workers | `workers_dev = false`; routes only on protected hostnames |
 | Origin server behind Cloudflare | Firewall the origin to Cloudflare IP ranges + authenticated origin pulls (mTLS); otherwise anyone who finds the origin IP bypasses Access entirely |
 | Origin server behind Cloudflare | Firewall the origin to Cloudflare IP ranges + authenticated origin pulls (mTLS); otherwise anyone who finds the origin IP bypasses Access entirely |
-| `cloudflared` tunnel | Best case — the origin has no public inbound at all; only the tunnel reaches it |
+| `cloudflared` tunnel | Best case — the origin has no public inbound at all; only the tunnel reaches it. Cloudflare's docs treat JWT validation as optional for tunnel-connected origins; verify anyway — defense in depth costs one function call |
 | Google IAP / AWS ALB + OIDC | Security groups / ingress rules so only the load balancer reaches the backends; verify the signed-identity header (`x-goog-iap-jwt-assertion` etc.), not the plain email header |
 | Google IAP / AWS ALB + OIDC | Security groups / ingress rules so only the load balancer reaches the backends; verify the signed-identity header (`x-goog-iap-jwt-assertion` etc.), not the plain email header |
 | Kubernetes + oauth2-proxy / Pomerium | NetworkPolicy so app pods accept traffic only from the proxy |
 | Kubernetes + oauth2-proxy / Pomerium | NetworkPolicy so app pods accept traffic only from the proxy |
 
 
@@ -210,7 +237,7 @@ Webhooks, ingest endpoints, and machine callers can't complete a human login. Th
 
 
 | Policy type | Mechanism | Use when |
 | Policy type | Mechanism | Use when |
 |-------------|-----------|----------|
 |-------------|-----------|----------|
-| **Service Auth** | Caller presents Access **service token** headers (`CF-Access-Client-Id` / `CF-Access-Client-Secret`); Access validates them and still issues a (non-identity) JWT | The caller is yours to configure — internal services, partner systems that can send custom headers |
+| **Service Auth** | Caller presents Access **service token** headers (`CF-Access-Client-Id` / `CF-Access-Client-Secret`); Access validates them and issues a JWT whose `common_name` is the token's Client ID (see [What's in the Token](#whats-in-the-token)) — verifiable at the origin with the same JWKS check | The caller is yours to configure — internal services, partner systems that can send custom headers |
 | **Bypass** | Access waves the route through entirely (optionally restricted by IP) | Third-party webhook senders you can't give headers to (Stripe, GitHub) — their signature scheme is then the only gate |
 | **Bypass** | Access waves the route through entirely (optionally restricted by IP) | Third-party webhook senders you can't give headers to (Stripe, GitHub) — their signature scheme is then the only gate |
 
 
 **At the origin**, mount bearer-token routes **outside** the human-auth middleware — not as an `if` inside it:
 **At the origin**, mount bearer-token routes **outside** the human-auth middleware — not as an `if` inside it:
@@ -231,6 +258,19 @@ The bearer key is the real gate on these routes — treat it accordingly:
 
 
 Keeping machine routes outside `/api/*` (rather than exempting paths inside the middleware) makes the security model auditable: the route table *is* the policy.
 Keeping machine routes outside `/api/*` (rather than exempting paths inside the middleware) makes the security model auditable: the route table *is* the policy.
 
 
+**Paths that never traverse the proxy at all** deserve the same audit: Worker cron triggers, queue consumers, and service-binding calls from other Workers arrive with **no Access header** — they invoke your code from inside the platform. Don't route internal invocations through the request-auth middleware (they'd 403), and don't let shared handlers assume an authenticated user exists. Give internal entry points their own explicit identity convention (a system principal with a fixed, minimal scope) so audit trails and scoping still hold.
+
+## Sessions, Logout, and the SPA Problem
+
+Access issues two `CF_Authorization` cookies: a **global session token** on the team domain (so one login covers many apps) and a per-app **application token** on the protected hostname (the JWT your origin verifies). Session duration is configured per application; admins can revoke a user's sessions from the Zero Trust dashboard, and `https://<hostname>/cdn-cgi/access/logout` ends the session for that app — wire your app's "log out" link to it, since your origin has no session of its own to destroy.
+
+The classic operational trap is the **SPA whose Access session expires mid-use**: the page is already loaded, and a background `fetch()` to the API doesn't get a clean 401 — Access answers with a **302 redirect to the IdP login**, which the browser's CORS machinery turns into an opaque failure the SPA can't interpret. Two mitigations:
+
+- **Detect and reload.** Treat a redirected/opaque or HTML response from an API route as "session expired" and trigger a full-page navigation, letting Access run its login flow and land the user back in the app.
+- **Managed OAuth (newer Access feature).** When enabled, Access returns `401` + a `WWW-Authenticate` header pointing at RFC 8414 OAuth discovery metadata for non-browser clients, and issues **opaque** (non-JWT) access tokens via a standard authorization-code flow. This is the intended path for API clients and agents that can't follow interactive redirects — check current Cloudflare docs before building on it.
+
+Keep API responses JSON even for auth failures your *own* middleware generates (verified-JWT-but-no-user → JSON 403, never a redirect), so the only redirect source is Access itself.
+
 ## The Local-Dev Problem
 ## The Local-Dev Problem
 
 
 Only the real Access edge injects `Cf-Access-Jwt-Assertion`. `wrangler dev` / `vite dev` on localhost never sees the header, so with a fail-closed origin, local UI work gets a 403 wall. Two legitimate solutions, one trap:
 Only the real Access edge injects `Cf-Access-Jwt-Assertion`. `wrangler dev` / `vite dev` on localhost never sees the header, so with a fail-closed origin, local UI work gets a 403 wall. Two legitimate solutions, one trap:
@@ -277,6 +317,10 @@ Mixed audiences on one app is normal: an IdP Allow policy for staff plus an OTP
 | `SKIP_AUTH`-style dev flag | Ships to production eventually | Doubly gated dev stub (env var in gitignored file + loopback-only hostname), pinned by a test |
 | `SKIP_AUTH`-style dev flag | Ships to production eventually | Doubly gated dev stub (env var in gitignored file + loopback-only hostname), pinned by a test |
 | Auto-provisioning users from OTP logins | Mailbox control alone mints an account | Auto-provision only from IdP-backed staff-domain emails, audited |
 | Auto-provisioning users from OTP logins | Mailbox control alone mints an account | Auto-provision only from IdP-backed staff-domain emails, audited |
 | Case-sensitive email matching | Same person, two identities; lookup misses | Canonicalize (trim + lower-case) before every lookup and store |
 | Case-sensitive email matching | Same person, two identities; lookup misses | Canonicalize (trim + lower-case) before every lookup and store |
+| Authorizing on the JWT's `custom`/groups claims | Access trims `custom` at ~1 KB, groups first — some users silently lose the claim | Roles in your own user store; full identity via `/cdn-cgi/access/get-identity` if you must read groups |
+| SPA treats every API failure as JSON | Expired Access session → 302 to IdP → opaque CORS failure, not a 401 | Detect redirected/opaque/HTML responses and full-page reload (or use Managed OAuth for API clients) |
+| Cron/queue/service-binding handlers reuse request-auth code | Internal invocations have no Access header — either 403s or, worse, an accidental unauthenticated path | Separate internal entry points with an explicit system identity |
+| App "logout" only clears app state | The Access session survives; next request logs straight back in | Send the browser to `/cdn-cgi/access/logout` on the protected hostname |
 
 
 ## See Also
 ## See Also