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 any CookieWriter, meaning any object with cookie() and clearCookie(). That covers both a controller’s Response and 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().required(), password: v.string().required() }),
},
} 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.