npm.io
0.1.1 • Published 3d ago

@pronghorn/cookie

Licence
WTFPL
Version
0.1.1
Deps
0
Size
15 kB
Vulns
0
Weekly
0

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 with createJwtAuth for session-style, stateful auth flows alongside stateless bearer tokens.

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 Cookie request header into a flat Record<string, string> on context.locals.cookies.
  • setCookie/clearCookie helpers queue Set-Cookie headers, 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 crypto module (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.

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: true on cookies holding session identifiers or tokens, to block access from client-side JavaScript.
  • Always set secure: true in production so cookies are never sent over plain HTTP.
  • Use signed: true for 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.

Keywords