Просмотр исходного кода

merge: lane/auth-ops-access-betterauth

0xDarkMatter 1 месяц назад
Родитель
Сommit
ff835b16a7

+ 23 - 0
CHANGELOG.md

@@ -53,6 +53,29 @@ feature releases live in the README "Recent Updates" section.
   into `tests/check-resources.sh` (offline, PR CI) and `freshness.yml`
   (live) - it previously ran only in the skill's own suite.
 
+- **`auth-ops` skill: two new references.** `references/cloudflare-access.md`
+  — identity-aware proxies worked through Cloudflare Access: app/policy
+  anatomy (team domain, per-hostname AUD tags, IdP vs one-time-PIN policies),
+  origin-side `Cf-Access-Jwt-Assertion` verification with jose (cached JWKS,
+  kid-triggered refetch), the closed-origin trust precondition
+  (`workers_dev = false` generalized to any IAP), fail-closed identity → user
+  → scope layering, Service Auth/Bypass for bearer-token machine routes, and
+  the local-dev tunnel-or-doubly-gated-stub problem. `references/better-auth.md`
+  — the Better Auth TypeScript library at the architectural level (explicit
+  verify-against-current-docs note): server instance + client pairing, database
+  adapters and CLI schema generation, DB-backed sessions with cookie caching,
+  email/password + social providers, passkey/2FA/organization plugins, Hono
+  and framework mounting, and choosing it vs hand-rolled vs a hosted IdP.
+  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
 - **`fleetflow` skill extracted to its own repo** (`X:\Forge\fleetflow`) with

+ 35 - 8
skills/auth-ops/SKILL.md

@@ -1,12 +1,12 @@
 ---
 name: auth-ops
-description: "Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, and MFA. Use for: authentication, authorization, jwt, oauth, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, auth, cookie, csrf, cors credentials, bearer token, refresh token, oidc."
-when_to_use: "Use when implementing authentication or authorization  e.g. 'add JWT login with refresh tokens', 'set up OAuth2 + PKCE', 'choose RBAC vs ABAC', 'add passkeys or MFA'. Covers sessions, cookies, token flows, and access-control models; reach for api-design-ops for the surrounding API shape."
+description: "Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, MFA, identity-aware proxies, and Better Auth. Use for: authentication, jwt, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, cookie, csrf, bearer token, refresh token, oidc, cloudflare access, zero trust, Cf-Access-Jwt-Assertion, AUD tag, service auth, better auth."
+when_to_use: "Use when implementing authentication or authorization - e.g. 'add JWT login with refresh tokens', 'set up OAuth2 + PKCE', 'RBAC vs ABAC', 'add passkeys or MFA', 'put an app behind Cloudflare Access', 'set up Better Auth'. Covers sessions, cookies, token flows, and access-control models."
 license: MIT
 allowed-tools: "Read Write Bash"
 metadata:
   author: claude-mods
-  related-skills: security-ops, api-design-ops, postgres-ops
+  related-skills: security-ops, api-design-ops, postgres-ops, cloudflare-ops
 ---
 
 # Auth Operations
@@ -44,11 +44,17 @@ What are you building?
 │     ├─ Delegate identity to trusted providers
 │     └─ Best for: consumer apps, social login
-└─ Passwordless authentication?
-   └─ Passkeys (WebAuthn) or Magic Links
-      ├─ Passkeys: phishing-resistant, biometric/hardware
-      ├─ Magic links: email-based, time-limited
-      └─ Best for: high-security, modern UX
+├─ Passwordless authentication?
+│  └─ Passkeys (WebAuthn) or Magic Links
+│     ├─ Passkeys: phishing-resistant, biometric/hardware
+│     ├─ Magic links: email-based, time-limited
+│     └─ Best for: high-security, modern UX
+│
+└─ Internal tool / staff app with an existing IdP?
+   └─ Identity-aware proxy (Cloudflare Access)
+      ├─ Authn enforced at the edge, before your origin
+      ├─ Origin verifies the proxy's signed JWT (never a bare header)
+      └─ Best for: admin panels, partner portals, not consumer signup
 ```
 
 ## JWT Quick Reference
@@ -292,6 +298,22 @@ Set-Cookie: __Host-session=abc123;
 - [ ] Handle platform vs cross-platform authenticators
 - [ ] 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
 
 | Gotcha | Why It's Dangerous | Fix |
@@ -312,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 |
 | 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) |
+| 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
 
@@ -321,9 +345,12 @@ Set-Cookie: __Host-session=abc123;
 | `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/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, 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
 
 - **security-ops** - Broader security patterns: OWASP, headers, input validation, encryption
 - **api-design-ops** - API design including authentication endpoints, rate limiting
 - **postgres-ops** - Row-level security (RLS) policies for database authorization
