Session Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
session-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Three layers compose for HTTP sessions:
@modularityjs/session—SessionServiceabstract contract:get(id) → SessionData | undefined,set(id, data, options?: { ttlMs? }),destroy(id),destroyForIdentity(identityId),regenerate(id) → newId.SessionDatais{ id, data: Record<string, unknown>, createdAt, updatedAt, identityId? }— every field isreadonly, so you mutatedata, never the envelope.SessionConfig.ttlMsdefaults to 24 h.@modularityjs/session-{memory,redis}— drivers. Memory is per-process (lost on restart, single-node only); Redis survives restarts and scales horizontally — needsRedisModulealongside.@modularityjs/http-session— Fastify-bridge. Reads the session cookie ononRequest, writes it back ononSendonly whendatachanged orrolling: true. Provides the@Session()/@Session('key')parameter decorator plus the request helpersgetRequestSession,setRequestSession,setRequestSessionIdentity, andsetRequestSessionRegenerate. Configure viaHttpSessionModule.forRoot({ cookieName, cookie: { secure, sameSite, ... }, rolling }).
The bridge mutates session.data in place — assign keys directly, no save() call. The diff is captured against a snapshot taken on request and persisted on response. Don't reassign request.session (the bridge's tracking lives in symbols on the request object). For session destruction call sessionService.destroy(session.id) and unset the cookie via @Response().
"Log out everywhere" is destroyForIdentity, not a loop over destroy. SessionData.identityId is a first-class field precisely because a store cannot dig an identity out of the opaque data bag — it doesn't know which key an extension stamped it into. Set it with setRequestSessionIdentity(request, id) on sign-in and setRequestSessionIdentity(request, null) on sign-out (this is exactly what http-auth-session does), then sessionService.destroyForIdentity(id) revokes every live session for that user — the revocation a password reset owes the account. It is not a barrier against a concurrent sign-in: stop issuing sessions first (disable the credential), then revoke.
@Session() with no argument returns the whole SessionData (or null for a brand-new request); @Session('userId') returns the named field. getRequestSession(request) from @modularityjs/http-session is the imperative equivalent for use inside guards/interceptors.
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import {
Body,
Controller,
HttpControllersPool,
HttpModule,
Post,
Response,
type HttpResponse,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpSessionModule, Session } from '@modularityjs/http-session';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import {
SessionData,
SessionModule,
SessionService,
} from '@modularityjs/session';
import { SessionMemoryModule } from '@modularityjs/session-memory';
@Controller()
class LoginController {
constructor(
@Inject(SessionService) private readonly sessions: SessionService,
) {}
@Post('/login')
login(@Body() body: { userId: string }, @Session() session: SessionData) {
session.data.userId = body.userId;
return { ok: true };
}
@Post('/whoami')
whoami(@Session('userId') userId: string | undefined) {
return { userId: userId ?? null };
}
@Post('/logout')
async logout(
@Session() session: SessionData,
@Response() response: HttpResponse,
) {
await this.sessions.destroy(session.id);
response.setCookie('sid', '', { maxAge: 0 });
return { ok: true };
}
}
@Module({
name: 'login',
imports: [HttpModule, HttpSessionModule, SessionModule],
providers: [LoginController],
pools: [
{ pool: HttpControllersPool, key: 'login', useClass: LoginController },
],
})
class AppModule {}
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
SessionModule,
SessionMemoryModule,
HttpSessionModule.forRoot({
cookieName: 'sid',
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}),
AppModule,
],
});
await app.start();rolling: true (default false) writes the session cookie on every response, refreshing maxAge and TTL. Use it when "active means logged in" — without it, an idle user expires regardless of activity.
Session-fixation defence: flag the request, don't rotate the id yourself. SessionData.id is readonly — assigning to it doesn't compile, and calling regenerate(oldId) from a handler leaves the request holding a destroyed id while the cookie still carries the old one. Call setRequestSessionRegenerate(request, true) immediately after authenticating instead:
import { Injectable } from '@modularityjs/di';
import {
Body,
Controller,
type HttpRequest,
Post,
Request,
} from '@modularityjs/http';
import {
setRequestSessionIdentity,
setRequestSessionRegenerate,
} from '@modularityjs/http-session';
@Injectable()
@Controller()
class SignInController {
@Post('/sign-in')
signIn(@Body() body: { userId: string }, @Request() request: HttpRequest) {
// ... verify credentials first ...
setRequestSessionIdentity(request, body.userId);
setRequestSessionRegenerate(request, true);
return { ok: true };
}
}onSend owns the store round-trip: it calls regenerate, re-persists the current data under the new id, republishes the rotated SessionData onto the request (so later onSend hooks — http-csrf HMACs session.id — see the new id), and writes the cookie. A brand-new session is skipped: its id was minted this request, so there is nothing to rotate away from. If you load @modularityjs/http-auth-session, signIn(request, identity) already does both calls for you.
CSRF rides on the session — and it is on by default
@modularityjs/http-csrf is wired into the session, not beside it: its @Module imports SessionModule + HttpSessionModule, so it can only be loaded by an app that already has session wiring. Read this before adding it — the defaults are deliberately secure and deliberately loud.
Loading HttpCsrfModule protects every mutating route in the app, immediately. HttpCsrfConfig.globalProtection defaults to true: in afterLoad the module walks every controller in HttpControllersPool and attaches CsrfGuard to each method whose HTTP verb is not in safeMethods (GET, HEAD, OPTIONS). The opt-in shape — decorate the routes you want with @CsrfProtect() — was the inverse of every other default here, because forgetting the decorator on one @Post was a silent vulnerability. So the @Post('/login') and @Post('/logout') above start returning 403 the moment this module is in modules: [...] unless the request carries a valid token.
Three consequences to plan for:
- A request with no session fails the guard.
CsrfGuard.activatereturnsfalsewhengetRequestSession(request)is empty, so an anonymousPOSTis rejected outright — including the sign-in route itself. Render the form through a GET first (which mints the session and the token), or mark a genuinely token-free endpoint@CsrfExempt(). @CsrfExempt()is the only opt-out per route. Use it for endpoints authenticated by something a browser will not attach automatically — Bearer-token APIs, incoming webhooks (which have their own HMAC viahttp-fastify-webhook). TurningglobalProtection: falseapp-wide is an explicit decision to go back to per-route@CsrfProtect().- Tokens reach the client by mode.
'session-stored'(default) keeps a random token in the session — template it into your HTML viagetCsrfToken(request)or theCsrfTokenReadertemplate helper, and send it back in theX-CSRF-Tokenheader or the_csrfbody field.'double-submit'derives the token asHMAC(secret, session.id)and ships it in a non-HttpOnlycookie, so page bodies carry no per-user state and stay shared-cacheable; theCsrfClientAssetcontributed toStaticAssetsPoolis the browser half that copies cookie → header.
Two config gates fail boot rather than degrade. In 'double-submit' mode secret is required and must be at least 32 bytes — it keys HMAC-SHA256. And cookieSecure defaults to true; setting it false fails validate() unless you also pass allowInsecureCookie: true, because in double-submit mode that cookie is the CSRF secret and it is written non-HttpOnly, so plain HTTP hands it to any on-path observer. The escape hatch exists for http://localhost development and nothing else.
Body-field validation (_csrf) is only safe in 'double-submit' mode, where the cookie is HMAC-bound to the session id.