Cookies and CSRF
Strict on the way out
A browser drops a cookie it cannot accept and reports nothing to the server. The server sees a successful response, and the login fails later with no error anywhere. So serializing a cookie throws a TypeError for every case a browser discards (D144). Size is one of them: under RFC 6265bis §5.6 a browser drops a cookie whose name and value exceed 4096 bytes, and ignores a Domain or Path over 1024 bytes. The value is measured after percent-encoding, because that is what the browser receives.
Splitting a large value across several cookies was rejected. No mainstream library does it by default, and when the value shrinks the leftover pieces stay in the browser until something deletes them.
Signed and sealed
Both protect a value the user holds. They differ in whether the user can read it.
| Signed | Sealed | |
|---|---|---|
| Algorithm | HMAC-SHA256 | AES-256-GCM |
| User can read the value | Yes | No |
| Text | <value>.<expiry>.<tag> | base64url of nonce, ciphertext and tag |
| Needs randomness | No | A 12-byte nonce per seal |
One secret, two keys. Each secret is stretched with HKDF-SHA256 into a signing key and a sealing key, using different info strings. Signing and sealing with the same secret never use the same key.
The name and the expiry are protected with the value. The signed data includes the cookie name, so a valid value cannot be copied into a cookie of another name. It also includes an expiry taken from maxAge or expires, which is checked on every read. The browser's own expiry is only a request to the browser; a copied cookie would otherwise work forever.
Every failure reads as null. A missing cookie, a malformed one, a changed byte, an unknown key and an expired value give the same answer. A handler needs one branch, and a forged cookie never becomes an exception, which would be a 500 an attacker can trigger. The tag comparison is constant-time, and every secret is tried, so the time taken does not show which key matched.
Rotation is a list. secrets[0] signs or seals; all are tried when reading. This is the model of Express's cookie-parser and React Router's createCookie. A key is retired once every cookie it signed has expired.
At least 32 bytes per secret. That is the size of the key behind it. A short human-chosen secret is the usual weakness of signed cookies, so it is refused when the cookie is set or read, not discovered later.
What it costs
A signed value grows by its expiry and a 43-character tag. A sealed value grows by 36 bytes (nonce, expiry and tag), and then by a third, because the result is base64url. With the 4096-byte limit, that leaves a sealed cookie about 3 KB for its value. Data larger than that belongs on the server, with only its ID in a signed cookie.
Signing and sealing run in Rust, synchronously, like the rest of CookieMap. There is nothing to wait for, and an await on every session read would make every handler that reads one async.
CSRF tokens
OWASP describes three defences: a random token kept in a server-side session (the synchronizer token), a token signed over the session ID, and the browser's own report of where a request came from. CSRF uses the second and checks the third when it is given the request.
Signed, not stored. The runtime has no session store, and a token that has to be stored is a write on every page view. A signed token carries its own proof: the server recomputes the HMAC over the session ID, the nonce, the issue time and the lifetime. Its key comes from the secret through HKDF with its own info, so it never equals a cookie key, even with the same secret.
Bound to a session, always. A signed token that is not tied to a session is valid for anyone who holds one, and an attacker can get one by visiting the site. This is why sessionId is required, where Bun makes it optional.
The browser's signal first, the token behind it. Sec-Fetch-Site says whether a request came from the same origin, and OWASP now calls it the primary signal. It is not always there: older browsers, privacy tools and some proxies remove it. A request without it falls back to Origin, and a request with neither is judged by the token. A header check alone, as in Go 1.25 and Hono, would have nothing left in that case.
false for any bad token. The token is request input, so a missing or malformed one is what an attacker sends. Throwing on it would turn every forged request into a 500. A misused API (no secret, no session ID) still throws, because that is a mistake in the code.
Two lifetimes. expiresIn is written into the token and cannot be changed without breaking the HMAC. maxAge is the verifier's own limit, so a server can shorten the window for tokens already issued. An issue time more than 60 seconds in the future fails, which allows for clocks that differ between servers.
See also
Cookies — how to use them
runtime:httpreference — every method and optionCSRF — how to protect a form
docs/DECISIONS.mdD144, D161 and D162 — what was decided and rejected