Part 4: Protect API endpoints
Continue from Part 3. Protect a new /api/me endpoint without blocking sign-in or the public health check.
Describe the current user
Section titled “Describe the current user”import * as Context from "effect/Context";import * as Schema from "effect/Schema";
export const User = Schema.Struct({ id: Schema.String, name: Schema.String, email: Schema.String,});
export class CurrentUser extends Context.Service< CurrentUser, typeof User.Type>()("app/CurrentUser") {}Handlers receive only the user fields they need. Session tokens stay out of API responses.
Declare an unauthorized response
Section titled “Declare an unauthorized response”import * as Schema from "effect/Schema";
export class Unauthorized extends Schema.TaggedError<Unauthorized>()( "Unauthorized", {}, { httpApiStatus: 401 },) {}A request without a session returns a typed 401.
Distinguish authentication failures
Section titled “Distinguish authentication failures”// Append to src/middleware.tsexport class AuthenticationUnavailable extends Schema.TaggedError<AuthenticationUnavailable>()( "AuthenticationUnavailable", {}, { httpApiStatus: 503 },) {}A failed session lookup returns 503, not 401. Its response exposes no database errors or credentials.
Declare the middleware
Section titled “Declare the middleware”import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware";import { CurrentUser } from "./current-user.ts";
export class Authentication extends HttpApiMiddleware.Service< Authentication, { provides: CurrentUser }>()("app/Authentication", { error: [Unauthorized, AuthenticationUnavailable],}) {}The middleware declares the service it provides and the errors callers can receive.
Implement session authentication
Section titled “Implement session authentication”import { RuntimeContext } from "alchemy";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { Auth } from "./auth.ts";
error: [Unauthorized, AuthenticationUnavailable],}) {}}) { static readonly layer = Layer.effect( Authentication, Effect.gen(function* () { const auth = yield* Auth; return (httpEffect) => Effect.gen(function* () { const session = yield* auth.getSession().pipe( Effect.mapError(() => new AuthenticationUnavailable()), Effect.catchDefect(() => Effect.fail(new AuthenticationUnavailable())), ); if (session === null) return yield* Effect.fail(new Unauthorized()); return yield* Effect.provideService(httpEffect, CurrentUser, { id: session.user.id, name: session.user.name, email: session.user.email, }); }).pipe(Effect.provide(RuntimeContext.phantom)); }), );}The Auth service is captured once; the session is read inside each request. RuntimeContext.phantom adapts Alchemy’s runtime-only methods to the native HTTP middleware boundary; it does not create a session or bypass authentication.
Protect one API group
Section titled “Protect one API group”import { User } from "./current-user.ts";import { Authentication } from "./middleware.ts";
export class PrivateApi extends HttpApiGroup.make("private") .add(HttpApiEndpoint.get("me", "/api/me", { success: User })) .middleware(Authentication) {}
export class AppApi extends HttpApi.make("app").add(PublicApi) {}export class AppApi extends HttpApi.make("app") .add(PublicApi) .add(PrivateApi) {}Only the private group requires authentication. Better Auth’s own routes and the public group remain accessible while signed out.
Read the user in a handler
Section titled “Read the user in a handler”import { CurrentUser } from "./current-user.ts";
const PrivateLive = HttpApiBuilder.group(AppApi, "private", (handlers) => handlers.handle("me", () => CurrentUser),);
export const HttpLive = HttpApiBuilder.layer(AppApi).pipe( Layer.provide(PublicLive), Layer.provide(Layer.mergeAll(PublicLive, PrivateLive)), Layer.provide(Http.Platform),);The middleware supplies CurrentUser before the handler runs. The handler does not parse cookies itself.
Provide the middleware implementation
Section titled “Provide the middleware implementation”import { Authentication } from "./middleware.ts";
export const HttpLive = HttpApiBuilder.layer(AppApi).pipe( Layer.provide(Layer.mergeAll(PublicLive, PrivateLive)), Layer.provide(Authentication.layer), Layer.provide(Http.Platform),);The Worker’s existing Auth.layer supplies the middleware’s Auth dependency. No additional database connection configuration is needed.
Call the protected endpoint
Section titled “Call the protected endpoint”Add a button to the existing page:
<button id="session" type="button">Read session</button><button id="me" type="button">Call protected API</button>Wire it to the API:
// Append to src/ui.tsconst readMe = async () => { const response = await fetch("/api/me"); if (response.status === 401) return show("Sign in first."); if (!response.ok) return show("Unable to read your account."); const user = await response.json(); show(`API user: ${user.email}`);};
document.querySelector("#me")!.addEventListener("click", () => { void readMe().catch(() => show("Unable to reach the server."));});Rebuild the browser bundle:
bun build ./src/ui.ts --target browser --outdir ./publicVerify the boundary
Section titled “Verify the boundary”curl -i "$DEV_URL/api/me"HTTP/1.1 401 UnauthorizedContent-Type: application/json
{"_tag":"Unauthorized"}In the browser, sign in and select Call protected API; it should show your email. Sign out and repeat; it should ask you to sign in while /api/health still returns 200.
Continue to Part 5: Add GitHub sign-in.