Skip to content

Part 4: Protect API endpoints

Continue from Part 3. Protect a new /api/me endpoint without blocking sign-in or the public health check.

src/current-user.ts
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.

src/middleware.ts
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.

// Append to src/middleware.ts
export 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.

src/middleware.ts
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.

src/middleware.ts
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.

src/api.ts
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.

src/http.ts
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.

src/http.ts
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.

Add a button to the existing page:

public/index.html
<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.ts
const 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:

Terminal window
bun build ./src/ui.ts --target browser --outdir ./public
Terminal window
curl -i "$DEV_URL/api/me"
HTTP/1.1 401 Unauthorized
Content-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.