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:
expis the issue time plusmaxAgeSecs. Reading the session does not extend it. To extend it, send a freshsession.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
expis rejected, even if the cookie was never cleared. - Changing
maxAgeSecsonly 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 itsexp. Changingsecretinvalidates 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: aDatecomes back as a string. Do not useiatorexpas field names, they are reserved. - Data type.
Tmust be an object with string keys. A type alias or a type literal ({ userId: string }) works; aninterfacemust extendRecord<string, unknown>. - Cookie name and caching. In ISR mode, requests carrying a session cookie skip the page cache.
A name ending in
session(such asbext_sessionormyapp_session) is recognised as one; with another name, or to be safe, addexport const dynamic = "force-dynamic"to pages that depend on the session (see Routing & Rendering). - Same settings to clear.
clearCookie()reuses the session'scookie,path,sameSiteandsecure: use the samesessionobject to open and close it. SameSite=NonerequiresSecurein browsers: do not combinesameSite: "None"withsecure: 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 |