@nextrush/cookiesCookies
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 viadocument.cookiesameSite: 'lax'— limits cross-site cookie transmission and provides baseline CSRF mitigation (not complete protection; see @nextrush/csrf)path: '/'— scopes the cookie to the entire domainsecure: 'auto'— emitsSecureon every request except plaintext loopback (localhost/127.0.0.1/::1). This is the same assecure: truein production behind HTTPS, and it is notfalsein 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
| Property | Type | Description |
|---|---|---|
httpOnly | boolean= true | Block 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? | number | Lifetime in seconds. Use 0 to expire immediately. |
expires? | Date | number | Expiration date. If both maxAge and expires are set, maxAge takes precedence. |
path | string= '/' | URL path scope for the cookie |
domain? | string | Domain scope. Leading dots are ignored. |
priority? | 'low' | 'medium' | 'high' | Browser priority hint (Chrome extension) |
partitioned? | boolean | Partition 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.
Cookie Prefixes
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');
// trueSerialization 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:
httpOnlycookies never appear indocument.cookie— check DevTools → Application → Cookies instead. That's the feature, not a bug.SameSiteis 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
Forgetting to await signed cookie operations
// Wrong — returns a Promise, not the value
const userId = ctx.cookies.signed.get('userId');
// Correct
const userId = await ctx.cookies.signed.get('userId');Passing a non-string value to set() — silently stores an empty cookie
// ❌ 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:8080On 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:
Set-Cookiepresent in the response? Check withcurl -i http://localhost:8080/login -X POST ...— if the header is missing, the handler never ranset().- Is the value non-empty? Passing an object/number/
undefinedsilently produces an empty cookie (see Common Mistakes). - Cross-origin? Both
credentials: 'include'on the fetch andcors({ credentials: true, origin: '<exact origin>' })on the server are required, or the browser discardsSet-Cookie(see Serving a frontend on another origin). - Is it
secureon plaintext HTTP for a non-loopback host (LAN IP, Docker,.local)? Browsers drop those. OnlocalhostHTTP,'auto'handles it. - Is it
SameSite=Strictand the request is cross-site (different registrable domain)?'strict'means the browser never sends it on cross-site requests. httpOnly? Then it won't show indocument.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 neededThe ctx.state.cookies / ctx.state.signedCookies aliases are removed in the next major
release.
Related
- @nextrush/cors — cross-origin cookies need
credentials: truehere - Security guide — CORS — credentials/wildcard rules
- CORS for a multi-tenant SaaS API — dynamic origin allowlists with cookies