SessionManager
Application session manager with opaque HttpOnly cookies, family-based rotation and revocation, and Origin and CSRF checks on authenticated mutations.
import { SessionManager } from "@meekojs/meeko/server";SessionManager is a Meeko application's session manager. It stores sessions in a SessionStore and identifies them through an opaque HttpOnly, host-only, Secure, SameSite=Lax cookie (__Host-meeko.sid by default). On each request, load reads the cookie and returns the SessionHandle passed to pages, API, RPC and Socket as context.session; on mutations with a cookie, assertMutationAllowed checks Origin and the x-meeko-csrf token.
Declare it in sessions in server/runtime.ts. Since 1.2 it is optional: without it, Meeko creates one in memory during development and in Redis with MEEKO_REDIS_URL in production (see Zero-configuration sessions). Your own SessionManager always takes precedence.
Usage#
// server/runtime.ts
import { createClient } from "redis";
import { defineServerRuntime, RedisSessionStore, SessionManager } from "@meekojs/meeko/server";
export default defineServerRuntime(async ({ env, production, prerendering }) => {
const redis = createClient({ url: env.MEEKO_REDIS_URL });
await redis.connect();
return {
sessions: new SessionManager({
store: new RedisSessionStore(redis, { prefix: "tienda:prod:session:" }),
production: production && !prerendering,
}),
close: async () => { await redis.quit(); },
};
});production && !prerendering: the factory also executes during meeko build prerendering, without client traffic, and no persistent store is needed there.
Type#
class SessionManager<T = unknown> {
readonly store: SessionStore<T>;
readonly ttlMs: number;
readonly cookieName: string;
readonly allowedOrigins: readonly string[];
constructor(options: SessionOptions<T>);
serializeCookie(value: string, ttlMs: number): string;
load(request: Request): Promise<SessionHandle<T>>;
rateLimit(key: string, windowMs: number): Promise<RateLimitHit>;
close(): Promise<void>;
assertMutationAllowed(
request: Request,
session: SessionHandle<T>,
mutating?: boolean,
): void;
}interface SessionOptions<T> {
store: SessionStore<T>
production?: boolean
ttlMs?: number
secure?: boolean
sameSite?: 'Lax' | 'Strict' | 'None'
cookieName?: string
allowedOrigins?: readonly string[]
}interface SessionStore<T = unknown> {
readonly persistent?: boolean;
get(id: string): Promise<SessionRecord<T> | undefined>;
set(record: SessionRecord<T>): Promise<void>;
delete(id: string): Promise<void>;
replace?(previousId: string, record: SessionRecord<T>): Promise<boolean>;
revokeFamily?(familyId: string): Promise<void>;
subscribeRevocations?(
listener: (familyId: string) => void,
): Unsubscribe | Promise<Unsubscribe>;
rateLimit?(key: string, windowMs: number): Promise<RateLimitHit>;
close?(): Promise<void>;
}interface SessionRecord<T = unknown> {
id: string
familyId?: string
csrfToken: string
data: T
createdAt: number
expiresAt: number
ttlMs?: number
}Parameters#
new SessionManager(options) takes a SessionOptions<T> object:
| Option | Type | Default | Description |
|---|---|---|---|
store |
SessionStore<T> |
required | Where sessions live: MemorySessionStore, RedisSessionStore, SqlSessionStore or your own store. |
production |
boolean |
false |
Requires a store with persistent: true, replace and revokeFamily; otherwise, the constructor throws. Also disables development warnings. |
ttlMs |
number |
604800000 (7 days) |
Default lifetime for each session, a positive integer in milliseconds. session.create(data, { ttlMs }) overrides it for one session. |
secure |
boolean |
true |
The cookie's Secure attribute. Without it, the default name becomes meeko.sid. |
sameSite |
"Lax" | "Strict" | "None" |
"Lax" |
The SameSite attribute. "None" requires secure. |
cookieName |
string |
"__Host-meeko.sid", or "meeko.sid" without secure |
Cookie name: letters, digits, _, . and -. A __Host-… name requires secure. |
allowedOrigins |
readonly string[] |
[] |
Origins, in addition to the application's own, accepted in API and RPC mutations (since 1.8, also without a session), native forms and session.create(). |
Return value#
An instance with these members:
| Member | Type | Description |
|---|---|---|
store |
SessionStore<T> |
The store from the options. |
ttlMs |
number |
The default lifetime. |
cookieName |
string |
The cookie name. |
allowedOrigins |
readonly string[] |
Additional origins. |
load(request) |
Promise<SessionHandle<T>> |
Reads the request cookie and returns its handle, without a session if absent, invalid or expired. The first call subscribes to store revocations and waits for confirmation; if it fails, it rejects and the next call retries. |
assertMutationAllowed(request, session, mutating?) |
void |
On a mutation with a session, checks that Origin is the application's own or one of allowedOrigins and that x-meeko-csrf is the session token. Throws SessionSecurityError with INVALID_ORIGIN or INVALID_CSRF. Without a session, or on GET, HEAD and OPTIONS, does nothing. |
serializeCookie(value, ttlMs) |
string |
The Set-Cookie header with the manager's policy; ttlMs 0 deletes it. |
rateLimit(key, windowMs) |
Promise<RateLimitHit> |
Since 1.2. Counts one request in the fixed window key with store.rateLimit, or in the instance's memory if the store lacks it. |
close() |
Promise<void> |
Cancels the revocation subscription and closes the store; does not close external clients such as Redis. |
With default options, the cookie is __Host-meeko.sid=<id>; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800; Secure.
Examples#
SQL sessions with a custom lifetime and origins#
// server/runtime.ts
import { defineServerRuntime, SessionManager, SqlSessionStore } from "@meekojs/meeko/server";
import { createPool, sessionDriver } from "./utils/database";
export default defineServerRuntime(async ({ env, production, prerendering }) => {
const pool = await createPool(env.DATABASE_URL);
return {
sessions: new SessionManager({
store: new SqlSessionStore(sessionDriver(pool)),
production: production && !prerendering,
ttlMs: 12 * 60 * 60 * 1000, // 12 hours
sameSite: "Strict",
allowedOrigins: ["https://admin.example.com"], // the dashboard, on another subdomain
}),
close: () => pool.end(),
};
});One session type for the entire application#
SessionManager<T> accepts session data as a type parameter, but context.session follows MeekoSession. Declare it once to type data in pages, API, RPC and Socket:
// server/types/session.d.ts
import type {} from "@meekojs/meeko/server";
declare module "@meekojs/meeko/server" {
interface MeekoSession { userId: string; role: "owner" | "staff" }
}Check Origin and CSRF on your own host#
createServerApp already does this in API and RPC. A host serving its own routes with Meeko's session does it manually:
import { MemorySessionStore, SessionManager, SessionSecurityError } from "@meekojs/meeko/server";
const sessions = new SessionManager({ store: new MemorySessionStore(), secure: false });
export async function handle(request: Request): Promise<Response> {
const session = await sessions.load(request);
try {
sessions.assertMutationAllowed(request, session);
} catch (error) {
if (error instanceof SessionSecurityError) return Response.json({ code: error.code }, { status: 403 });
throw error;
}
const headers = new Headers();
if (session.cookieHeader) headers.append("set-cookie", session.cookieHeader);
return new Response(session.current ? "authenticated" : "anonymous", { headers });
}Deployment behind an HTTPS proxy#
MEEKO_PUBLIC_ORIGIN=https://tienda.example.com # the origin seen by the browser
MEEKO_REDIS_URL=rediss://default:…@redis.example.com:6380
MEEKO_TRUST_PROXY=1 # Traefik, Caddy or nginx in front
# MEEKO_TRUST_PROXY=cloudflare # since 1.8, behind Cloudflare Tunnel: CF-Connecting-IPThe browser sends Origin: https://tienda.example.com; createServerApp constructs the request URL with MEEKO_PUBLIC_ORIGIN, so assertMutationAllowed accepts it even if the proxy talks HTTP to the application.
Common mistakes#
An in-memory store in production#
new SessionManager({ store: new MemorySessionStore(), production: true });The constructor throws Error: Persistent session storage with atomic rotation and family revocation is required in production and the application does not start. An in-memory store loses sessions on restart and is not shared between instances.
new SessionManager({ store: new RedisSessionStore(redis, { prefix: "app:prod:session:" }), production: production && !prerendering });Production without sessions or MEEKO_REDIS_URL#
server/runtime.ts without sessions, and the server starts without MEEKO_REDIS_URLmeeko start (and each adapter) stops with Production requires a persistent session store: set MEEKO_REDIS_URL (with the redis package installed), or return { sessions } with a persistent SessionStore from server/runtime.ts. On Workers, MEEKO_REDIS_URL is not valid either: MEEKO_REDIS_URL needs TCP connections, which Cloudflare Workers do not offer….
MEEKO_REDIS_URL=rediss://… and npm install redisSameSite=None or __Host- without Secure#
new SessionManager({ store, sameSite: "None", secure: false });The constructor throws Error: SameSite=None requires Secure. With cookieName: "__Host-sid" and secure: false, Error: __Host- cookies require Secure. Browsers reject those cookies.
new SessionManager({ store, sameSite: "None", secure: true });A non-integer lifetime#
new SessionManager({ store, ttlMs: 1.5 * 24 * 60 * 60 * 1000 + 0.5 });RangeError: Invalid session TTL. Use a positive integer in milliseconds.
new SessionManager({ store, ttlMs: 36 * 60 * 60 * 1000 });Notes#
- Server only. Imported from
@meekojs/meeko/serverand lives inserver/runtime.ts; the cookie isHttpOnly, so the browser never sees the ID. The CSRF token arrives through themeeko-csrf-tokenmeta tag andx-meeko-csrf-tokenheader, and theapi.*andrpc.*clients send it automatically. - CSRF and Origin. Mutations (
POST,PUT,PATCH,DELETE) with a cookie require an allowedOriginandx-meeko-csrf; failure returns403 INVALID_ORIGINor403 INVALID_CSRF. Without a session there is no token to check, but since1.8every mutation still requires an allowedOrigin, except a route withorigin: "none";session.create()has rejected a foreignOriginsince1.2. - Proxies. The application's own origin comes from
publicOriginorMEEKO_PUBLIC_ORIGIN, never theHostheader. If you do not configure it behind a proxy terminating TLS, browser mutations fail withINVALID_ORIGIN. - Environment variables.
MEEKO_SESSION_TTLandMEEKO_SESSION_SAMESITEonly configure sessions Meeko creates without configuration; with your ownSessionManager, they do not apply and Meeko warns once. - Cross-instance revocation. With a store implementing
subscribeRevocations(RedisSessionStorewithsubscriber), revoking a family aborts requests and closes that family's sockets on every instance.SqlSessionStoreand D1 do not notify: other instances detect it when rereading the session. - Shutdown.
close()releases the store, not the Redis client created by the application: close it incloseinserver/runtime.ts, which executes afterward.
Important
Secure requires HTTPS. During HTTP development on localhost, browsers accept the Secure cookie; on another machine or behind a proxy without TLS, use HTTPS instead of disabling secure.