Skip to content

Sessions for pages

pageSession() resolves a request’s credential to a user for @warlock.js/web pages (web.session). sessionMiddleware() gives cookie API routes the same resolver.

import { pageSession, sessionMiddleware } from "@warlock.js/auth";
export default { session: pageSession({ project: (user: User) => userSessionResource(user) }) };
// cookie API route
router.get("/me", handler, { middleware: [sessionMiddleware({ optional: true })] });
OptionDefault
project (pageSession only, required)none
cookieauth.cookie.name (access_token)
refreshCookieauth.cookie.refreshName (refresh_token)
sources["cookie", "header"]
renewtrue (cookie source only)
userTypesauth.defaultUserType; renewal needs exactly one
maxAgeauth.session.maxAge, else "30d"
overlapMsrenewal default (5 s, capped at 10 s)

sessionMiddleware also takes optional (guests pass with no user instead of a 401) and puts the user on request.locals.user.

Set both cookies with authService.setSessionCookies(response, tokens) and clear them with authService.clearSessionCookies(response). They are HttpOnly, SameSite=Lax, Path=/ and Secure outside development. The refresh cookie must be Path=/ so it reaches every page.

maxAge caps the family’s absolute age. Rotation never extends a family past it; an older family is revoked. Renewal is reactive (on an expired or missing access cookie), not proactive, and there is no remember-me switch.

A present Authorization header always wins and never falls back to the cookie, even when it is invalid. Renewal applies only to the cookie source.

CSRF: a cookie-authenticated unsafe request must pass the Origin/Referer check (auth.csrf.allowedOrigins adds origins). sessionMiddleware runs it whenever an access or refresh cookie is present, since renewal can mint cookies. Header-authenticated requests skip it.

const result = await authService.loginWithSessionCookies(request, response, User, credentials);

It checks Origin (or Referer) before looking at credentials, even when the request has no cookies (login CSRF). A missing or cross-site Origin throws CsrfOriginMismatchError. It returns null for bad credentials and sets both cookies on success.

The cookie helpers take a response: pass a controller’s Response or a page action’s response, so login and logout work as page actions. The cookies go out with the action’s reply.

src/web/auth/login.setup.ts
export const config = {
action: {
validation: v.object({ email: v.string().email(), password: v.string() }),
},
} satisfies PageConfig;
export async function action({ request, response }: PageActionContext<typeof config.action>) {
const result = await authService.loginWithSessionCookies(request, response, User, request.validated());
if (!result) return response.unauthorized({ message: "Invalid email or password." });
return response.redirect(safeRedirectTarget(request.input("redirect")) ?? "/");
}
// src/web/account/index.setup.ts
export const actions = {
logout: async ({ request, response, session }: PageActionContext) => {
if (session?.model) {
await authService.logout(session.model, request.cookie("access_token"), request.cookie("refresh_token"));
}
authService.clearSessionCookies(response);
return response.redirect("/login");
},
};

session.model is your auth model once SessionRegistry declares it.

Nothing is held in process memory; renewal serialises in the database. Share:

  • The JWT secret, identical on every instance.
  • The cache store used by the login throttle: use a shared driver (for example Redis), or each instance counts alone. The throttle fails open.
  • Synchronised clocks (NTP): the renewal overlap check compares Date.now() with revoked_at; skew above about 5 s can read a concurrent renewal as a replay and sign the user out.

Logout is immediate everywhere because the access-token row is checked per request. Core rateLimit stays per-process.