+- **cloudflare-ops** - Workers runtime, wrangler config, secrets, deploy mechanics behind an Access-fronted origin

+ 242 - 0
skills/auth-ops/references/better-auth.md

@@ -0,0 +1,242 @@
+# Better Auth (TypeScript)
+
+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, 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
+
+| Approach | You own | They own | Examples |
+|----------|---------|----------|----------|
+| **Hand-rolled** (Lucia-style: library-assisted sessions, you write the flows) | Everything — flows, tokens, edge cases, security hardening | Nothing | Lucia (now a learning resource), custom JWT/session code per `jwt-sessions.md` |
+| **Auth library, your DB** | Data, deployment, customization | Flow implementation, plugin features, security patches | **Better Auth**, Auth.js/NextAuth |
+| **Hosted IdP / auth SaaS** | Integration code | Everything else — including your user data | Auth0, Clerk, WorkOS, Supabase Auth, Cognito |
+| **Identity-aware proxy** | App authorization | Authentication entirely, at the network edge | Cloudflare Access (see `cloudflare-access.md`) |
+
+Better Auth's pitch: the feature ceiling of a hosted IdP (social login, passkeys, 2FA, orgs/multi-tenant, magic links) without surrendering user data, per-MAU pricing, or the login UX to a third party. Users live in **your** database in **your** schema (extended by plugins), and every flow runs in your process.
+
+### When to choose which
+
+```
+Who are your users, and who should own the credential risk?
+│
+├─ Staff/partners behind an existing IdP, internal tools?
+│  └─ Identity-aware proxy (Cloudflare Access) — don't build login at all
+│     └─ see cloudflare-access.md
+│
+├─ Consumer/SaaS product, TypeScript stack, want to own user data?
+│  └─ Better Auth
+│     ├─ Full-featured via plugins (passkeys, 2FA, orgs, magic links)
+│     ├─ Your DB, your schema, no per-MAU bill
+│     └─ You own uptime and patching of the auth path
+│
+├─ 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)
+│     ├─ 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)?
+   └─ Hand-rolled on the primitives in jwt-sessions.md / implementation.md
+      └─ Budget for the hardening checklist you inherit (rate limits,
+         enumeration, rotation, reset flows — see implementation.md)
+```
+
+The Lucia lesson: its maintainers deprecated the library and turned it into a tutorial, concluding that a thin session library saves too little over hand-rolling while still hiding the parts you need to understand. The ecosystem's answer to "I want auth *implemented*, not just assisted" is a full-featured library — which in TypeScript today usually means Better Auth.
+
+## Core Setup: Server Instance + Client
+
+Two halves, mirrored: a **server instance** (`betterAuth(...)`) that owns the database and exposes an HTTP handler + server API, and a **client** (`createAuthClient(...)`) whose methods call those endpoints. Plugins come in pairs too — a server plugin and its client counterpart.
+
+```typescript
+// server: auth.ts — the single source of truth for auth config
+import { betterAuth } from 'better-auth';
+
+export const auth = betterAuth({
+  database: /* adapter — see next section */,
+  emailAndPassword: {
+    enabled: true,
+    // hand the email-sending to YOUR mailer; Better Auth calls these hooks
+    sendResetPassword: async ({ user, url }) => { /* send url to user.email */ },
+  },
+  socialProviders: {
+    github: {
+      clientId: process.env.GITHUB_CLIENT_ID!,
+      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
+    },
+  },
+  plugins: [/* passkey(), twoFactor(), organization(), ... */],
+});
+```
+
+```typescript
+// client: auth-client.ts — framework-specific import path
+// (react / vue / svelte / solid / vanilla variants exist)
+import { createAuthClient } from 'better-auth/react';
+
+export const authClient = createAuthClient({
+  baseURL: 'https://app.example.com', // where the auth routes are mounted
+  plugins: [/* client halves of the server plugins */],
+});
+
+// usage: authClient.signIn.email({...}), authClient.signIn.social({provider: 'github'}),
+// authClient.signUp.email({...}), authClient.signOut(), authClient.useSession() (hook)
+```
+
+The server instance exposes:
+
+| Surface | What | Use for |
+|---------|------|---------|
+| `auth.handler` | A `Request => Response` fetch-style handler serving all auth routes (conventionally mounted at `/api/auth/*`) | Wiring into your framework's router |
+| `auth.api.*` | Server-side callable endpoints (e.g. get the session from request headers) | Middleware, server components, RSC/loader code |
+
+Mount the handler once; never build login/logout routes by hand next to it.
+
+## Database Adapters
+
+Better Auth owns its tables (user, session, account, verification — plus plugin tables) inside **your** database, through an adapter:
+
+| Adapter family | Notes |
+|----------------|-------|
+| Kysely-based direct connections | Postgres / MySQL / SQLite via the built-in layer |
+| ORM adapters | Drizzle, Prisma, MongoDB adapters wrap your existing ORM instance |
+| Serverless/edge databases | Work through the same adapters (e.g. D1 via Drizzle) — check current docs for your combination |
+
+Schema management is CLI-driven: the Better Auth CLI can **generate** the schema (migration files / ORM schema for your adapter) and, for direct connections, **migrate** the database. Plugins add columns/tables; re-run generate after adding one. Treat the generated schema as owned artifacts in your repo — review and commit them like any migration.
+
+**Secondary storage** (optional): a KV/Redis-style store can be configured alongside the database for hot data (sessions, rate-limit counters), keeping per-request reads off the primary DB. On serverless platforms this is the natural home for session lookups.
+
+## Session Model
+
+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.
+- 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.
+
+Server-side session access is the integration point for everything else in your app:
+
+```typescript
+// in middleware / a loader / an RSC — shape per current docs
+const session = await auth.api.getSession({ headers: request.headers });
+if (!session) return unauthorized();
+// session.user is YOUR user row (plus plugin fields) — feed it to your
+// authorization layer (roles, tenant scope) exactly as in authorization.md
+```
+
+The same fail-closed layering as every other auth source applies: Better Auth authenticates; your role/scope binding on `session.user` authorizes. Never trust identity from a request body.
+
+## Email/Password + Social Providers
+
+**Email/password** is a config flag plus hooks. Better Auth implements the flows (signup, sign-in, verification, password reset with single-use expiring tokens, password hashing) and calls *your* functions to actually send email — it deliberately does not ship a mailer. Turn on email verification for real deployments; wire the reset/verification senders to your provider (Resend, SES, Cloudflare Email, …). The hardening in `implementation.md` (rate limiting, enumeration-safe responses) is largely handled by the library — configuration, not reimplementation.
+
+**Social providers** are config entries per provider (OAuth2/OIDC under the hood — the flows from `oauth2-oidc.md`, implemented for you):
+
+- Built-ins for the majors (Google, GitHub, Apple, Microsoft, Discord, …) plus a **generic OAuth plugin** for any OIDC-conformant provider.
+- Redirect URI is derived from where the handler is mounted (`<baseURL>/api/auth/callback/<provider>` by convention) — register that with the provider.
+- **Account linking** connects a social login to an existing user with the same verified email (configurable — auto-link only trusted, email-verifying providers; see gotchas).
+- The `account` table stores the provider linkage and tokens per user — one user, many linked providers.
+
+## Plugins: Passkeys, 2FA, Organizations
+
+Plugins are the differentiating layer. Each has a server half (routes + schema) and a client half (typed methods). Representative set — check current docs for the full catalog:
+
+| Plugin | Gives you | Notes |
+|--------|-----------|-------|
+| **passkey** | WebAuthn registration + sign-in | The `implementation.md` passkey checklist, implemented: challenge handling, credential storage, multiple credentials per user |
+| **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* |
+| **admin** | User administration (list, ban, impersonate) | Impersonation should stay audited — log the real admin identity on writes |
+| **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.
+
+## Middleware Integration (incl. Hono)
+
+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
+// Hono (Workers/Node/Bun) — verified against the official integration docs 2026-08
+import { Hono } from 'hono';
+import { auth } from './auth';
+
+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
+app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
+
+// 2. Session middleware for your app routes
+app.use('/api/*', async (c, next) => {
+  const session = await auth.api.getSession({ headers: c.req.raw.headers });
+  if (!session) return c.json({ error: 'unauthorized' }, 401);
+  c.set('user', session.user);      // then bind roles/tenant scope server-side
+  await 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):
+
+- **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).
+- **SvelteKit / Nuxt / SolidStart / TanStack Start / Astro / Remix**: same two moves via each framework's handler-mounting idiom.
+- **Express/Fastify (Node)**: adapt Node req/res to fetch `Request` (helpers exist — `toNodeHandler` or the framework's own adapter).
+- **Serverless/edge (Workers)**: works — pair with an edge-resident DB or secondary storage so session reads aren't cross-region; enable cookie caching.
+
+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
+
+- **Secrets**: a `BETTER_AUTH_SECRET`-style signing secret plus per-provider OAuth credentials — platform secret store, never committed (see `implementation.md` on secret handling).
+- **Rate limiting**: built-in on auth endpoints (tighter on sign-in/sign-up) — configure storage (memory/DB/secondary) appropriately for multi-instance deployments; memory-only limits don't coordinate across serverless instances.
+- **Hooks/lifecycle**: before/after hooks on auth events (user created, session created, …) are the place for provisioning side-effects — creating a tenant on signup, audit rows, welcome email. Keep them idempotent.
+- **Upgrades**: fast-moving library — read the changelog on every minor bump, re-run schema generation after upgrading or adding plugins, and keep an integration test that exercises sign-up → sign-in → session → sign-out against a real database.
+
+## Common Gotchas
+
+| Gotcha | Why It's Dangerous | Fix |
+|--------|--------------------|-----|
+| Pinning API snippets from memory or old tutorials | The API surface shifts between minors; stale option names fail silently or at type-check | Verify against current docs; keep Better Auth in one module so upgrades touch one file |
+| Building login/reset routes beside the mounted handler | Two auth paths, one hardened, one yours | Everything auth goes through `auth.handler` / `auth.api` / plugins |
+| Skipping schema regeneration after adding a plugin | Runtime errors on missing tables/columns | Re-run CLI generate/migrate on every plugin add and version bump |
+| Auto-linking accounts from providers with unverified emails | Account takeover: attacker registers your email at a lax provider, links into your account | Restrict auto-linking to trusted, email-verifying providers; require verification otherwise |
+| Auth checks only in framework middleware (Next.js) | Middleware can be bypassed (CVE-2025-29927-class bugs) | Check the session in the data-access layer / route handlers too |
+| DB-per-request session reads on edge/serverless with a distant DB | Latency tax on every request | Cookie cache and/or secondary storage near the compute |
+| Treating org membership as data scoping | Membership says who's *in* the org; queries still need tenant filters | Enforce tenant scope at the repository/query layer (`authorization.md`) |
+| Memory rate-limit storage on multi-instance deploys | Each instance counts separately — limits are ~N× looser | Database or secondary-storage backed rate limiting |
+| Secrets in client-reachable config | Provider secrets leak to the bundle | Server module only; client gets nothing but `baseURL` and plugin client halves |
+| Ignoring the mailer hooks (no verification/reset emails wired) | Signup verification and password reset silently can't complete | Wire `sendResetPassword` / verification senders to a real mailer before launch |
+
+## See Also
+
+- `jwt-sessions.md` — the session/cookie model Better Auth implements (and when raw JWTs fit instead)
+- `oauth2-oidc.md` — the flows underneath `socialProviders`
+- `implementation.md` — password hashing, MFA, rate limiting fundamentals (what the library is doing for you)
+- `authorization.md` — roles/tenant scoping to layer on `session.user`
+- `cloudflare-access.md` — the delegate-it-entirely alternative for staff/internal apps

+ 330 - 0
skills/auth-ops/references/cloudflare-access.md

@@ -0,0 +1,330 @@
+# 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). Access-specific facts (claims, endpoints) verified against Cloudflare docs as of 2026-08.
+
+## The Model
+
+An identity-aware proxy moves *authentication* out of your application and into an enforcement edge in front of it:
+
+```
+┌─────────┐     ┌──────────────────────┐      ┌───────────────┐
+│ Browser  │───>│ Identity-aware proxy  │────> │ Origin (your  │
+│          │    │ (Cloudflare Access)   │      │ app/Worker)   │
+│          │    │ - IdP login (SSO)     │ JWT  │ - verify JWT  │
+│          │    │ - OTP for externals   │ hdr  │ - user lookup │
+│          │    │ - session mgmt        │      │ - roles/scope │
+│          │    │ - bot mitigation      │      │               │
+└─────────┘     └──────────────────────┘      └───────────────┘
+```
+
+What the proxy owns: login UI, IdP federation, OTP delivery, session lifetime, rate limiting, bot mitigation. What your origin still owns: **verifying the proxy's assertion, mapping identity to an application user, and every authorization decision.**
+
+The proxy asserts identity to the origin via a signed JWT in a request header — for Access, `Cf-Access-Jwt-Assertion`. Everything below follows from one question: *can you trust that header?* Answer: only after cryptographic verification, and only if the proxy is the sole path to the origin.
+
+### When an IAP is the right call
+
+| Situation | Fit |
+|-----------|-----|
+| Internal tools / admin panels for staff with an existing IdP (Google Workspace, Entra) | Excellent — SSO for free, no credential storage |
+| Small external audiences (partners, counterparties) who need occasional access | Good — one-time PIN policies avoid provisioning them in your IdP |
+| Security-critical app where you don't want to own login hardening (rate limits, CSRF, bot defense, OTP delivery) | Excellent — the edge is hardened and audited for you |
+| Consumer-facing product with self-signup, thousands of users | Poor — use an auth library or hosted IdP (see `better-auth.md`, `oauth2-oidc.md`) |
+| You need a fully branded login experience | Weak — the proxy's hosted login page is minimally themeable |
+
+## Access Application + Policy Anatomy
+
+An Access deployment has four moving parts:
+
+| Part | What it is | Example |
+|------|------------|---------|
+| **Team domain** | Your Zero Trust tenant; also the JWT issuer | `example.cloudflareaccess.com` → issuer `https://example.cloudflareaccess.com` |
+| **Application** | A "self-hosted" app bound to a hostname (or hostname + path) | Domain `app.example.com` |
+| **Policies** | Ordered Allow/Deny/Bypass/Service-Auth rules on the application | Allow staff, Allow named partners |
+| **AUD tag** | Per-application audience identifier; goes in the JWT's `aud` claim | Copied from the dashboard into origin config |
+
+Typical policy set for an app with staff + external users:
+
+1. **Allow — staff:** identity provider login (e.g. Google), include rule "emails ending in `@example.com`".
+2. **Allow — external counterparties:** login method **One-time PIN**, include rule listing the specific partner emails.
+
+Both policies authenticate; neither authorizes. The emails Access admits must still map to rows/roles in your application's user store (see [Fail-Closed Layering](#fail-closed-layering) below).
+
+Setup sequence (order matters — the AUD tag doesn't exist until the app does):
+
+```
+1. Zero Trust dashboard -> Access -> Applications -> Add -> Self-hosted
+2. Set the application domain (the exact hostname the origin serves)
+3. Add Allow policies (IdP for staff, OTP for externals)
+4. Copy the application AUD tag
+5. Configure the origin with team domain + AUD
+   (Workers: wrangler secret put CF_ACCESS_AUD, then redeploy)
+```
+
+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
+
+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`.
+
+```typescript
+// Cloudflare Access JWT verification (Workers / jose).
+// Never trust the Cf-Access-Jwt-Assertion header without verifying
+// signature, issuer, and audience.
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+export interface AccessConfig {
+  /** Access team domain, e.g. "example.cloudflareaccess.com" (no scheme). */
+  teamDomain: string;
+  /** The Access application AUD tag for this hostname. */
+  aud: string;
+}
+
+export class AccessAuthError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'AccessAuthError';
+  }
+}
+
+// One JWKS per team domain, cached for the process/isolate lifetime.
+// createRemoteJWKSet caches keys and refetches on an unknown `kid`,
+// so Access key rotation does not cause a 403 storm.
+const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
+
+function jwksFor(issuer: string) {
+  let jwks = jwksByIssuer.get(issuer);
+  if (!jwks) {
+    jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
+    jwksByIssuer.set(issuer, jwks);
+  }
+  return jwks;
+}
+
+/**
+ * Verify an Access JWT and return the caller's email (lower-cased).
+ * Throws AccessAuthError on any failure — missing token, bad signature,
+ * wrong issuer/audience, expired, or no email claim. Map to 403.
+ */
+export async function verifyAccessJwt(
+  token: string | undefined,
+  config: AccessConfig,
+): Promise<string> {
+  if (!token) throw new AccessAuthError('missing Access token');
+  if (!config.teamDomain || !config.aud) {
+    throw new AccessAuthError('Access is not configured');
+  }
+
+  const issuer = `https://${config.teamDomain}`;
+  try {
+    const { payload } = await jwtVerify(token, jwksFor(issuer), {
+      issuer,
+      audience: config.aud,
+      algorithms: ['RS256'],
+    });
+    const email =
+      typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
+    if (!email) throw new AccessAuthError('Access token has no email claim');
+    return email;
+  } catch (err) {
+    if (err instanceof AccessAuthError) throw err;
+    throw new AccessAuthError(
+      `Access token verification failed: ${(err as Error).message}`,
+    );
+  }
+}
+```
+
+The details that matter:
+
+| Detail | Why |
+|--------|-----|
+| Verify `iss` against the team domain | Rejects tokens signed by any *other* Access tenant — the JWKS URL alone doesn't pin the tenant |
+| Verify `aud` against **this application's** AUD tag | A valid token for a different app on the same team must not open this one. With per-hostname apps, resolve the expected AUD from the request hostname |
+| Pin `algorithms: ['RS256']` | Never accept whatever `alg` the token declares |
+| Cache the JWKS per process/isolate | The JWKS endpoint is remote; fetching it per-request adds latency and a availability dependency |
+| Refetch on unknown `kid` (jose's `createRemoteJWKSet` does this) | Access rotates signing keys; without kid-triggered refetch, rotation causes a spurious-403 storm until the cache expires |
+| Lower-case + trim the email before lookup | Email comparison against your user store must be canonical — `Alice@Example.com` and `alice@example.com` are the same person |
+| Fail closed: any error → 403 | Misconfigured team domain or missing AUD must deny, never pass through |
+
+## THE Trust Precondition: the Proxy Must Be the Only Path
+
+**An identity-aware proxy header is worthless if the origin is directly reachable.** The header is just a request header; anyone who can reach the origin without going through the proxy can set it themselves. Signature verification protects against *forged tokens*, not against a *forged unverified header* on a code path that skips verification — and more subtly, an open origin invites "trust the header, skip the JWT" shortcuts that turn into real vulnerabilities.
+
+On Cloudflare Workers, closing the origin means:
+
+```toml
+# wrangler.toml / wrangler.jsonc
+workers_dev = false   # no <name>.workers.dev origin exists
+# routes/custom domains: only the Access-protected hostname(s)
+```
+
+With `workers_dev = false` and routes only on Access-protected hostnames, there is no unproxied URL on which the header could be forged — the classic prototype bug (trusting `Cf-Access-*` headers, then discovering anyone hitting the `*.workers.dev` origin could claim any email and become admin) is structurally impossible.
+
+Generalized, for any IAP:
+
+| Deployment | How to close the side door |
+|------------|---------------------------|
+| 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 |
+| `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 |
+| Kubernetes + oauth2-proxy / Pomerium | NetworkPolicy so app pods accept traffic only from the proxy |
+
+If you cannot close the side door, JWT verification is your only line of defense — which is exactly why you verify the JWT even when you *think* the origin is closed. Defense in depth: closed origin **and** verified assertion.
+
+## Fail-Closed Layering
+
+The proxy authenticates; it must never implicitly authorize. Layer strictly, each step failing closed:
+
+```
+1. Verify the Access JWT            -> verified email, or 403
+2. Look up the email in YOUR users  -> application user + role, or 403
+   store (active users only)
+3. Bind role/tenant scope           -> scoped data access, enforced
+   server-side                         server-side on every query
+```
+
+Rules for the layering:
+
+- **Never trust identity from a request body, query param, or client-set header.** The only identity input is the verified JWT. A `{"email": ...}` field in a POST body is display data at most.
+- **Access admitting an email ≠ the email having an account.** Policies are coarse (a whole staff domain, a PIN list that lags reality). The user-store lookup is the fine-grained gate: no active row → 403, regardless of a valid JWT.
+- **Roles and scopes live in your database, never in the assertion.** The Access JWT tells you *who*; your `users` table tells you *what they may do*. (Access can forward IdP group claims, but treating those as app roles couples your authorization to dashboard/IdP config — keep authorization in the app.)
+- **Enforce scope at the data layer**, not per-handler: resolve `{user, role, tenant}` once in middleware, then have every query go through a repository that is constructed with — and cannot escape — that scope. See `authorization.md` for the RBAC/scoping patterns.
+
+### Auto-provisioning vs explicit user rows
+
+Two workable enrollment models, often combined:
+
+| Model | How | Use for |
+|-------|-----|---------|
+| **Auto-provision on first login** | A verified email at the org's staff domain with no user row gets a real, **audited** user row created on first request (e.g. as an admin of their tenant) | Staff — the IdP domain is the trust anchor; removes a bootstrap/onboarding step |
+| **Explicit rows for everyone else** | External emails (OTP policies) must be pre-created by an admin; unknown email → 403 | Partners, clients, contractors — OTP proves mailbox control, nothing more |
+
+If you auto-provision, gate it on the *IdP-backed* staff domain only (never on OTP logins), write an audit record for the provisioning event, and create a real row — don't synthesize a virtual admin per request.
+
+## Service Auth and Bypass: Non-Human Routes
+
+Webhooks, ingest endpoints, and machine callers can't complete a human login. They need to pass the edge *and* skip your session/JWT middleware — two separate allowances that must both be made deliberately:
+
+**At the edge**, add a separate Access application (or path-scoped app) for the machine path, e.g. `app.example.com/webhooks/*`, with one of:
+
+| 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 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 |
+
+**At the origin**, mount bearer-token routes **outside** the human-auth middleware — not as an `if` inside it:
+
+```
+/api/*        -> Access JWT middleware -> user lookup -> scoped handlers
+/webhooks/*   -> bearer-key check      -> pinned-scope handlers   (never sees JWT middleware)
+/ingest/*     -> bearer-key check      -> pinned-scope handlers
+/health       -> unauthenticated       (the only fully open route)
+```
+
+The bearer key is the real gate on these routes — treat it accordingly:
+
+- Long random secrets, stored hashed or in the platform secret store, rotatable.
+- Constant-time comparison.
+- Pin each key to a scope/tenant server-side (e.g. `tenantId:key` — a request whose body claims a different tenant than its key is rejected).
+- For third-party webhooks behind a Bypass policy, verify the sender's HMAC signature (Stripe-Signature etc.) — Bypass means the edge does nothing for you.
+
+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
+
+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:
+
+| Approach | How | Trade-off |
+|----------|-----|-----------|
+| **Tunnel behind Access** | `cloudflared` tunnel from a dev hostname (in the Zero Trust dashboard) to localhost; put an Access app + policy on the dev hostname | Real end-to-end auth, real header; needs dashboard setup and a login per session |
+| **Deliberate auth stub** | A dev-only middleware branch takes the identity from a local env var (e.g. `DEV_AUTH_EMAIL` in a gitignored `.dev.vars`) instead of a JWT | Fast; must be *structurally* incapable of shipping (see below) |
+| ~~Disable the check~~ | `if (env.SKIP_AUTH) return next()` | **Never.** A boolean that turns auth off is one bad deploy away from an open production origin |
+
+A stub that can't ship is **doubly gated** and changes nothing downstream:
+
+1. Active only when the dev env var is set — and the var lives in a gitignored local file, never in deployed secrets/vars.
+2. Active only when the request hostname is loopback (or another hostname no deployed instance can be reached on) — so even a leaked var is inert in production.
+3. It substitutes the *identity input only*: the stubbed email still goes through the same user lookup, role binding, and scoping as a verified JWT. It can assume an existing user, never mint one (exclude it from auto-provisioning).
+4. Pin it with a test asserting it is inert when the gates are absent.
+
+## OTP vs IdP Policies
+
+| | IdP login (Google, Entra, SAML) | One-time PIN (email OTP) |
+|---|---|---|
+| Proves | Account in an org directory (+ the IdP's own MFA/device posture) | Control of a mailbox, at that moment |
+| Provisioning | None beyond the include rule (domain/group) | Someone must list the emails in the policy *and* usually in your user store |
+| Offboarding | Automatic — IdP account disabled → login dies | Manual — remove from policy and user store; the mailbox outlives the relationship |
+| Assurance | Higher (org-managed identity) | Lower (mailbox compromise = access) |
+| Fit | Staff, anyone in your directory | External counterparties too few/transient to federate |
+| Privileges | Can justify elevated roles, auto-provisioning | Least privilege; never auto-provision from OTP |
+
+Mixed audiences on one app is normal: an IdP Allow policy for staff plus an OTP Allow policy enumerating externals. Keep the *role ceiling* of OTP identities low in your app-level authorization regardless of what the edge admits.
+
+## Common Gotchas
+
+| Gotcha | Why It's Dangerous | Fix |
+|--------|--------------------|-----|
+| Trusting `Cf-Access-Authenticated-User-Email` (or any plain identity header) without JWT verification | Headers are attacker-settable on any unproxied path | Verify `Cf-Access-Jwt-Assertion` cryptographically; ignore the convenience headers |
+| `workers_dev` left `true` (or origin IP reachable) | An unproxied origin exists — forged headers, no Access at all | `workers_dev = false`; firewall/tunnel non-Workers origins to the proxy only |
+| Validating signature but not `aud` | A valid token for *another* app on your team opens this one | Verify the per-application AUD tag, resolved per hostname |
+| Validating signature but not `iss` | A token from a different Access tenant could pass | Pin issuer to `https://<your-team-domain>` |
+| Fetching the JWKS on every request | Latency + hard availability dependency on the certs endpoint | Cache per process/isolate; refetch on unknown `kid` |
+| No `kid`-triggered refetch | Access key rotation → spurious 403 storm until cache expiry | Use jose's `createRemoteJWKSet` (does this) or replicate the behaviour |
+| Treating an Access-admitted email as an authorized user | Policies are coarse; ex-partners linger in PIN lists | App-level user lookup is mandatory; no row → 403 |
+| Bypass/Service-Auth path with a weak or unpinned bearer key | The edge is open there; the key is the only gate | Long random keys, hashed at rest, constant-time compare, tenant-pinned |
+| Webhook route inside the session middleware with an exemption flag | Exemption logic rots; one refactor away from exposed | Mount machine routes structurally outside the human-auth middleware |
+| `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 |
+| 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
+
+- `jwt-sessions.md` — JWT structure, claims, and verification fundamentals
+- `authorization.md` — RBAC/tenant scoping to layer on top of the verified identity
+- `better-auth.md` — when you own the login flow instead of delegating it to a proxy
+- **cloudflare-ops** skill — Workers runtime, wrangler config, secrets, deploy mechanics

+ 2 - 0
skills/cloudflare-ops/SKILL.md

@@ -26,6 +26,8 @@ Cloudflare Workers + Wrangler: runtime patterns, bindings, local dev, secrets, d
 | [references/deploy-and-cicd.md](references/deploy-and-cicd.md) | `wrangler deploy`, environments, secrets, Workers Builds, GitHub Actions + OIDC/API-token, gradual deployments, rollbacks, observability |
 | [assets/wrangler.jsonc.template](assets/wrangler.jsonc.template) | Commented, current `wrangler.jsonc` covering all common bindings + assets |
 
+> Access / Zero Trust auth patterns (verifying `Cf-Access-Jwt-Assertion`, AUD tags, service auth, closed origins) → **auth-ops** skill, `references/cloudflare-access.md`.
+
 ## Workers vs Pages Decision
 
 Cloudflare added static-asset hosting to Workers; a single Worker now serves a static site, a full-stack app, or an API + SPA. **For new projects, default to Workers with static assets.** Pages still works and isn't deprecated, but Workers has the broader, faster-moving feature set (Durable Objects, Cron Triggers, Queues, richer observability) and is where Cloudflare's investment goes.