Skip to content

URIError: URI malformed — ClerkRequest.parseCookies throws on any malformed percent-escape in the Cookie header #9333

Description

@davidbarratt

Preliminary Checks

  • I have reviewed the documentation: https://clerk.com/docs
  • I have searched for existing issues: https://github.com/clerk/javascript/issues
  • I have not already reached out to Clerk support via email or Discord
  • This issue is not a question, general help request, or anything other than a bug report directly related to Clerk

Reproduction

https://gist.github.com/davidbarratt/08d1334f2c9dde6fa5cde0da7c3a4030

Publishable key

Not applicable. The throw happens inside the ClerkRequest constructor, in a string function, before any Clerk API call — the reproduction runs with no instance, no key, and no network access.

Description

ClerkRequest.decodeCookieValue runs decodeURIComponent over the raw Cookie header with no try/catch, in packages/backend/src/tokens/clerkRequest.ts#L96-L103:

private parseCookies(req: Request) {
  const cookiesRecord = parse(this.decodeCookieValue(req.headers.get('cookie') || ''));
  return new Map(Object.entries(cookiesRecord));
}

private decodeCookieValue(str: string) {
  return str ? str.replace(/(%[0-9A-Z]{2})+/g, decodeURIComponent) : str;
}

decodeURIComponent throws URIError: URI malformed on any percent-escape that isn't valid UTF-8. Because parseCookies is called from the constructor (L53), the error escapes createClerkRequest, which is the first thing authenticateRequest does — so the request fails before any auth logic runs.

Two properties widen the blast radius well past the one cookie that is malformed:

  • The decode is applied to the whole header, not per value. Any cookie on the domain can trigger it, including ones Clerk never set and never reads — analytics, ad tooling, third-party scripts.
  • The bad value persists in the browser until it expires. Every subsequent request from that client fails, not just one.

Steps to reproduce:

  1. npm install @clerk/backend@3.15.1
  2. Call createClerkRequest with a request whose Cookie header contains a malformed escape:
    import { createClerkRequest } from '@clerk/backend/internal';
    
    createClerkRequest(new Request('https://example.com/', {
      headers: { cookie: '__session=abc; analytics_id=%E2%9' },
    }));
  3. Observe URIError: URI malformed.

Expected behavior:

A cookie value that cannot be percent-decoded is left as its raw value or skipped, and the rest of the header parses normally. A cookie unrelated to Clerk should not be able to fail the request.

Actual behavior:

URIError: URI malformed
    at decodeURIComponent (<anonymous>)
    at String.replace (<anonymous>)
    at ClerkRequest.decodeCookieValue (.../@clerk/backend/dist/chunk-DDFIPK3V.mjs:6491:22)
    at ClerkRequest.parseCookies (.../@clerk/backend/dist/chunk-DDFIPK3V.mjs:6487:57)
    at new ClerkRequest (.../@clerk/backend/dist/chunk-DDFIPK3V.mjs:6450:25)
    at createClerkRequest (.../@clerk/backend/dist/chunk-DDFIPK3V.mjs:6496:37)

Inputs that throw, all verified against 3.15.1:

Cookie header Why
x=%E2%9 escape truncated mid-sequence; %E2 decodes alone as an incomplete UTF-8 lead byte
x=%98 lone continuation byte
x=%C0%80 overlong UTF-8 encoding

The truncated case is the one we hit in production. A client-side script writing a value that gets clipped at the browser's ~4096-byte per-cookie limit lands mid-escape, and that alone is enough to make every later request 500.

How it surfaces in a Next.js app. @clerk/nextjs 7.x on Vercel (Node.js 22 runtime), clerkMiddleware() in proxy.ts. The proxy returns HTTP 500 and the request never reaches our handler. The minified frames map onto the same four methods:

Error running the exported Web Handler: URIError: URI malformed
    at decodeURIComponent (<anonymous>)
    at String.replace (<anonymous>)
    at aJ.decodeCookieValue (.next/server/chunks/[root-of-the-server]__0tiorp-._.js:49:40399)
    at aJ.parseCookies (.next/server/chunks/[root-of-the-server]__0tiorp-._.js:49:40317)
    at new aJ (.next/server/chunks/[root-of-the-server]__0tiorp-._.js:49:39648)
    at aq (.next/server/chunks/[root-of-the-server]__0tiorp-._.js:49:40531)

Because it happens in middleware, there is no application-level place to catch it — the exception is raised while Clerk is constructing its own request wrapper.

Present in the latest release. The code above is current on main, and the published build is the same: @clerk/backend@3.15.1/dist/internal.js lines 7007-7013.

Possibly related, same two lines. The regex (%[0-9A-Z]{2})+ matches only uppercase hex digits, so lowercase escapes are skipped while uppercase ones in the same value are decoded:

decodeCookieValue('x=%c3%a9')  // 'x=%c3%a9'  — left raw
decodeCookieValue('x=%C3%A9')  // 'x=é'

Percent-encoding is case-insensitive, so both should decode to the same value.

Prior public report. ViewComfy/ViewComfy#147 has the identical ClerkRequest.decodeCookieValue → parseCookies → new ClerkRequest stack, reported in July 2025 and closed with no fix and no upstream link. I searched this repo for URI malformed, URIError, decodeCookieValue, and malformed-cookie phrasings and did not find an existing issue.

Environment

  System:
    OS: Linux 6.12 Debian GNU/Linux 13 (trixie)
    CPU: (5) arm64
  Binaries:
    Node: 24.18.0
    npm: 11.16.0
    pnpm: 11.11.0
  npmPackages:
    @clerk/backend: 3.15.1 => 3.15.1

  Also reproduced in production on:
    Vercel, Node.js 22.x runtime
    next: 16.2.6
    @clerk/nextjs: 7.2.1  (@clerk/backend 3.2.11)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions