effect-auth

Expose the Application User

Add a typed app-owned profile endpoint beside the technical session endpoint.

GET /auth/session answers an authentication question: which session was validated, for which actor, with what assurance and expiry? Keep it technical. GET /auth/me should answer an application question: what profile may this actor see now?

cookie -> SessionCookie.read -> Sessions.validate -> CurrentActor.userId
                                                     |
                                                     v
                                      AppUsers lookup + current policy
                                                     |
                                                     v
                                          safe PublicProfile

This separation prevents auth storage from becoming the profile database. Roles, tenant membership, display name, and avatar belong to your application repository and can change independently of a session. Never return password hashes, credential records, session tokens, internal notes, recovery state, or unfiltered database rows.

Define and serve the contract

Use one schema as both the encoded response and the allowlist of public fields. This follows the typed HttpApi extension used by the TanStack example's client stack.

import { Effect, Option, Schema } from "effect";
import { SessionCookie, Sessions } from "@effect-auth/core/Sessions";
import {
  AuthInternalError,
  AuthUnauthenticatedError,
} from "@effect-auth/core/HttpApi";
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";

export const PublicProfile = Schema.Struct({
  id: Schema.String,
  displayName: Schema.String,
  avatarUrl: Schema.NullOr(Schema.String),
  tenantId: Schema.String,
  roles: Schema.Array(Schema.String),
});

const me = HttpApiEndpoint.get("current", "/auth/me", {
  success: PublicProfile,
  error: [AuthUnauthenticatedError, AuthInternalError],
});
class MeGroup extends HttpApiGroup.make("me").add(me) {}
export class MeApi extends HttpApi.make("MeApi").add(MeGroup) {}

export const MeHttpApiGroupLive = HttpApiBuilder.group(MeApi, "me", (handlers) =>
  Effect.gen(function* () {
    const cookies = yield* SessionCookie;
    const sessions = yield* Sessions;
    const users = yield* AppUsers; // Drizzle-backed app repository
    return handlers.handle("current", ({ request }) =>
      Effect.gen(function* () {
        const token = yield* cookies.read(request);
        if (Option.isNone(token))
          return yield* new AuthUnauthenticatedError({
            code: "unauthenticated", message: "Authentication required",
          });
        const { actor } = yield* sessions.validate(token.value);
        const user = yield* users.findPublicByAuthUserId(actor.userId);
        if (Option.isNone(user))
          return yield* new AuthUnauthenticatedError({
            code: "unauthenticated", message: "Authentication required",
          });
        return user.value; // repository selects only PublicProfile columns
      }).pipe(mapSessionAndRepositoryErrors); // sanitize expected 401 vs unexpected 500
    );
  })
);

mapSessionAndRepositoryErrors is app-owned: map missing, malformed, expired, or revoked sessions to the declared 401, and repository failures to the safe declared 500; never leak causes. If membership is absent, choose a deliberate 403 or 404 and add its schema to error. Resolve tenant from trusted membership or route context, not a client-supplied tenant ID. Check role and resource ownership again on every protected operation; /auth/me is presentation data, not an authorization grant.

Extend the browser protocol

import {
  createAuthClient,
  defineAuthHttpApiExtension,
} from "@effect-auth/core/Client";
import { MeApi } from "./me-api";

const meExtension = defineAuthHttpApiExtension(MeApi, ({ run }) => ({
  current: (options?: { signal?: AbortSignal }) =>
    run((client) => client.me.current(), options),
}));

export const authClient = createAuthClient({
  protocol: { extensions: { me: meExtension } },
});

const profile = await authClient.extensions.me.current();

Cache with a user/tenant-scoped query key, invalidate it after sign-in, sign-out, tenant switching, or profile updates, and never put authenticated responses in a shared CDN cache. Prefer Cache-Control: private, no-store when freshness affects authorization.

Test the boundary

  1. Missing, malformed, expired, and revoked cookies produce the same sanitized 401.
  2. A valid session loads by actor.userId; another user's or tenant's profile is never returned.
  3. The encoded body contains exactly the public schema fields, and repository failures produce only the declared 500.
  4. The extension decodes a valid response, rejects an invalid shape, and forwards AbortSignal.

Continue with Sessions, Browser Client, and Custom Auth API.

On this page