GioJSdocs
On this page

Authentication

Encrypted cookie sessions, session guards verified in Rust, and secure cookie helpers.

GioJS ships the primitives a login needs, with secure defaults and no extra dependencies:

  • createSessionStorage (from @gio.js/core) - sessions stored in an encrypted, signed cookie. No session database to run.
  • require_session guards - [[guards]] in gio.toml or middleware.ts that verify the session's signature and expiry in the Rust HTTP layer, before any Node code runs.
  • serializeCookie, parseCookies, signValue, unsignValue - for every other cookie.

For a working starting point, npx create-giojs add auth (or --auth when creating the app) adds a login page, logout, and a guarded /dashboard built from these pieces - see the authentication example.

1. Set a session secret

Sessions are encrypted and signed with keys derived from GIO_SESSION_SECRET. Generate a secret of at least 32 bytes:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Set it in the server's environment, or in a git-ignored .env.production.local (the server loads .env files at startup, and the worker inherits them). Never commit it.

  • Production without a secret: createSessionStorage() throws with the command above, and every require_session guard denies all requests (fail closed) with an error in the server log. Called at module scope (as in lib/session.server.ts), the throw happens when a module imports it: each page and route.ts that does answers 500 with an error digest, and the log line under that digest names the file and the missing secret.
  • Development without a secret: the server generates an ephemeral one and shares it with the worker, so logins work out of the box and survive worker restarts, but reset when the server restarts. A warning says so.
  • Tests (@gio.js/core/testing, which runs in production mode under vitest's NODE_ENV=test): the kit sets a random secret for the test process when none is set - see Testing.

2. Create a session storage

lib/session.server.ts
import { createSessionStorage } from '@gio.js/core';

interface UserSession {
  userId: string;
  name: string;
}

export const sessions = createSessionStorage<UserSession>({
  // all optional:
  // cookieName: 'gio_session',
  // maxAge: 60 * 60 * 24 * 7,           // seconds; carried in the token and the cookie
  // cookie: { sameSite: 'strict' },     // serializeCookie options
});

Name the module *.server.ts: it is then guaranteed never to reach a client bundle - a page that used it outside getServerSideProps would fail its client build instead of shipping your session code to the browser.

Keys come from GIO_SESSION_SECRET unless you pass a secrets option. Leave that option unset for any storage a guard checks: require_session guards verify with GIO_SESSION_SECRET only (in development without one, the server's ephemeral secret), so sessions signed with other secrets are turned away on every request and the login loops back to redirect_to. Node logs a warning when a secrets option signs with a key GIO_SESSION_SECRET does not contain. To rotate, put several secrets in GIO_SESSION_SECRET (see Rotating secrets below).

3. Log in

getSession reads the request's session (an empty, new one when there is none), and commitSession encrypts it into a Set-Cookie value:

app/api/login/route.ts
import type { GioRequest } from '@gio.js/core';
import { sessions } from '../../../lib/session.server.ts';

export async function POST(req: GioRequest) {
  const form = new URLSearchParams(req.body ?? '');
  const user = await verifyPassword(form.get('email'), form.get('password'));
  if (!user) {
    return new Response(null, { status: 303, headers: { Location: '/login?error=1' } });
  }
  const session = sessions.getSession(req);
  session.set('userId', user.id);
  session.set('name', user.name);
  return new Response(null, {
    status: 303,
    headers: { Location: '/dashboard', 'Set-Cookie': sessions.commitSession(session) },
  });
}

From getServerSideProps, return the cookie in headers: { 'set-cookie': sessions.commitSession(session) }. Every commitSession re-encrypts the data with a fresh expiry, so committing on each visit gives a rolling session.

4. Protect pages with a guard

gio.toml
[[guards]]
path            = "/dashboard/*rest"
require_session = true
redirect_to     = "/login"
ts
// middleware.ts - the same rule
import { defineMiddleware } from '@gio.js/core';

export default defineMiddleware({
  guards: [{ path: '/dashboard/*rest', requireSession: true, redirectTo: '/login' }],
});

The guard reads the gio_session cookie (set require_cookie / requireCookie when your storage uses another cookieName), checks its HMAC against every secret in GIO_SESSION_SECRET in constant time - never against a storage's secrets option - and checks its expiry. A missing, forged, tampered, or expired session gets a 302 to redirect_to and never reaches Node. Rust only verifies - it never decrypts, so the encryption key stays in the worker. A guard with require_cookie alone still only checks that the cookie exists; see Middleware for patterns and evaluation order.

A broken guard never leaves its path open: a gio.toml guard with a misspelled key or no requirement stops the server at startup, and so does a middleware.ts guard that is malformed, or a middleware.ts that throws while it loads - the worker refuses to boot, naming the problem.

5. Read the session

app/dashboard/page.tsx
import { sessions } from '../../lib/session.server.ts';

export async function getServerSideProps(ctx) {
  const session = sessions.getSession(ctx);
  return { props: { name: session.get('name') ?? 'guest' } };
}

getSession accepts the getServerSideProps context, a route.ts GioRequest, a plugin's request, a web Request, or a raw Cookie header. On a page it reads ctx.cookies, which marks the render personalized: it is never cached and served to another visitor, even with export const revalidate. A guard proves the session is authentic; checks on what is inside it (roles, a disabled account) belong here, in the page: a plugin's onRequest does not run when the Rust cache answers, and a page that exports revalidate without reading the session is cached and served to anyone the guard lets through.

A session exposes get(key), set(key, value), unset(key), has(key), a data snapshot, and isNew (true when the request had no valid session). Values must be JSON-serializable. Expired, tampered, or foreign cookies simply produce an empty new session - getSession never throws on bad input.

6. Log out

app/api/logout/route.ts
import { sessions } from '../../../lib/session.server.ts';

export function POST() {
  return new Response(null, {
    status: 303,
    headers: { Location: '/', 'Set-Cookie': sessions.destroySession() },
  });
}
Cookie sessions are stateless: logging out deletes the browser's cookie, but a copy of it stays valid until it expires. If logout must end every copy (a stolen laptop, a password change), keep maxAge short, or store a per-user session version server-side, put it in the session, and compare the two when you read it.

Rotating secrets

GIO_SESSION_SECRET takes several comma-separated secrets. The first one encrypts and signs new sessions; all of them are accepted when reading, in Node and in the Rust guards alike. To rotate without logging everyone out:

  1. Prepend the new secret: GIO_SESSION_SECRET=new,old, and restart.
  2. Sessions are re-signed with new whenever they are committed.
  3. After maxAge has passed, remove old.

Removing a secret immediately invalidates every session it signed - the way to log everyone out after a leak.

Limits

  • The whole session lives in the cookie, and browsers drop cookies over 4096 bytes: commitSession throws a clear error instead of silently losing the session. Keep ids and small flags in it; look the rest up server-side.
  • Changing cookieName logs everyone out: the cookie name is bound into every token, so a session cannot be replayed under another cookie that shares the secret.

CSRF

Session cookies are SameSite=Lax by default, so browsers leave them off cross-site POST, fetch, and iframe requests - a malicious site cannot submit a form as your logged-in user. Keep every state change behind POST, PUT, PATCH, or DELETE (never GET, which top-level navigations send cookies with). SameSite is the first layer. The Rust server's CSRF protection, on by default, adds a second: it refuses cross-site requests with those methods before they reach your code. sameSite: 'strict' is available when even links from other sites should arrive logged out.

Cookies

serializeCookie(name, value, options?) builds one Set-Cookie value. Its defaults are the secure ones:

OptionDefaultNotes
path/Must start with /.
httpOnlytrueScripts cannot read the cookie.
secureon in productionOff in development (plain-http localhost), except for __Secure-/__Host- names, sameSite: 'none' and partitioned, which browsers accept only with it (Chrome and Firefox take Secure cookies from http://localhost). Pass secure: false explicitly to serve production over http.
sameSite'lax''strict' or 'none' (requires secure).
maxAge / expires-Whole seconds / a Date. Neither means a browser-session cookie; maxAge: 0 deletes.
domain-Host-only when omitted.
partitionedfalseCHIPS; requires secure.
ts
import { serializeCookie } from '@gio.js/core';

headers.append('Set-Cookie', serializeCookie('theme', 'dark', { httpOnly: false, maxAge: 31536000 }));
headers.append('Set-Cookie', serializeCookie('theme', '', { maxAge: 0 }));   // delete
headers.append('Set-Cookie', serializeCookie('q', encodeURIComponent(search)));

Names and values are validated against RFC 6265 and anything invalid throws - a value with ;, a space, a quote, or a line break could otherwise smuggle extra attributes or headers into the response. Values are not encoded for you: encode free text yourself, as above. Combinations browsers would silently reject also throw - an explicit secure: false with a __Secure- or __Host- name, sameSite: 'none', or partitioned, or a __Host- cookie with a domain or a path other than /. parseCookies(header) is the parser behind ctx.cookies and req.cookies; read its result with Object.hasOwn when the cookie name comes from elsewhere, since a plain object also "has" constructor.

Signed values

For a value that must not be forged but may be read - an id in a URL, a preference cookie:

ts
import { signValue, unsignValue } from '@gio.js/core';

const signed = signValue('user-42', secrets);   // "user-42.<HMAC-SHA256, base64url>"
unsignValue(signed, secrets);                   // "user-42"
unsignValue('user-43.' + signed.split('.')[1], secrets);   // null

secrets is a string or an array (the first signs, all verify), each at least 32 bytes. Signatures are compared in constant time. The value itself stays readable - use a session for anything secret.

Token format

For reference only - the format is internal and may change behind a new version tag:

text
v1.<exp>.<payload>.<mac>

exp      expiry, unix seconds
payload  base64url( iv[12] | AES-256-GCM(JSON data) | tag[16] ),  AAD = "<cookie name>\nv1.<exp>"
mac      base64url( HMAC-SHA256(macKey, "<cookie name>\nv1.<exp>.<payload>") )

encKey / macKey = HKDF-SHA256(secret, salt = "", info = "gio-session-enc" / "gio-session-mac")

The expiry travels in clear and the MAC uses its own key, which is what lets the Rust guards verify authenticity and expiry without being able to decrypt.