Deviopath › Node.js

Signed Cookies with HMAC

Hard · Security

Web servers keep small pieces of state (like a session id) in cookies. The browser can edit cookies, so the server signs them: it appends an HMAC of the value and later checks that the HMAC still matches. In Express, res.cookie(name, value, { signed: true }) stores "s:" + sign(value), and the cookie-parser middleware checks the signature and puts the result in req.signedCookies.

Implement these functions with the Web Crypto API (crypto.subtle, available in browsers and in Node):

- sign(value, secret) (async) returns value + "." + signature. signature is the HMAC-SHA256 of value using secret as the key (both UTF-8 encoded with TextEncoder), written as base64url without padding: take normal base64, replace + with - and / with _, and remove trailing =.

- unsign(signed, secrets) (async). secrets is an array so keys can be rotated: new cookies are signed with the first secret, but cookies signed with an older secret keep working. Split at the LAST . (the value itself may contain dots), then try the secrets in order and return the value as soon as the signature matches one of them. Otherwise (no dot, wrong signature, changed value) return false.

- safeEqual(a, b) compares two strings in constant time: return false if the lengths differ, otherwise look at every character (for example OR together the XOR of each pair of char codes) and never stop early. unsign must use it.

- parseCookies(header, secrets) (async) parses a Cookie request header like theme=dark; sid=s%3Aabc.SIG and returns { cookies, signedCookies }, like cookie-parser. Split the header on ;, split each pair at its first = (skip pairs without =) and trim the name and the value. If the value starts and ends with ", remove those two quotes. Then decode it with decodeURIComponent (keep it unchanged if that throws). If a name appears more than once, only the first one counts. A value that starts with s: is signed: store await unsign(<value without "s:">, secrets) in signedCookies, so a forged cookie shows up as false. Every other value goes into cookies.

Why constant time? A normal === stops at the first different character, so an attacker who measures response times could guess a valid signature one character at a time.

The real cookie-signature package uses standard base64; this exercise uses base64url so the result is safe in cookies and URLs. The driver runCookies(secrets, ops) is already written: ["sign", value] signs with secrets[0], ["unsign", signed] and ["parse", header] use all the secrets, ["signWith", value, secret] signs with secret and then unsigns with secrets, and ["equal", a, b] calls safeEqual.

Next: Sliding Window Log Limiter