Next.js Put Your HttpOnly Cookie in a Header Any Script Could Read
Next.js 16.4.5 and 15.5.29 fixed seven security issues on November 3. One of them leaked serialized cookies into an internal x-middleware-set-cookie response header from Edge route handlers, where same-origin JavaScript could read them even with HttpOnly set. Here is how the leak worked, how to check your own responses, and why a medium severity rating undersells it.

Next.js 16.4.5 and 15.5.29 landed on November 3 with seven security fixes, and the one I keep thinking about is the smallest of them. It is rated medium. It also means that for a stretch of releases, your HttpOnly session cookie was sitting in a response header that any script on your page could read with two lines of JavaScript.
The advisory is GHSA-f3mh-rxg3-f5g2. If an App Router Edge Route Handler or a Pages Router Edge API route set a cookie with NextResponse.cookies.set(), the serialized cookie also went out in an internal response header named x-middleware-set-cookie.
why that header exists at all
Middleware in Next.js can set cookies, and middleware runs before the route that eventually produces the response. So the cookie has to travel from the middleware layer back through the Next.js server to be merged into whatever goes out the door. x-middleware-set-cookie is the courier for that trip. It is plumbing. It was never supposed to survive to the browser.
In Edge route handlers it survived.
// app/api/login/route.ts
export const runtime = 'edge';
export async function POST(req: Request) {
const session = await createSession(await req.json());
const res = NextResponse.json({ ok: true });
res.cookies.set('session', session.token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
return res; // also ships x-middleware-set-cookie: session=eyJhbGciOi...
}The route looks correct. It is the flag-for-flag example you would write in a code review to show someone doing cookies properly.
the part that makes it a real bug
Browsers treat Set-Cookie as a forbidden response header. fetch cannot read it, even on a same-origin response. That restriction is what makes HttpOnly mean anything.
x-middleware-set-cookie has no such protection. It is an ordinary header, and on a same-origin response every header is readable.
const res = await fetch('/api/login', { method: 'POST', body: '{}' });
res.headers.get('set-cookie'); // null, as designed
res.headers.get('x-middleware-set-cookie'); // "session=eyJhbGciOi...; HttpOnly; Secure"Vercel's writeup is honest about the conditions: you need a sensitive cookie on an affected response, and you need attacker-controlled JavaScript already running on the same origin. They point out this is not an independent way to execute JavaScript, which is fair.
I still think medium undersells it, and the reason is circular in a way that bothers me. HttpOnly exists for precisely the scenario where somebody else's JavaScript is running on your origin. That is its entire job. Rating the bypass as low-impact because it requires that scenario is like rating a broken seatbelt as low-impact because it only matters during a crash.
And "attacker-controlled same-origin JavaScript" is a much wider door than XSS. Your tag manager. The chat widget marketing added last quarter. An A/B testing snippet. A transitive dependency that made it into the client bundle. None of those need a vulnerability in your code to read a header off a same-origin fetch.
which routes were actually affected
The reproduced leak did not hit Node.js Route Handlers, and it did not hit routes that set cookies through cookies().set(). The combination that mattered was Edge runtime plus the NextResponse.cookies API.
If you only ever set auth cookies from a server action or a Node handler, you were fine. If you moved a login or token-refresh route to the Edge runtime for latency, which plenty of people did because that is the advice, you were the target shape.
what to do today
Patch first: [email protected] on the Active LTS line, [email protected] on Maintenance. That is the actual fix and everything below is for the gap before a deploy lands.
If you cannot ship an upgrade this week, move session-setting routes off the Edge runtime and set cookies with cookies().set() instead of building a NextResponse. If you have a proxy or CDN in front of the app, strip the header there:
// Cloudflare Worker, or the equivalent in your proxy config
const res = await fetch(request);
const clean = new Response(res.body, res);
clean.headers.delete('x-middleware-set-cookie');
return clean;Then assert on it, because this class of bug will happen again:
test('no framework internals leak to clients', async () => {
const res = await fetch(`${base}/api/login`, { method: 'POST', body: '{}' });
for (const [name] of res.headers) {
expect(name.startsWith('x-middleware-')).toBe(false);
}
});Four lines, and it covers every future internal header Next decides to invent, not just this one.
the rest of the release is worse, honestly
Six other fixes shipped alongside it, and four were cache-identity bugs in Cache Components. The high-severity one, GHSA-mfr8-8qww-v76h, is cross-tenant: call the same public use cache function directly and from inside unstable_cache, have it read a root parameter, and during a cold cache fill a concurrent unstable_cache call can get another request's result back. That result then gets stored and served to later callers.
There is also a shared shell poisoning issue for self-hosted next start where an unauthenticated crafted URL can leave other URLs returning persistent 404s, and a compressed-response framing bug that can deliver one client's response to another behind an HTTP/1.1 reverse proxy.
Taken together, the theme of this release is that caching and request plumbing both got complicated enough that identity (whose data is this, which response does this header belong to) stopped being obvious to the framework. I do not have a clever fix for that. I do have one more assertion in my smoke tests, which I should have written years ago.