Sessions and security

Sessions

useSession(event, options) returns { id, data, update(patch), regenerate(), clear() }. The session is stored in a cookie, sealed with AES-GCM, so the server keeps nothing and a tampered or expired cookie reads as an empty session.

import { createError, defineHandler, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;

const nameInput = {
  "~standard": {
    version: 1 as const,
    vendor: "docs",
    validate: (value: unknown) => {
      const name = (value as { name?: unknown } | undefined)?.name;
      return typeof name === "string" && name ? { value: { name } } : { issues: [{ message: "name is required", path: ["name"] }] };
    },
  },
};

export const login = defineHandler({
  route: "/api/login",
  method: "POST",
  input: nameInput,
  async handler({ name }: { name: string }, { event }) {
    if (name.length > 64) throw createError({ status: 400, message: "name is too long" });
    const session = await useSession(event, { password, maxAge: 60 * 60 * 24 * 7 });
    await session.regenerate();
    await session.update({ user: name });
    return { id: session.id };
  },
});
OptionMeaning
passwordat least 32 characters, or useSession throws. A list of up to 8 rotates the password: the first seals, every entry opens (see below)
namethe cookie name, silgi-session by default
maxAgeseconds from the session's creation until it reads as empty; also the cookie's Max-Age. Without it the cookie lasts for the browser session
cookiedomain, path, httpOnly, secure, sameSite, partitioned; the defaults are path=/, HttpOnly, Secure and SameSite=Lax. A Secure cookie is not stored by a browser on http:// except on localhost, so a dev host on plain http needs cookie: { secure: false }
import { createError, defineHandler, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;

export const me = defineHandler({
  route: "/api/me",
  method: "GET",
  async handler(_, { event }) {
    const session = await useSession<{ user?: string }>(event, { password });
    if (!session.data.user) throw createError({ status: 401, message: "Not signed in" });
    return { user: session.data.user };
  },
});

Nothing is written until the first update. update(patch) merges the patch into data and sets the cookie. Calls to update and clear run one after another, in call order. clear() gives the session a new id, empties it and deletes the cookie.

import { defineHandler, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;

export const logout = defineHandler({
  route: "/api/logout",
  method: "POST",
  async handler(_, { event }) {
    const session = await useSession(event, { password });
    await session.clear();
    return null;
  },
});

A cookie is at most 4096 bytes: update and regenerate throw an error that names the cookie when the sealed session would be larger. Keep the data small and store an id of a record, not the record. Expiry counts from creation, not from the last update. Read the password from the environment, not from the source.

Rotating the password

To replace a password without signing anyone out, put the new one first and keep the old one: password: [env.SESSION_PASSWORD_NEW, env.SESSION_PASSWORD_OLD]. New cookies are sealed with the first entry; a cookie sealed with any listed entry still opens. session.rotated is true when the request's cookie opened with an entry other than the first, and the cookie is sealed again with the first entry on the next update (await session.update({}) does it at once). Once the old cookies have expired (maxAge), drop the old entry; a cookie sealed with a password that is no longer listed reads as an empty session. A list holds 1 to 8 passwords of at least 32 characters each, else useSession throws a TypeError, and a second useSession for the same cookie name in one request must pass the same list in the same order.

Protecting a page and its data

A route middleware (app/middleware) and a load run in the browser on a navigation, and a user can skip both. A route middleware does not guard data, and neither does a load: a redirect from them steers a visitor, it stops no request. The handler must check, on every call, whoever calls it. The page's own chunk is public, as it always was; what is secret is the data, and the data is behind handlers.

Put the check in a middleware and list it where the data is read. One defineMiddleware object does both jobs:

import { defineMiddleware, redirect } from "silgi/app";
import { getSession } from "../utils/auth.ts";

export const requireLogin = defineMiddleware<{}, { user: string }>(async ({ event, next }) => {
  const session = await getSession(event);
  if (!session.data.user) throw redirect("/login");
  return next({ context: { user: session.data.user } });
});
GuardWhereDocument without a sessionWith one
a path in the request list: { route: "/admin/**", handler: requireLogin }middleware of defineServerthe middleware throws redirect("/login"): a 302, and no page HTML; with createError({ status: 401 }) a 401 with the error page, JSON for Accept: application/json200
the handler itself: middleware: [requireLogin]the definitionits endpoint answers with the error, the row never readthe row
a load calling a handler that holds the guardx.load.tsthe handler's status or redirect becomes the document's, before any page HTML200
a route middleware that redirectsapp/middleware, named in the page's metaa 302 on the server; in the browser a navigation to the targetthe page

The first row protects the document: it runs before the router and any render, and no client can skip it. A client navigation sends no document request, so the first row does not run for it; that is why the second row is the one that counts. It refuses each call, a kept parent() data and an expired session included:

import { defineHandler } from "silgi/server";
import { requireLogin } from "./guards/login.ts";

export const listUsers = defineHandler({
  method: "GET",
  middleware: [requireLogin],
  handler: () => [{ id: 1, name: "ada" }],
});

For the redirect a visitor sees on a navigation, name a route middleware in the page's meta: it asks the shared state the same question through a handler, as Forms and login shows. It is a convenience for the visitor and nothing more. A guard in useAsyncData is not one either: in a streamed render it runs after the shell was sent, so the status can no longer change.

CSRF

csrf(event, { secret, session }) protects a form post. It needs a secret of at least 32 characters and throws a TypeError for a missing or shorter one. On a safe method (GET, HEAD, OPTIONS, TRACE, QUERY) it returns the token and sets it in a cookie. A cross-origin QUERY is allowed only when CORS methods lists it. On any other method it checks that the cookie token and the token the request sends are the same, and throws a 403 Invalid CSRF token when they are not. The request sends the token in the x-csrf-token header or in the _csrf field of a form body.

import { csrf, defineHandler, readBody, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;
const secret = process.env.CSRF_SECRET as string;

export const account = defineHandler({
  route: "/account",
  method: "*",
  raw: true,
  async handler(_, { event }) {
    const session = await useSession(event, { password });
    const token = await csrf(event, { secret, session });
    if (event.method === "GET") {
      return `<form method="post"><input type="hidden" name="_csrf" value="${token}"><button>Save</button></form>`;
    }
    const form = await readBody<Record<string, string>>(event);
    return { saved: Object.keys(form ?? {}).filter((key) => key !== "_csrf") };
  },
});

Always pass session, the awaited result of useSession. The token is then bound to the session, so a token issued for one session is rejected for another. regenerate() gives the session a new id and keeps its data, and clear() gives it a new id and empties it; either one invalidates every older token. Call regenerate() on login, as the login sample above does, so the tokens and the session id from before the login stop working. A visitor who gets a form also gets a session cookie. Fetching the token with useFetch("/api/csrf") works on a direct load too: the session and CSRF cookies the route sets while the page renders on the server are sent with the page. Without session, the token is signed with the secret alone, and any token that was ever issued is valid for every visitor, which lets a sibling subdomain plant a matching cookie and field.

On https the cookie is named __Host-silgi-csrf and is Secure; the browser refuses to set it from a sibling subdomain, and a plain silgi-csrf cookie is ignored. On plain http the name is silgi-csrf and the first request logs a warning. Tokens last 24 hours.

CORS

handleCors(event, options) answers a preflight request and sets the CORS headers. Return its result from a request middleware: it is a 204 response for a preflight and undefined for anything else, so the request goes on. List the middleware in the middleware of defineServer in server/index.ts.

import { defineMiddleware, handleCors } from "silgi/server";

export const cors = defineMiddleware(({ event }) =>
  handleCors(event, {
    origin: ["https://app.example.com"],
    methods: ["GET", "POST"],
    credentials: true,
    maxAge: 600,
  }),
);
OptionMeaning
origin"*" (default), one origin, a list, or a function that gets the request's Origin
methodsallowed methods; the default is GET, HEAD, PUT, PATCH, POST, DELETE
headersallowed request headers; by default the requested ones are echoed
credentialsallow credentials; it cannot be combined with origin: "*"
maxAgeseconds a preflight answer may be cached

An origin matches only when scheme, host and port all match. The route rule cors does the same for every path under a pattern, ahead of server middleware; cors: true uses the defaults. When a request ends in an error, the CORS headers are copied onto the error response, so the browser can read it.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  routeRules: {
    "/api/**": { cors: { origin: "https://app.example.com", credentials: true, maxAge: 600 } },
  },
});

Security defaults

Every answer of the server carries three headers, unless the answer already has a header of that name:

HeaderValue
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
X-Frame-OptionsSAMEORIGIN

A handler's or middleware's setHeader(event, "x-frame-options", "DENY") wins, and so does a proxied upstream's header. For one path, a route rule's headers replaces a header with a string or removes it with false. For the whole app, server.securityHeaders takes false (no headers) or an object merged over the three:

// silgi.config.ts
export default defineConfig({
  server: { securityHeaders: { "permissions-policy": "camera=()" } },
  routeRules: { "/embed/**": { headers: { "x-frame-options": false } } },
});

The static, vercel, netlify and cloudflare presets write the same headers into the files the platform serves. On Cloudflare a rule's false or string replaces the default in those files too. On Netlify and with the static preset the host joins the values of every matching block, so a rule cannot remove or replace a default header there: the build warns, and server.securityHeaders with that header set to false leaves it to your rules. The server's own answers always follow the rule.

The server also refuses a cross-origin form post. A POST, PUT, PATCH or DELETE with no Content-Type, or with application/x-www-form-urlencoded, multipart/form-data or text/plain, gets a 403 Cross-origin request blocked when Sec-Fetch-Site says another site sent it or, without that header, when Origin names another host. Same-origin requests, JSON requests, requests from curl or a server, in-process $fetch and paths whose route rule has cors all pass. An identity provider that posts its callback form to your app needs its origin listed:

// silgi.config.ts
export default defineConfig({
  server: { originCheck: { allow: ["https://appleid.apple.com"] } },
});

server.originCheck: false turns the check off. It does not replace csrf(); both can run.

For a nonce-based Content Security Policy, set event.context.nonce in a request middleware (the middleware list of defineServer) and send the header yourself. Silgi writes the nonce on the import map, the payload and entry scripts, each modulepreload link and each useHead script without a nonce of its own, and adds <meta property="csp-nonce"> so the client's preload links get it too.

// server/guards/csp.ts
import { createNonce, defineMiddleware, setHeader } from "silgi/server";

export const csp = defineMiddleware(({ event }) => {
  const nonce = createNonce();
  event.context.nonce = nonce;
  setHeader(event, "content-security-policy", `script-src 'nonce-${nonce}' 'strict-dynamic'`);
});

A page with a nonce answers cache-control: no-store, even when a route rule sets cache-control for its path, so no cache serves one nonce to every visitor; silgi dev warns about such a rule. A prerendered page cannot carry a nonce, and silgi generate fails such a path. For prerendered pages, allow the import map by its sha256-… source instead of a nonce.