---
title: "Expose the Application User"
url: "https://effect-auth.itsbroly.com/recipes/expose-application-user/"
description: "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?

```text
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 [#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.

```ts
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 [#extend-the-browser-protocol]

```ts
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 [#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](/concepts/sessions/), [Browser Client](/clients/browser-client/#extensions-and-custom-endpoints), and [Custom Auth API](/guides/custom-auth-api/).

