ReferenceRequest & Body
@nextrush/cookies

Cookies

Secure cookie parsing and serialization with signing support.

Handle HTTP cookies with secure defaults and signing support.

Source & internals

Cookies are essential for sessions, authentication, and client-side state. This middleware provides parsing, setting, and optional HMAC-SHA256 signing with RFC 6265 compliance.


Installation

$ pnpm add @nextrush/cookies

Quick Start

import { createApp } from '@nextrush/core';
import { cookies } from '@nextrush/cookies';

const app = createApp();

app.use(cookies());

app.use(async (ctx) => {
  // Read a cookie
  const session = ctx.cookies.get('session');

  // Set a cookie (secure defaults: httpOnly, sameSite=lax, path=/, secure='auto')
  ctx.cookies.set('session', 'user-123', {
    maxAge: 86400,
  });

  ctx.json({ session });
});

ctx.cookies is fully typed — no casts. Using it without the middleware throws an actionable CapabilityNotInitializedError instead of a TypeError.


Default Behavior

With default options, cookies() applies these defaults to every cookie you set:

  • httpOnly: true — blocks JavaScript access via document.cookie
  • sameSite: 'lax' — limits cross-site cookie transmission and provides baseline CSRF mitigation (not complete protection; see @nextrush/csrf)
  • path: '/' — scopes the cookie to the entire domain
  • secure: 'auto' — emits Secure on every request except plaintext loopback (localhost/127.0.0.1/::1). This is the same as secure: true in production behind HTTPS, and it is not false in dev — see Setting cookies on HTTP.

Values are URL-decoded and sanitized (CRLF characters stripped) during parsing.

set() and delete() write the Set-Cookie header immediately, in the same call — NextRush commits the response the moment the handler sends a body, so there is no later "flush cookies" step. The cookie is also readable through get() for the rest of the same request.


Reading Cookies

app.use(async (ctx) => {
  // Get a single cookie
  const token = ctx.cookies.get('token');

  // Get all parsed cookies
  const all = ctx.cookies.all();

  // Check if a cookie exists
  const hasSession = ctx.cookies.has('session');

  ctx.json({ token, all, hasSession });
});

Setting Cookies

app.use(async (ctx) => {
  // Uses secure defaults (httpOnly, sameSite=lax, path=/, secure='auto')
  ctx.cookies.set('name', 'value');

  // Override specific options
  ctx.cookies.set('session', 'user-123', {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 86400,
    path: '/',
    domain: '.example.com',
  });

  ctx.json({ ok: true });
});

Configuration Options

CookieOptions

PropertyTypeDescription
httpOnlyboolean= trueBlock JavaScript access via document.cookie
secure'auto' | boolean= 'auto'Emit the Secure attribute. "auto" (default) emits it except on plaintext loopback (localhost/127.0.0.1). Explicit true emits it always; explicit false suppresses it. Required for SameSite=None and cookie prefixes.
sameSite'strict' | 'lax' | 'none' | boolean= 'lax'Baseline CSRF mitigation by limiting cross-site transmission (not complete protection)
maxAge?numberLifetime in seconds. Use 0 to expire immediately.
expires?Date | numberExpiration date. If both maxAge and expires are set, maxAge takes precedence.
pathstring= '/'URL path scope for the cookie
domain?stringDomain scope. Leading dots are ignored.
priority?'low' | 'medium' | 'high'Browser priority hint (Chrome extension)
partitioned?booleanPartition by top-level site for third-party cookies (CHIPS)

maxAge is in seconds, not milliseconds

maxAge: 604800 is 7 days. A common bug is passing milliseconds (maxAge: 1000 * 60 * 60 * 24 * 7 = 604800000), which the middleware floors to Max-Age=604800000 — a cookie that effectively never expires.


Deleting Cookies

