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:
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
| File | Responsibility |
|---|---|
alchemy.run.ts | Resources, bindings, and 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 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.