vercel / vercel/next.js

Middleware rewrite: internal request URL re-serialized incorrectly so query param value containing & is split

Open
#89,879 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Middleware
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.