@auth-craft/pages-auth-proxy
v1.1.0
Published
Thin Cloudflare Pages reverse-proxy for Auth Craft — serves /{scope}/auth/* same-origin (first-party cookies) and forwards to the gateway worker (Service Binding or HTTP + X-Edge-Gate). Keeps all trust-critical logic in the gateway.
Maintainers
Readme
@auth-craft/pages-auth-proxy
Thin reverse-proxy so an app on Cloudflare Pages can serve /{scope}/auth/* on its
own origin, then forward to the Auth Craft gateway worker.
Why: when the browser only ever sees the app's own origin, the refresh cookie the
backend sets is first-party (same-site) — SameSite=Lax/Strict works, there is no
cross-site CORS, and it is immune to Safari ITP. This proxy is intentionally
thin: it never holds backend secrets. All trust-critical logic (X-Gateway-Secret,
JWT verify, rate-limit, stealth 404) stays in the gateway.
The one thing it can enforce is scope: lock the proxy to a single scope (scope:
'customer') and the frontend calls scope-less /auth/... while the proxy prepends the
scope segment. The client never picks the scope, so a customer app cannot reach
system/tenant context through it — the lock holds even against a single shared gateway
worker that derives scope from the path. (Leave scope unset for the legacy behaviour
where the client carries its own /{scope}/... segment.)
Optional: use this only for apps you deploy on Cloudflare Pages. Apps that can't (e.g. a store you don't host) keep calling the gateway cross-origin and use body token transport instead — both models work against the same gateway + backend.
Install
pnpm add @auth-craft/pages-auth-proxyCloudflare Pages usage
Add a catch-all Pages Function under /auth and lock it to this app's scope:
// functions/auth/[[path]].ts
import { createPagesAuthProxy } from '@auth-craft/pages-auth-proxy/pages';
export const onRequest = createPagesAuthProxy({ scope: 'customer' });Configure the Pages project bindings:
| Binding | Purpose |
|---------|---------|
| AUTH_GATEWAY | Service Binding to the gateway worker (preferred — in-network, lowest latency) |
| AUTH_GATEWAY_URL | Gateway public URL (fallback when no Service Binding) |
| EDGE_GATE_SECRET | Client→gateway gate, sent as X-Edge-Gate (Pages secret) |
The frontend then calls auth on its own origin, scope-less, e.g.
fetch('/auth/authenticate', { method: 'POST', credentials: 'include', ... }).
The proxy stamps the scope; the cookie set by the backend is first-party to the app.
Pairs with @auth-craft/client cookie mode
Because cookies are first-party, use the cookie provider — no IndexedDB, refresh handled
by the HttpOnly cookie:
import { createCookieProvider } from '@auth-craft/client';
const provider = createCookieProvider({
loginUrl: '/auth/authenticate',
refreshUrl: '/auth/refresh',
logoutUrl: '/auth/logout',
});Framework-agnostic core
import { createAuthProxy } from '@auth-craft/pages-auth-proxy';
const proxy = createAuthProxy({
scope: 'customer',
gatewayUrl: 'https://auth-craft-prod-customer.acme.workers.dev',
edgeGateSecret: env.EDGE_GATE_SECRET,
});
export default { fetch: (req: Request) => proxy(req) };Security notes
- Not a CORS replacement — better. Same-origin requests have no CORS at all. Site
isolation comes from first-party
SameSitecookies +HttpOnly+ the backend's CSRF Origin check (trustedOrigins), not from CORS. - Scope is enforced, not trusted. With
scopeset, the prepended segment is the lock's value; the client's path is normalized (..collapsed) before matching, so it can't smuggle another scope. A customer app physically cannot reach system/tenant — even against one shared gateway worker. Use one locked proxy per app/scope. - Keep
EDGE_GATE_SECRETin a Pages secret. With a Service Binding you can configure the gateway to trust the binding and avoid exposing the gate entirely. - The proxy preserves method, body, headers and cookies; it adds only
X-Edge-Gate.
