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
- 1Client requestWeb or mobile sends a request carrying the BetterAuth session cookie.
Cookie: better-auth.session_token - 2requireAuth (onRequest hook)The base service intercepts the request before the route handler runs.
forwards the cookie → - 3auth :8888 · /api/auth/get-sessionThe trust anchor validates the session and returns the principal.
← { user, session } - 4request.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:
- The client authenticates against the auth service. BetterAuth sets an httpOnly session cookie on the client.
- The client sends a request to a base service with that cookie attached.
- The base service's
requireAuthhook forwards the cookie to the auth service's/api/auth/get-sessionendpoint. - If the session is valid, the hook decorates the request with
userandsessionfrom the response. - 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.
| Flavor | Behavior |
|---|---|
| standard | Cookie forwarding only. Decorates request.user / request.session. The default when an auth service exists. |
| role-gated | Standard, plus a role check: the user must hold one of the allowed roles (admin always passes). Otherwise 403. |
| flexible | Standard, plus device-session support and first-call provisioning. Adds requireDeviceSession and requireAuthOrDeviceSession. |
| none | No 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 thex-device-session-tokenheader against the service's device-session store.requireAuthOrDeviceSession— accepts either a valid auth cookie or a device session, decoratingrequest.authTypewith"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.