setCookie writes a broken Expires header when expires is an out-of-range number
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 7.7k
- Forks
- 879
- Avg merge
- 2d 16h
- Merged PRs (30d)
- 68
Description
What happens
setCookie writes a broken Expires attribute if you pass expires as a number that is too large.
import { Headers, setCookie } from 'undici'
const headers = new Headers()
setCookie(headers, { name: 'Space', value: 'Cat', expires: 1e16 })
console.log(headers.getSetCookie()[0])
// Space=Cat; Expires=undefined, undefined undefined NaN undefined:undefined:undefined GMT
That is not a valid date. Instead of an expiry time, the header contains the literal words undefined and NaN.
This is easy to hit in real code: 1e16 is what you get when you pass seconds where the API expects milliseconds. A date in seconds is always far too large for Date.
Why it happens
expires can be a Date or a number. The check in stringify only understands the Date form:
// lib/web/cookies/util.js
if (cookie.expires != null && cookie.expires.toString() !== 'Invalid Date') {
out.push(`Expires=${toIMFDate(cookie.expires)}`)
}
The idea is "skip it if the date is invalid". That works for a Date, because a bad Date turns into the text "Invalid Date". But a number never does:
(new Date(NaN)).toString()is"Invalid Date"-> skipped, correctNaN.toString()is"NaN"-> not skipped(1e16).toString()is"10000000000000000"-> not skipped
So for a number the check always says "fine, go ahead". The value then goes into toIMFDate, which builds the date from a Date object whose values are all NaN. Reading NaN out of it gives undefined for the day name, and those values get pasted straight into the header text.
I confirmed this with a debugger on both lines. Below is the state at each stop.
Stop 1 - the check passes even though the value is not a valid date
cookie.expires is 10000000000000000. The check says true, so it does not skip. But new Date(...) on that value is invalid, and the value is outside the range a Date can hold:

| Expression | Value |
|---|---|
cookie.expires |
10000000000000000 |
cookie.expires.toString() |
'10000000000000000' |
cookie.expires.toString() !== 'Invalid Date' |
true (so it does not skip) |
new Date(cookie.expires).getTime() |
NaN (but it is not a real date) |
Math.abs(cookie.expires) <= 8640000000000000 |
false (out of range) |
Stop 2 - toIMFDate builds the header out of undefined and NaN
Stepping into toIMFDate, the lookup that produces the day name returns undefined:

| Expression | Value |
|---|---|
date |
Invalid Date |
date.getUTCDay() |
NaN |
IMFDays[date.getUTCDay()] |
undefined |
date.getUTCMonth() |
NaN |
date.getUTCFullYear() |
NaN |
The call stack at this point is toIMFDate -> stringify (util.js:321, which is inside the if body) -> setCookie. That confirms the check let the value through rather than skipping it.
Which values are affected
expires: 0 (valid) => Space=Cat; Expires=Thu, 01 Jan 1970 00:00:00 GMT
expires: 8.64e15 (max) => Space=Cat; Expires=Sat, 13 Sep 275760 00:00:00 GMT
expires: 1e16 (out of range) => Space=Cat; Expires=undefined, undefined undefined NaN undefined:undefined:undefined GMT
expires: new Date(NaN) => Space=Cat

Only an out-of-range number is broken. 0 works, the largest valid value works, and an invalid Date is skipped correctly. So the problem is specific to the number form.
Suggested fix
Check the number itself instead of its text, and leave the Date behaviour alone:
if (cookie.expires != null) {
const expiresMs = cookie.expires instanceof Date
? cookie.expires.getTime()
: cookie.expires
if (typeof expiresMs === 'number' && Number.isFinite(expiresMs) && Math.abs(expiresMs) <= 8640000000000000) {
out.push(`Expires=${toIMFDate(cookie.expires)}`)
}
}
8640000000000000 is the largest value a Date can hold, so anything that passes the check is safe to format. This matches how the neighbouring Max-Age branch already works - it checks the type rather than a converted string.
One thing worth noting: Infinity, -Infinity and NaN never reach this code as-is, because the converter in lib/web/cookies/index.js turns them into 0 first, so they still serialize as the epoch. That is a separate issue and this change does not touch it.
Environment
- undici
mainat6933139(undici@8.10.2) - Node.js
v26.8.1, macOS
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
The affected logic is in lib/web/cookies/util.js; start by reproducing the setCookie example and inspecting the existing expires guard. Done means out-of-range numeric expires values no longer produce an Expires attribute, while 0, the maximum valid value, and invalid Date behavior remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- api
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 87/100