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:

Keep split authentication same-origin

The frontend owns the public hostname and a TanStack Start server route. The auth Worker remains unreachable from the public Internet. Alchemy orders both resources in one stack; for independent rollouts, deploy compatible backend changes before the frontend.

File map

FileResponsibility
alchemy.run.tsResources, bindings, and deployment graph
src/routes/auth/$.tsSame-origin transparent proxy
src/workers/auth-backend.tsAuth Worker fetch entrypoint
src/server/auth.tseffect-auth layers and HTTP handler
src/server/env.tsLazy typed cloudflare:workers environment

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";
import * as Config from "effect/Config";

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

const AuthBackend = Cloudflare.Worker("AuthBackend", {
  main: "./src/workers/auth-backend.ts",
  url: false,
  compatibility: { flags: ["nodejs_compat"] },
  env: {
    DB: Database,
    AUTH_SECRET: Config.redacted("AUTH_SECRET"),
    AUTH_PUBLIC_URL: Config.string("AUTH_PUBLIC_URL"),
  },
});

const Website = Cloudflare.Website.Vite("Website", {
  compatibility: { flags: ["nodejs_compat"] },
  env: { AUTH_BACKEND: AuthBackend },
});

export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;

Bind databases, Durable Objects, email, and auth secrets only to AuthBackend unless the frontend genuinely needs them. In Alchemy 2.0.0-beta.63, Config.redacted binds AUTH_SECRET as Cloudflare secret_text; never replace it with a literal environment value. Infer the frontend 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. Require AUTH_PUBLIC_URL as that exact serialized browser origin, including scheme and non-default port, and derive the origin policy from it. Production uses secure mode; local HTTP requires an explicit development-mode binding. Select CloudflareConnectingIp only because requests enter through this controlled Cloudflare service binding, Cloudflare overwrites that header, and the backend has no public route. IP metadata never authorizes an 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. Remove @cloudflare/vite-plugin from TanStack Start because Cloudflare.Website.Vite injects its own plugin. Wrangler can also run both Workers, although its multi-config mode is 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 public-origin configuration, migrations before traffic, and staged backend-then-frontend rollouts. Service calls have no separate Cloudflare charge, but count toward subrequest and Worker-invocation limits.

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