1---
2title: HTTP API middleware
3description: Authenticate each request and provide a typed current user to Effect HTTP API handlers.
4---
5
6## Define the service, middleware, and protected API
7
8```typescript
9// worker.ts
10import { BetterAuth } from "@alchemy.run/better-auth";
11import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
12import { RuntimeContext } from "alchemy";
13import * as Cloudflare from "alchemy/Cloudflare";
14import * as Http from "alchemy/Http";
15import * as Context from "effect/Context";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19import * as HttpRouter from "effect/http/HttpRouter";
20import { HttpServerRequest } from "effect/http/HttpServerRequest";
21import * as HttpApi from "effect/http-api/HttpApi";
22import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
23import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
24import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
25import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware";
26
27const makeAuth = BetterAuth({
28 basePath: "/api/auth",
29 emailAndPassword: { enabled: true },
30});
31
32class Auth extends Context.Service<
33 Auth,
34 Effect.Success<typeof makeAuth>
35>()("app/Auth") {
36 static readonly layer = Layer.effect(Auth, makeAuth);
37}
38
39const User = Schema.Struct({
40 id: Schema.String,
41 name: Schema.String,
42 email: Schema.String,
43});
44
45class CurrentUser extends Context.Service<
46 CurrentUser,
47 typeof User.Type
48>()("app/CurrentUser") {}
49
50class Unauthorized extends Schema.TaggedError<Unauthorized>()(
51 "Unauthorized", {}, { httpApiStatus: 401 },
52) {}
53
54class AuthenticationUnavailable extends Schema.TaggedError<AuthenticationUnavailable>()(
55 "AuthenticationUnavailable", {}, { httpApiStatus: 503 },
56) {}
57
58class Authentication extends HttpApiMiddleware.Service<
59 Authentication,
60 { provides: CurrentUser }
61>()("app/Authentication", {
62 error: [Unauthorized, AuthenticationUnavailable],
63}) {
64 static readonly layer = Layer.effect(
65 Authentication,
66 Effect.gen(function* () {
67 const auth = yield* Auth;
68 return (httpEffect) => Effect.gen(function* () {
69 const session = yield* auth.getSession().pipe(
70 Effect.mapError(() => new AuthenticationUnavailable()),
71 Effect.catchDefect(() => Effect.fail(new AuthenticationUnavailable())),
72 );
73 if (session === null) return yield* Effect.fail(new Unauthorized());
74 return yield* Effect.provideService(httpEffect, CurrentUser, {
75 id: session.user.id,
76 name: session.user.name,
77 email: session.user.email,
78 });
79 }).pipe(Effect.provide(RuntimeContext.phantom));
80 }),
81 );
82}
83
84class PrivateApi extends HttpApiGroup.make("private")
85 .add(HttpApiEndpoint.get("me", "/api/me", { success: User }))
86 .middleware(Authentication) {}
87
88class PublicApi extends HttpApiGroup.make("public").add(
89 HttpApiEndpoint.get("health", "/api/health", {
90 success: Schema.Struct({ ok: Schema.Boolean }),
91 }),
92) {}
93
94class AppApi extends HttpApi.make("app").add(PublicApi).add(PrivateApi) {}
95
96const PrivateLive = HttpApiBuilder.group(AppApi, "private", (handlers) =>
97 handlers.handle("me", () => CurrentUser),
98);
99const PublicLive = HttpApiBuilder.group(AppApi, "public", (handlers) =>
100 handlers.handle("health", () => Effect.succeed({ ok: true })),
101);
102const HttpLive = HttpApiBuilder.layer(AppApi).pipe(
103 Layer.provide(Layer.merge(PublicLive, PrivateLive)),
104 Layer.provide(Authentication.layer),
105 Layer.provide(Http.Platform),
106);
107
108const AuthDb = Cloudflare.D1.Database("AuthDb");
109
110export default class AuthApi extends Cloudflare.Worker<AuthApi>()(
111 "AuthApi",
112 { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } },
113 Effect.gen(function* () {
114 const auth = yield* Auth;
115 const fetch = yield* HttpRouter.toHttpEffect(HttpLive);
116 return {
117 fetch: Effect.gen(function* () {
118 const request = yield* HttpServerRequest;
119 const path = request.url.split("?")[0];
120 if (path === "/api/auth" || path.startsWith("/api/auth/")) {
121 return yield* auth.fetch;
122 }
123 return yield* fetch;
124 }),
125 };
126 }).pipe(
127 Effect.provide(Auth.layer.pipe(Layer.provide(CloudflareD1(AuthDb)))),
128 ),
129) {}
130```
131
132The layer captures `Auth` once, but reads the session inside each returned request effect. It provides only that request's user to the handler; never construct a process-wide user layer or accept identity from a request body.
133
134`auth.getSession()` reads headers from the ambient Effect HTTP request. Outside that boundary, `auth.getSession(headers)` accepts a web `Headers` object.
135
136Only `null` becomes `Unauthorized` with HTTP `401`; typed lookup failures and unexpected lookup defects become `AuthenticationUnavailable` with HTTP `503`. The catch surrounds session resolution, not the downstream handler, so it cannot hide unrelated application failures.
137
138For diagnostics, `BetterAuthApiError.statusCode` is numeric and `body.code` identifies the upstream error. Log those fields rather than the complete error, whose headers and cause can contain credentials.
139
140`RuntimeContext.phantom` adapts Alchemy's runtime marker to the native HTTP middleware boundary. It does not authenticate a request or provide a session.
141
142The private group requires authentication, while the health group and Better Auth routes remain public. Dispatching to `auth.fetch` first preserves status, redirects, and `Set-Cookie` headers, including cookies written during session refresh.
143
144## Deploy the standalone example
145
146```typescript
147// alchemy.run.ts
148import * as Alchemy from "alchemy";
149import * as Cloudflare from "alchemy/Cloudflare";
150import * as Effect from "effect/Effect";
151import AuthApi from "./worker.ts";
152
153export default Alchemy.Stack(
154 "AuthMiddleware",
155 { providers: Cloudflare.providers(), state: Alchemy.localState() },
156 Effect.gen(function* () {
157 const api = yield* AuthApi;
158 return { url: api.url };
159 }),
160);
161```
162
163Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, with Cloudflare credentials configured. This stack owns its D1 database, migrations, and stable signing secret; preserve its local state.
164
165```sh
166bunx alchemy dev
167```
168
169Alchemy selects an available port and prints the origin. In another shell, enter that origin without a trailing slash after `read`:
170
171```sh
172read -r BASE_URL
173curl -i "$BASE_URL/api/me"
174curl -i "$BASE_URL/api/health"
175```
176
177The first request returns `401` with `{"_tag":"Unauthorized"}`; the second returns `200` with `{"ok":true}`. An authenticated `/api/me` response contains only `id`, `name`, and `email`, not a session token.
178
179## Keep optional authentication explicit
180
181An intentionally public endpoint can call `auth.getSession()` within its request effect and treat only `null` as anonymous. Keep lookup failures in the error channel or map them to a declared server error; do not attach required-auth middleware merely to obtain optional identity.
182
183Authentication identifies the caller; authorization still needs server-side ownership or permission checks. For session lifetime, revocation, and cookie-cache behavior, see [Better Auth's session documentation](https://better-auth.com/docs/concepts/session-management).