app.use(async (ctx) => {
  ctx.cookies.delete('session');

  // Path and domain must match the original cookie
  ctx.cookies.delete('session', {
    path: '/',
    domain: '.example.com',
  });

  ctx.json({ ok: true });
});

Secure Presets

Both presets are functions that accept additional options and return merged CookieOptions.

secureOptions()

Returns options for production-grade cookies.

import { cookies, secureOptions } from '@nextrush/cookies';

app.use(cookies());

app.use(async (ctx) => {
  ctx.cookies.set('auth', 'token', secureOptions({ maxAge: 86400 }));
});

Produces: httpOnly: true, secure: true, sameSite: 'strict', path: '/'.

sessionOptions()

Returns options for session cookies that expire when the browser closes.

import { cookies, sessionOptions } from '@nextrush/cookies';

app.use(cookies());

app.use(async (ctx) => {
  ctx.cookies.set('session', 'user-123', sessionOptions());
});

Produces: httpOnly: true, sameSite: 'lax', path: '/'. No maxAge or expires.


Signed Cookies

Prevent cookie tampering with HMAC-SHA256 signatures. Signed cookies are a sub-capability of ctx.cookies — signedCookies() requires cookies() to be registered first.

import { cookies, signedCookies } from '@nextrush/cookies';

app.use(cookies());
app.use(
  signedCookies({
    secret: process.env.COOKIE_SECRET!,
  })
);

app.use(async (ctx) => {
  // Set a signed cookie (async)
  await ctx.cookies.signed.set('user', 'user-123', { httpOnly: true });

  // Get and verify a signed cookie (async)
  const user = await ctx.cookies.signed.get('user');
  // Returns undefined if the signature is invalid or the cookie is missing

  ctx.json({ user });
});

Signed cookie operations (get and set) are async. Forgetting await returns a Promise, not the value.

The signing secret must be kept secure and rotated periodically.

Never Hardcode Secrets

Store COOKIE_SECRET in environment variables. Rotate keys periodically using previousSecrets for zero-downtime migration. Never commit signing keys to source control.

Key Rotation

Provide previous secrets to verify cookies signed with older keys during rotation:

app.use(cookies());
app.use(
  signedCookies({
    secret: process.env.COOKIE_SECRET_NEW!,
    previousSecrets: [process.env.COOKIE_SECRET_OLD!],
  })
);

New cookies are signed with secret. Verification tries secret first, then each entry in previousSecrets in order. At most 10 previous secrets are accepted.


Security prefixes enforce browser-level constraints.

__Secure- Prefix

Requires secure: true. The helper auto-sets this flag.

import { createSecurePrefixCookie } from '@nextrush/cookies';

const header = createSecurePrefixCookie('token', 'abc123', { httpOnly: true });
// '__Secure-token=abc123; Secure; HttpOnly'

__Host- Prefix

Requires secure: true, path: '/', and no domain. The helper enforces all three.

import { createHostPrefixCookie } from '@nextrush/cookies';

const header = createHostPrefixCookie('session', 'abc123', { httpOnly: true });
// '__Host-session=abc123; Secure; Path=/; HttpOnly'

Parsing Utilities

Low-level cookie parsing for use outside the middleware:

import { parseCookies, getCookie, getCookieNames, hasCookie } from '@nextrush/cookies';

const cookies = parseCookies('name=value; session=abc');
// { name: 'value', session: 'abc' }

const session = getCookie('name=value; session=abc', 'session');
// 'abc'

const names = getCookieNames('name=value; session=abc');
// ['name', 'session']

const exists = hasCookie('name=value', 'name');
// true

Serialization Utilities

import { serializeCookie, createDeleteCookie } from '@nextrush/cookies';

const header = serializeCookie('name', 'value', {
  httpOnly: true,
  secure: true,
});

const deleteHeader = createDeleteCookie('name', { path: '/' });

Signing Utilities

Manual cookie signing without the middleware:

import { signCookie, unsignCookie, unsignCookieWithRotation } from '@nextrush/cookies';

