Sessions

The @bext-stack/framework/auth entry point provides createSession: a session held entirely in a signed (HMAC-SHA256), timestamped cookie. Nothing is stored on the server: the cookie carries the data, its issue time and its expiry, and the signature prevents tampering. Signing is done in pure JavaScript, with no node:crypto or WebCrypto, so it works in every loader, action and route.

Creating a session

Declare the session once in a shared module, then import it from your routes:

// src/lib/session.ts
import { createSession } from "@bext-stack/framework/auth";

export const session = createSession<{ userId: string; role: string }>({
  secret: process.env.SESSION_SECRET!,
  cookie: "myapp_session",   // default "bext_session"
  maxAgeSecs: 7 * 24 * 3600, // default 86400 (1 day)
});
Option Default Purpose
secret (required) HMAC-SHA256 signing key
cookie "bext_session" Cookie name
maxAgeSecs 86400 (1 day) Lifetime, written into the signed data and used as the cookie Max-Age
sameSite "Lax" "Lax", "Strict" or "None"
secure true Pass false to drop the Secure attribute
path "/" Cookie Path attribute

The cookie is always HttpOnly: page JavaScript cannot read it. Read the secret from process.env (see Secrets) and never write it in your code.

API

createSession<T>(options) returns a Session<T> object:

Member Description
read(request) Reads the request cookie and checks signature and expiry. Returns the data, or null when the cookie is missing, tampered with or expired.
cookie(data) Returns a Set-Cookie header value that opens the session with data.
clearCookie() Returns a Set-Cookie header value that clears the cookie (Max-Age=0).
issue(data) Returns the signed token alone, without a cookie.
verify(token) Checks a token produced by issue. Returns the data or null.
cookieName The cookie name in use.

read and verify return the data as you passed it: the iat and exp fields added at issue time are removed.

Example: sign-in and sign-out

A sign-in page whose action checks the password, then opens the session:

// src/app/login/page.tsx
import { password } from "@bext-stack/framework/password";
import { session } from "@/lib/session";

export const dynamic = "force-dynamic";

export function loader({ request }) {
  // Already signed in: skip the form.
  if (session.read(request)) {
    throw new Response(null, { status: 303, headers: { Location: "/account" } });
  }
  return {};
}

export async function action({ request }) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "");
  const user = await findUserByEmail(email); // your own lookup
  const ok = user && (await password.verify(String(form.get("password") ?? ""), user.passwordHash));
  if (!ok) return { error: "Wrong email or password." };

  return new Response(null, {
    status: 303,
    headers: {
      Location: "/account",
      "Set-Cookie": session.cookie({ userId: user.id, role: user.role }),
    },
  });
}

export default function Page({ actionData }) {
  return (
    <form method="post">
      {actionData?.error && <p>{actionData.error}</p>}
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Sign in</button>
    </form>
  );
}

A protected page reads the session in its loader and handles sign-out in its action:

// src/app/account/page.tsx
import { session } from "@/lib/session";

export const dynamic = "force-dynamic";

export function loader({ request }) {
  const user = session.read(request);
  if (!user) throw new Response(null, { status: 303, headers: { Location: "/login" } });
  return { user };
}

export function action() {
  // Sign-out: clear the cookie and go back to the sign-in page.
  return new Response(null, {
    status: 303,
    headers: { Location: "/login", "Set-Cookie": session.clearCookie() },
  });
}

export default function Page({ data }) {
  return (
    <form method="post">
      <p>Signed in: {data.user.userId}</p>
      <button type="submit">Sign out</button>
    </form>
  );
}

The page component never sees request: always read the session in the loader or action, and pass the page what it needs to display (see Loaders & Actions).

Expiry

  • Expiry is fixed: exp is the issue time plus maxAgeSecs. Reading the session does not extend it. To extend it, send a fresh session.cookie(data) in a response, for example at the end of an action.
  • It is checked on the server: a token copied from the browser and replayed after exp is rejected, even if the cookie was never cleared.
  • Changing maxAgeSecs only affects sessions issued afterwards.

Things to know

  • Signed, not encrypted. The data is base64url-encoded JSON, readable by anyone holding the cookie. Store an identifier and what helps you decide quickly (a role, a language), never a password, a third-party token or personal data. Keep it short: it travels with every request.
  • No server-side revocation. clearCookie() removes the cookie from the browser, but a copied token stays valid until its exp. Changing secret invalidates every session at once. To sign out a single user, keep a version number in your database, put it in the session and compare it in the loader.
  • A long, random secret. Anyone who knows the secret can forge a valid session.
  • JSON data. The data goes through JSON.stringify: a Date comes back as a string. Do not use iat or exp as field names, they are reserved.
  • Data type. T must be an object with string keys. A type alias or a type literal ({ userId: string }) works; an interface must extend Record<string, unknown>.
  • Cookie name and caching. In ISR mode, requests carrying a session cookie skip the page cache. A name ending in session (such as bext_session or myapp_session) is recognised as one; with another name, or to be safe, add export const dynamic = "force-dynamic" to pages that depend on the session (see Routing & Rendering).
  • Same settings to clear. clearCookie() reuses the session's cookie, path, sameSite and secure: use the same session object to open and close it.
  • SameSite=None requires Secure in browsers: do not combine sameSite: "None" with secure: false.

Other helpers

The same entry point exports the building blocks behind createSession:

Function Purpose
parseCookies(header) Turns a Cookie header into a { name: value } object (values decoded).
serializeCookie(name, value, options?) Builds a Set-Cookie value. Options: path ("/"), maxAge, domain, sameSite ("Lax"), secure (true), httpOnly (true).
createSigner(secret) Returns { sign(payload), verify(payload, signature) } using HMAC-SHA256, base64url signature.
timingSafeEqual(a, b) Compares two strings in constant time.

serializeCookie is handy for a cookie that is not a session (a preference, a cart) or to set domain, which createSession does not offer.

Where to next

Page What it covers
Loaders & Actions Where to read the request, redirects with Response
Secrets Providing SESSION_SECRET to your app
Routing & Rendering Page cache, force-dynamic