@pronghorn/cookie
Cookie
Cookie is a lightweight, TypeScript-first cookie parsing and signing middleware built as an external plugin for Pronghorn. It reads incoming Cookie headers into context.locals.cookies and exposes setCookie/clearCookie helpers that queue outgoing Set-Cookie headers, with optional HMAC signing to prevent client-side tampering.
Built as a standalone package (
@pronghorn/cookie), pairs naturally withcreateJwtAuthfor session-style, stateful auth flows alongside stateless bearer tokens.
Why Cookie
Pronghorn ships JWT auth for stateless bearer tokens, but has no built-in way to read or write cookies, which blocks session IDs, CSRF tokens, and "remember me" flows. Cookie fills that gap without pulling in a heavyweight cookie-jar dependency.
- Parses the
Cookierequest header into a flatRecord<string, string>oncontext.locals.cookies. setCookie/clearCookiehelpers queueSet-Cookieheaders, applied to the response after your handler runs.- Optional HMAC-SHA256 signing via
node:crypto, verified with a timing-safe comparison to prevent tampering and timing attacks. - Zero runtime dependencies beyond Node's built-in
cryptomodule (available in Bun). - Single global middleware, no plugin/decorator indirection needed since cookies are inherently per-request.
Installation
bun add @pronghorn/cookie
Requires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only).
Quick Start
import { createApp } from 'pronghorn'
import { cookieParser } from '@pronghorn/cookie'
const app = createApp()
app.use(cookieParser({ secret: process.env.COOKIE_SECRET ?? 'dev-secret' }))
app.get('/', context => {
const visits = Number(context.locals.cookies['visits'] ?? '0') + 1
context.locals.setCookie('visits', String(visits), { signed: true, httpOnly: true, maxAge: 86_400 })
return context.json({ visits })
})
await app.listen(4000)
Core Concepts
Reading cookies
Once cookieParser is registered globally, every request gets context.locals.cookies, a flat map of cookie name to decoded value. If a secret was provided and a cookie was signed, its value is automatically unsigned and verified before it reaches your handler.
app.get('/profile', context => {
const sessionId = context.locals.cookies['session']
if (!sessionId) return context.json({ error: 'No session' }, 401)
return context.json({ sessionId })
})
Writing cookies
context.locals.setCookie(name, value, options?) queues a Set-Cookie header. Multiple calls in the same request each append their own header, matching how browsers expect multiple cookies to be set.
app.post('/login', context => {
context.locals.setCookie('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 3600
})
return context.json({ loggedIn: true })
})
Clearing cookies
context.locals.clearCookie(name, options?) sets the cookie to an empty value with Max-Age=0, causing the browser to delete it immediately.
app.post('/logout', context => {
context.locals.clearCookie('session')
return context.json({ loggedOut: true })
})
Signed cookies
Pass signed: true when writing a cookie to append an HMAC-SHA256 signature, and provide a secret to cookieParser so incoming values are verified and stripped back to their original form automatically. This prevents users from editing cookie values client-side without invalidating the signature.
app.use(cookieParser({ secret: process.env.COOKIE_SECRET! }))
app.post('/cart', context => {
context.locals.setCookie('cartTotal', '49.99', { signed: true })
return context.json({ ok: true })
})
app.get('/cart', context => {
// Already verified and unsigned by cookieParser, tampering returns the raw (invalid) value instead
const total = context.locals.cookies['cartTotal']
return context.json({ total })
})
Calling setCookie with signed: true but no secret configured throws immediately, so misconfiguration fails loudly instead of silently shipping unsigned cookies.
Cookie Options
| Option | Type | Default | Description |
|---|---|---|---|
maxAge |
number |
- | Lifetime in seconds; sent as Max-Age |
expires |
Date |
- | Absolute expiration; sent as Expires |
path |
string |
'/' |
Cookie path scope |
domain |
string |
- | Cookie domain scope |
secure |
boolean |
false |
Only sent over HTTPS |
httpOnly |
boolean |
false |
Inaccessible to client-side JS |
sameSite |
'Strict' | 'Lax' | 'None' |
- | Cross-site request behavior |
signed |
boolean |
false |
HMAC-signs the value; requires secret on cookieParser |
These map directly to standard Set-Cookie attributes.
Middleware Options
| Option | Type | Default | Description |
|---|---|---|---|
secret |
string |
undefined |
Enables signing/verification for cookies marked signed: true |
API Reference
cookieParser(options?: CookieParserOptions): Middleware - global middleware factory, register via app.use(cookieParser(options)).
| Locals property | Type | Description |
|---|---|---|
context.locals.cookies |
Record<string, string> |
Parsed (and auto-unsigned) incoming cookies |
context.locals.setCookie(name, value, options?) |
void |
Queues an outgoing Set-Cookie header |
context.locals.clearCookie(name, options?) |
void |
Queues a Set-Cookie header that deletes the cookie |
Lower-level building blocks are also exported for advanced use outside the middleware:
import { parseCookieHeader, serializeCookie, signValue, unsignValue } from '@pronghorn/cookie'
const cookies = parseCookieHeader(request.headers.get('cookie'))
const header = serializeCookie('theme', 'dark', { maxAge: 604_800 })
const signed = signValue('user-42', 'my-secret')
const original = unsignValue(signed, 'my-secret') // 'user-42', or null if tampered
Architecture
Cookie is split into three small modules, each with a single responsibility.
| Module | Responsibility |
|---|---|
parser.ts |
Parses the Cookie request header, and serializes outgoing cookies into Set-Cookie header values |
signer.ts |
HMAC-SHA256 signing/verification using node:crypto, with timingSafeEqual to avoid timing side-channels |
middleware.ts |
Wires parsing/signing into context.locals, and rebuilds the response with appended Set-Cookie headers after the handler resolves |
Because a Response returned by Response.json() has immutable headers, the middleware constructs a new Response with a merged Headers object rather than mutating the original, the same pattern used by Pronghorn's built-in cors middleware.
Security Notes
- Always set
httpOnly: trueon cookies holding session identifiers or tokens, to block access from client-side JavaScript. - Always set
secure: truein production so cookies are never sent over plain HTTP. - Use
signed: truefor any cookie whose value influences server-side logic (cart totals, role flags, etc.), unsigned cookies can be freely edited by the client. - Prefer
sameSite: 'Lax'or'Strict'to reduce CSRF exposure unless you specifically need cross-site cookie delivery.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.