const signed = await signCookie('token', 'value', 'secret-key');
// 'value.issuedAt.SIGNATURE'

const value = await unsignCookie('token', signed, 'secret-key');
// 'value' or undefined if tampered/expired

const rotated = await unsignCookieWithRotation('token', signed, {
  current: 'new-key',
  previous: ['old-key'],
});

The first argument to every sign/unsign function is the cookie name — the signature is bound to it (SEC-07), so a value signed for one cookie cannot be replayed under another.


Typing

ctx.cookies is fully typed on Context (RFC-034) — no casts, no ?., no if guards:

app.use(cookies());

app.post('/login', (ctx) => {
  ctx.cookies.set('token', 'abc123', { httpOnly: true, maxAge: 604800 });
  const token = ctx.cookies.get('token'); // string | undefined
  ctx.json({ ok: true });
});

Signed cookies live under the same capability with their own typed surface:

app.use(cookies());
app.use(signedCookies({ secret: process.env.COOKIE_SECRET! }));

app.get('/me', async (ctx) => {
  const userId = await ctx.cookies.signed.get('userId'); // string | undefined
  ctx.json({ userId });
});

The capability is always present — activation is the middleware's job

Before cookies() runs, ctx.cookies exists but every operation throws CapabilityNotInitializedError (code: 'COOKIES_NOT_INITIALIZED') with a WHAT/WHY/HOW/WHERE diagnostic. ctx.cookies.signed throws SIGNED_COOKIES_NOT_INITIALIZED until signedCookies() runs, and signedCookies() without cookies() fails loudly at request time.

Deprecated: ctx.state.cookies

ctx.state.cookies / ctx.state.signedCookies still work as aliases for one release cycle (with a once-per-process warning), and are removed in the next major. Migrate to ctx.cookies / ctx.cookies.signed.

Serving a frontend on another origin (SPA)

Cookies are per-origin. When your frontend (http://localhost:5173) calls the API (http://localhost:8080) — or any two different ports/hosts — the browser treats them as cross-origin, and Set-Cookie from the response is silently discarded unless both sides opt in:

// 1. Server — enable credentials with an explicit origin (NOT '*'):
import { cors } from '@nextrush/cors';

app.use(
  cors({
    origin: 'http://localhost:5173',
    credentials: true, // emits Access-Control-Allow-Credentials: true
  })
);
app.use(cookies());
// 2. Client — the fetch must include credentials:
fetch('http://localhost:8080/auth/login', {
  method: 'POST',
  credentials: 'include', // sends + stores cookies cross-origin
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password }),
});

Both are required — missing either one means the browser never stores the cookie.

Three more things that trip people up:

  • httpOnly cookies never appear in document.cookie — check DevTools → Application → Cookies instead. That's the feature, not a bug.
  • SameSite is about site, not origin. 'strict' blocks the cookie on any cross-site request (a different registrable domain), including top-level navigations. 'lax' (the default) allows it on top-level GET navigations. For API calls from a different site you need 'lax'/'none' + Secure, never 'strict'.
  • A missing credentials: 'include' fails silently — no error, the browser does not store the cookie. It's the first thing to check.

Common Mistakes

// Wrong — returns a Promise, not the value
const userId = ctx.cookies.signed.get('userId');

// Correct
const userId = await ctx.cookies.signed.get('userId');
// ❌ Wrong — an object (or number, or undefined) becomes '' with NO error.
// The browser stores an empty 'token=' cookie and it looks like nothing happened.
cookiesApi.set('token', loginResult.token, { httpOnly: true });

// ✅ Correct — pass a string. If the value is nested, pass the specific field.
cookiesApi.set('token', loginResult.token.accessToken, { httpOnly: true });

sanitizeCookieValue() returns '' for anything that isn't a string, so the middleware never throws — the failure is a silent empty cookie. If a cookie "doesn't appear" in the browser, check the response's Set-Cookie header (e.g. curl -i) and confirm the value is actually non-empty.

