Cross-Service Auth

Base services don't run their own auth. They verify each request by forwarding the session cookie to the auth service and reading back the verified principal. This is the single most important invariant in a stackr monorepo — and the one ad-hoc projects most often get wrong.

The request flow

  1. 1
    Client requestWeb or mobile sends a request carrying the BetterAuth session cookie.
    Cookie: better-auth.session_token
  2. 2
    requireAuth (onRequest hook)The base service intercepts the request before the route handler runs.
    forwards the cookie →
  3. 3
    auth :8888 · /api/auth/get-sessionThe trust anchor validates the session and returns the principal.
    ← { user, session }
  4. 4
    request.user is decoratedThe handler executes with a verified user — never a client-supplied id.

With the flexible middleware flavor, the same hook also accepts anx-device-session-token header for native clients that can't carry cookies.

Step by step:

  1. The client authenticates against the auth service. BetterAuth sets an httpOnly session cookie on the client.
  2. The client sends a request to a base service with that cookie attached.
  3. The base service's requireAuth hook forwards the cookie to the auth service's /api/auth/get-session endpoint.
  4. If the session is valid, the hook decorates the request with user and session from the response.
  5. The route handler runs with a verified request.user — never a client-supplied id.

The generated requireAuth decorator is essentially:

// base/backend/controllers/rest-api/plugins/auth.ts
server.decorate('requireAuth', async (request, reply) => {
const res = await fetch(AUTH_SERVICE_URL + '/api/auth/get-session', {
  method: 'GET',
  headers: { cookie: request.headers.cookie || '' },
});
if (!res.ok) throw ErrorFactory.unauthorized();

const data = await res.json();           // { user, session } | null
if (!data || !data.user) throw ErrorFactory.unauthorized();

request.user = data.user;                // verified principal
request.session = data.session;
});

Guard at the hook, not in the handler

requireAuth is registered as an onRequest hook on protected routes. Handlers stay thin and assume an already-authenticated request.user — they never re-check auth inline:

server.get('/me', { onRequest: [server.requireAuth] }, async (request) => {
// request.user is guaranteed here
return request.user;
});
Never trust a header or a client id

A base service must derive identity from the auth service's verified response. Trusting an x-user-id header, a JWT the client minted, or a body field is a privilege-escalation bug. This rule lives in the project's root AGENTS.md.

Provisioning

A user authenticated against auth may not yet have a record in a given base service. Each user object carries a has<Service>Account flag. The first time a user hits a service that provisions (the flexible flavor), the service creates its local record and calls POST /api/auth/provision to tell the auth service to flip the flag:

if (!data.user.hasWalletAccount) {
await provisionWallet(data.user, request.headers.cookie || '');
// → POST ${AUTH_SERVICE_URL}/api/auth/provision  { app: 'wallet' }
}

Middleware flavors

When you add a base service, you choose how strict its requireAuth is. The flavor is stored per service in stackr.config.json.

FlavorBehavior
standardCookie forwarding only. Decorates request.user / request.session. The default when an auth service exists.
role-gatedStandard, plus a role check: the user must hold one of the allowed roles (admin always passes). Otherwise 403.
flexibleStandard, plus device-session support and first-call provisioning. Adds requireDeviceSession and requireAuthOrDeviceSession.
noneNo auth plugin. A no-op is exported so the bootstrap still imports cleanly — for backend-only/internal services.

Device sessions (flexible)

Native clients that can't carry browser cookies authenticate with an x-device-session-token header instead. The flexible flavor adds two more decorators:

  • requireDeviceSession — validates the x-device-session-token header against the service's device-session store.
  • requireAuthOrDeviceSession — accepts either a valid auth cookie or a device session, decorating request.authType with "user" or "device".

This is what lets an Expo app and a web app share one backend while using different credential transports. Choose the flavor per service in the wizard or via the --auth-middleware flag on stackr add service.