effect-auth

Split Frontend and Auth Workers

Put an auth Worker behind a same-origin frontend route with a Cloudflare Service Binding.

Start with one Worker. Split auth only when independent deployments, ownership, resource isolation, or reuse by several Workers outweigh another deployment boundary. Cloudflare Service Bindings make the split inexpensive, but they do not remove operational coupling.

When separation is justified, keep auth same-origin from the browser's perspective:

                         public origin                    private hop
┌─────────┐  /auth/*  ┌─────────────────┐  AUTH_BACKEND  ┌─────────────┐
│ browser │ ─────────>│ frontend Worker │ ──────────────>│ auth Worker │
└─────────┘ <─────────└─────────────────┘ <──────────────└─────────────┘
              Set-Cookie / JSON             Service Binding

The frontend owns the public hostname and a TanStack Start server route. The auth Worker can remain unreachable from the public Internet. They are separate Worker resources and deployments; deploy the auth target before a caller that first binds it, and make backend changes compatible with the currently deployed frontend.

File map

alchemy.run.ts                    resources, bindings, deployment graph
src/routes/auth/$.ts              same-origin transparent proxy
src/workers/auth-backend.ts       auth Worker fetch entrypoint
src/server/auth.ts                effect-auth layers and HTTP handler
src/server/env.ts                 lazy typed cloudflare:workers env access

See the maintained tanstack-cloudflare-auth-split example for the complete application.

Declare two Workers

In Alchemy v2, resources assigned to another Worker's environment become bindings. This compact version follows the example's Effect API; consult Alchemy's current bindings and Cloudflare Worker guide when upgrading Alchemy.

import * as Cloudflare from "alchemy/Cloudflare";

const Database = Cloudflare.D1.Database("AuthDatabase", {
  migrationsDir: "./migrations",
});

const AuthBackend = Cloudflare.Worker("AuthBackend", {
  main: "./src/workers/auth-backend.ts",
  env: {
    DB: Database,
    AUTH_SECRET: process.env.AUTH_SECRET!,
    AUTH_PUBLIC_URL: process.env.AUTH_PUBLIC_URL!,
    AUTH_ALLOWED_ORIGINS: process.env.AUTH_ALLOWED_ORIGINS!,
  },
});

const Website = Cloudflare.Website.Vite("Website", {
  env: { AUTH_BACKEND: AuthBackend },
});

Bind databases, Durable Objects, email, and auth secrets only to AuthBackend unless the frontend genuinely needs them. Treat secrets with the mechanism required by your pinned Alchemy version; do not commit them. Type the frontend binding as AUTH_BACKEND: Service (or infer its environment from the resource declaration).

Forward without rebuilding

import { createFileRoute } from "@tanstack/react-router";
import { env } from "../../server/env";

export const Route = createFileRoute("/auth/$")({
  server: {
    handlers: {
      GET: ({ request }) => env.AUTH_BACKEND.fetch(request),
      POST: ({ request }) => env.AUTH_BACKEND.fetch(request),
    },
  },
});

Pass the original Request and return the original Response. Do not parse/re-encode bodies, copy headers into plain objects, rewrite the URL, or manufacture a response. This preserves streaming, Cookie, Origin, Cloudflare metadata and every Set-Cookie header. The browser therefore calls /auth/* normally, and createAuthClient() needs neither a cross-origin base URL nor CORS configuration.

The auth backend sees the public frontend URL because the original request is forwarded. Configure AUTH_PUBLIC_URL and AUTH_ALLOWED_ORIGINS to that exact browser origin, including scheme and non-default port. If request metadata enables trustProxyHeaders, do so only because requests enter through your controlled Cloudflare binding; never expose the backend directly while trusting client-supplied forwarding headers. Validate which Cloudflare headers your metadata policy consumes, and test the resulting client IP and origin.

Develop, test, and ship

Alchemy's alchemy dev runs the resource graph and local bindings; the example uses bun run dev:local. Raw vite dev does not supply Cloudflare bindings. Wrangler can also run both Workers, although its multi-config mode is documented as experimental.

Test the public frontend URL, not the backend: sign in, read the session, refresh/logout, and assert multiple Set-Cookie values survive. Cover missing binding/backend failures and unsafe requests with missing, allowed, and hostile Origin. Production should use HTTPS, secure cookies, real secrets and email, exact WebAuthn/public-origin configuration, migrations before traffic, and staged backend-then-frontend rollouts. Service calls count as Worker invocations/subrequests, so monitor limits and failures.

A direct cross-origin auth hostname instead adds CORS, credentialed fetches, cookie SameSite/domain constraints, preflights, and a larger exposed surface. Prefer this same-origin route, or keep auth in the frontend Worker until a concrete reason makes the split worthwhile.

On this page