Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clerkrequest-omit-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Stop `authenticateRequest` from consuming the incoming request body, which previously left downstream handlers unable to read it (for example a Hono POST route calling `c.req.json()`).
46 changes: 42 additions & 4 deletions packages/backend/src/tokens/__tests__/clerkRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest';

import { createClerkRequest } from '../clerkRequest';

// Some test runtimes (e.g. Cloudflare/miniflare) gate `new ReadableStream()`
// behind a feature flag and throw when it is constructed directly.
const supportsStreamConstruction = (() => {
try {
new ReadableStream({
start(controller) {
controller.close();
},
});
return true;
} catch {
return false;
}
})();

describe('createClerkRequest', () => {
describe('instantiating a request', () => {
it('retains the headers', () => {
Expand All @@ -17,11 +32,34 @@ describe('createClerkRequest', () => {
expect(req.method).toBe(oldReq.method);
});

it('retains the body', async () => {
const data = { a: '1' };
const oldReq = new Request('http://localhost:3000', { method: 'POST', body: JSON.stringify(data) });
// The hazard only exists on undici-style runtimes (Node, edge) where the
// request body is a single-use stream. Cloudflare/miniflare buffers bodies
// (so the body survives anyway) and cannot construct a streaming body, so
// this regression is skipped there.
it.skipIf(!supportsStreamConstruction)('does not consume the original request body (issue #8305)', async () => {
// Clerk only needs the method, headers, cookies, and URL. Forwarding the
// body made the clone share the original's single-use stream, so reading
// either side left the other "unusable" for downstream handlers (e.g. a
// Hono POST route calling `c.req.json()`).
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify({ a: '1' })));
controller.close();
},
});
const oldReq = new Request('http://localhost:3000', {
method: 'POST',
body: stream,
// `duplex` is required when streaming a body; not yet in all lib typings.
duplex: 'half',
} as RequestInit);

const req = createClerkRequest(oldReq);
expect((await req.json())['a']).toBe(data.a);

// The clone carries no body, so it can never lock the original's stream...
expect(req.body).toBeNull();
// ...and the original stream stays readable for downstream consumers.
expect(((await oldReq.json()) as { a: string }).a).toBe('1');
});

it('retains the url', () => {
Expand Down
16 changes: 10 additions & 6 deletions packages/backend/src/tokens/clerkRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,22 @@ class ClerkRequest extends Request {
// https://github.com/nodejs/undici/issues/2155
// https://github.com/nodejs/undici/blob/7153a1c78d51840bbe16576ce353e481c3934701/lib/fetch/request.js#L854
const url = typeof input !== 'string' && 'url' in input ? input.url : String(input);
// When cloning a Request by passing it as init, hide its `signal`. Undici's
// Request constructor in Node 24 performs a strict instanceof check on the
// signal and rejects ones from a different realm (e.g. NextRequest). Using a
// Proxy keeps property access lazy so environments that don't implement
// optional getters (e.g. Cloudflare Workers' Request lacks `cache`) still work.
// When cloning a Request by passing it as init, hide its `signal` and `body`.
// Undici's Request constructor in Node 24 performs a strict instanceof check on
// the signal and rejects ones from a different realm (e.g. NextRequest). The
// `body` is hidden because forwarding it makes the clone share the original's
// single-use ReadableStream; once either side is read the other throws
// "Body is unusable" downstream (issue #8305). Auth only reads the method,
// headers, cookies, and URL, so the clone never needs a body. Using a Proxy
// keeps property access lazy so environments that don't implement optional
// getters (e.g. Cloudflare Workers' Request lacks `cache`) still work.
let cloneInit: RequestInit | undefined;
if (init) {
cloneInit = init;
} else if (typeof input !== 'string') {
cloneInit = new Proxy(input as Request, {
get(target, prop) {
if (prop === 'signal') {
if (prop === 'signal' || prop === 'body') {
return undefined;
}
return Reflect.get(target, prop, target);
Expand Down
Loading