Setting secure cookies on HTTP

// The default is secure: 'auto' — Secure is emitted EXCEPT on plaintext loopback
// (localhost/127.0.0.1/::1). So in local dev over http://localhost this works fine.
cookiesApi.set('session', 'value', { maxAge: 3600 }); // ok on localhost

// ❌ Browser drops Secure cookies on plaintext http:// for any NON-loopback host
// (LAN IP, Docker, a .local hostname). It fails closed to Secure, by design.
cookiesApi.set('session', 'value', { secure: true }); // dropped on http://192.168.1.10:8080

On a real deployment, serve over HTTPS and let the default 'auto' emit Secure. Only reach for an explicit secure: false when you have a specific reason — it overrides the safe default.

Calling cookies() but reading signedCookies (or vice versa)

// ❌ cookies() alone does NOT activate ctx.cookies.signed — it throws
// SIGNED_COOKIES_NOT_INITIALIZED.
app.use(cookies());
await ctx.cookies.signed.get('user'); // CapabilityNotInitializedError

// ✅ signedCookies() activates ctx.cookies.signed — but requires cookies() first.
app.use(cookies());
app.use(signedCookies({ secret: process.env.COOKIE_SECRET! }));
await ctx.cookies.signed.get('user');

Using SameSite=None without Secure

// Browsers reject this combination
ctx.cookies.set('cross', 'value', { sameSite: 'none' });

// Correct
ctx.cookies.set('cross', 'value', { sameSite: 'none', secure: true });

Troubleshooting

Cookie not appearing in the browser — work through this checklist:

  1. Set-Cookie present in the response? Check with curl -i http://localhost:8080/login -X POST ... — if the header is missing, the handler never ran set().
  2. Is the value non-empty? Passing an object/number/undefined silently produces an empty cookie (see Common Mistakes).
  3. Cross-origin? Both credentials: 'include' on the fetch and cors({ credentials: true, origin: '<exact origin>' }) on the server are required, or the browser discards Set-Cookie (see Serving a frontend on another origin).
  4. Is it secure on plaintext HTTP for a non-loopback host (LAN IP, Docker, .local)? Browsers drop those. On localhost HTTP, 'auto' handles it.
  5. Is it SameSite=Strict and the request is cross-site (different registrable domain)? 'strict' means the browser never sends it on cross-site requests.
  6. httpOnly? Then it won't show in document.cookie — look in DevTools → Application → Cookies.

Signed cookie returns undefined: The signature verification failed. Confirm the secret matches the one used to sign the cookie. If you rotated keys, pass the old keys in previousSecrets.

SecurityError: Cookie with __Host- prefix must have Path set to "/": __Host- prefixed cookies require secure: true, path: '/', and no domain attribute. Use createHostPrefixCookie() to handle these constraints automatically.

CapabilityNotInitializedError (code COOKIES_NOT_INITIALIZED): The cookie middleware has not run for this request — it is not registered, is registered after this route, or is conditionally skipped. Register app.use(cookies()) before the routes that use cookies. SIGNED_COOKIES_NOT_INITIALIZED means signedCookies() has not run (and signedCookies() requires cookies() first).


Migration: ctx.state.cookies → ctx.cookies

// Before (deprecated — works for one release with a once-per-process warning)
const session = ctx.state.cookies.get('session');
ctx.state.cookies.set('session', value, { httpOnly: true });
await ctx.state.signedCookies.get('user');
const cookieApi = ctx.state.cookies as CookieContext; // the old unknown-type cast

// After
const session = ctx.cookies.get('session');
ctx.cookies.set('session', value, { httpOnly: true });
await ctx.cookies.signed.get('user'); // fully typed — no cast needed

The ctx.state.cookies / ctx.state.signedCookies aliases are removed in the next major release.


Was this helpful?

On this page