Middleware rewrite: internal request URL re-serialized incorrectly so query param value containing & is split
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/DanielGiljam/nextjs-internal-request-url-re-serialization-issue
To Reproduce
Middleware rewrites /_next/image and injects a secret query param into the image URL that is passed as the url param. The image URL can already have a query string (e.g. a cache-busting timestamp), so after adding secret, the value of url looks like:
http://localhost:3000/api/media/file/...?2026-02-11T12:20:48.699Z=&secret=super-secret-image-secret
i.e. it contains a literal "&".
Relevant middleware code:
try {
const rewriteUrl = new URL(urlPathAndBeyond(request.nextUrl), serverURL);
console.log(
`rewriteUrl before url search param is set: ${inspect(rewriteUrl)}`
);
const imageUrl = new URL(request.nextUrl.searchParams.get("url")!);
console.log(`imageUrl: ${inspect(imageUrl)}`);
if (imageUrl.searchParams.has("secret")) {
return NextResponse.next();
}
imageUrl.searchParams.set("secret", imageSecret);
rewriteUrl.searchParams.set("url", imageUrl.toString());
// sanity check: rewriteUrl should not have secret
if (rewriteUrl.searchParams.has("secret")) {
throw new Error("rewriteUrl has secret");
}
console.log(`rewriting to ${inspect(rewriteUrl)}`);
return NextResponse.rewrite(rewriteUrl);
} catch (error) {
console.error(error);
}
First request (correct)
Incoming: /_next/image?url=...&w=3840&q=75 (no secret). We set url to the image URL including ?timestamp=&secret=.... After rewriteUrl.searchParams.set("url", imageUrl.toString()), the serialized rewrite URL is correct (e.g. "&" inside url is "%26"):
rewriteUrl before url search param is set: URL {
href: 'http://localhost:3000/_next/image?url=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fmedia%2Ffile%2Ftest.png%3F2026-02-11T12%3A20%3A48.699Z&w=3840&q=75',
...
searchParams: URLSearchParams {
'url' => 'http://localhost:3000/api/media/file/test.png?2026-02-11T12:20:48.699Z',
'w' => '3840',
'q' => '75' },
}
imageUrl: URL {
href: 'http://localhost:3000/api/media/file/test.png?2026-02-11T12:20:48.699Z',
...
}
rewriting to URL {
href: 'http://localhost:3000/_next/image?url=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fmedia%2Ffile%2Ftest.png%3F2026-02-11T12%253A20%253A48.699Z%3D%26secret%3Dsuper-secret-image-secret&w=3840&q=75',
...
searchParams: URLSearchParams {
'url' => 'http://localhost:3000/api/media/file/test.png?2026-02-11T12:20:48.699Z=&secret=super-secret-image-secret',
'w' => '3840',
'q' => '75' },
}
So on the first run, rewriteUrl has no top-level secret, only inside url.
Second request (internal rewrite request – bug)
The internal request that Next.js sends for the rewrite hits the same middleware. Its URL is parsed so that the url param value is truncated at the first "&", and secret appears as a top-level param:
rewriteUrl before url search param is set: URL {
href: 'http://localhost:3000/_next/image?url=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fmedia%2Ffile%2Ftest.png%3F2026-02-11T12%3A20%3A48.699Z%3D&secret=super-secret-image-secret&w=3840&q=75',
...
searchParams: URLSearchParams {
'url' => 'http://localhost:3000/api/media/file/test.png?2026-02-11T12:20:48.699Z=',
'secret' => 'super-secret-image-secret',
'w' => '3840',
'q' => '75' },
}
imageUrl: URL {
href: 'http://localhost:3000/api/media/file/test.png?2026-02-11T12:20:48.699Z=',
...
}
So on the internal request, url is cut at "&" and secret is leaked to the top level. The sanity check rewriteUrl.searchParams.has("secret") then throws:
ERROR: rewriteUrl has secret
Current vs. Expected behavior
Current
The internal request URL appears to be built from decoded search params and re-serialized without encoding "&" inside the url param value, so the value is split and secret becomes a separate top-level query parameter.
Expected
The internal request for the rewrite should use a query string that preserves the url param value (with "&" encoded as "%26"), so that parsing it again yields a single url param whose value is the full image URL including &secret=....
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:40 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6000
Available memory (MB): 32768
Available CPU cores: 10
Binaries:
Node: 24.8.0
npm: 11.6.0
Yarn: N/A
pnpm: 10.12.1
Relevant Packages:
next: 16.1.6 // Latest available version is detected (16.1.6).
eslint-config-next: N/A
react: 19.1.0
react-dom: 19.1.0
typescript: 5.9.3
Next.js Config:
output: standalone
Which area(s) are affected? (Select all that apply)
Middleware
Which stage(s) are affected? (Select all that apply)
next dev (local), next start (local), Other (Deployed)
Additional context
When middleware (proxy.ts uses NextResponse.rewrite(rewriteUrl) with a URL whose search param value contains a literal "&", the internal request that Next.js issues for the rewrite is built in a way that does not encode that "&". The value is therefore split at "&", so the next middleware run sees extra top-level params and a truncated param.
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
Start with the linked reproduction and the middleware entry point in proxy.ts, focusing on NextResponse.rewrite(rewriteUrl) and the internal request it produces. Trace how the rewritten query string is serialized and parsed again; done means a literal '&' inside the url parameter remains encoded and does not become a top-level secret parameter.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, typescript
- Domain
- backend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100