Request Body Cloning#
Overview#
HTTP request bodies are one-time readable streams. Once a framework's body parser consumes the body (to deserialize JSON or form data into ctx.body), any subsequent attempt to read ctx.request — including by user-supplied callbacks — raises:
TypeError: Body is unusable: Body has already been read
Better-auth solves this with two complementary mechanisms:
cloneRequest: true— an endpoint-level option (from thebetter-calllibrary) that instructs the routing infrastructure to clone the rawRequestobject before the body is parsed, preserving a readable copy inctx.request.ctx.request?.clone()— called at every callsite where the request is forwarded to a user callback, creating an additional independent copy so each async consumer gets its own unread stream.
This pattern was systematically introduced in PR #9619 to fix issue #8969.
Affected Endpoints#
| Endpoint | File | cloneRequest | Callbacks receiving clone() |
|---|---|---|---|
POST /sign-up/email | sign-up.ts | ✅ | onExistingUserSignUp , sendVerificationEmail |
POST /sign-in/email | sign-in.ts | ✅ | sendVerificationEmail |
POST /send-verification-email | email-verification.ts | ✅ | sendVerificationEmail via sendVerificationEmailFn |
GET /verify-email | email-verification.ts | — | sendVerificationEmail |
Note:
GET /verify-emailhas no body to parse, socloneRequestis not set — but it still callsctx.request?.clone()when invoking callbacks, following the same defensive pattern.
How It Works#
Step 1 — Clone at endpoint definition#
Declaring cloneRequest: true in the endpoint options tells better-call to clone the Request before handing it to the body parser. The original request stays accessible as ctx.request with an unread body.
// sign-up.ts (simplified)
createAuthEndpoint("/sign-up/email", {
method: "POST",
cloneRequest: true, // ← preserve readable ctx.request
body: signUpEmailBodySchema,
...
}, async (ctx) => { ... });
Step 2 — Clone again per callback#
Each user callback receives its own clone, so multiple callbacks in one request lifecycle don't race over the same stream:
ctx.context.options.emailVerification.sendVerificationEmail(
{ user, url, token },
ctx.request?.clone(), // ← fresh clone per call
);
The same pattern applies in sign-in.ts and email-verification.ts .
Where sendVerificationEmailFn Differs#
The shared helper sendVerificationEmailFn — called when the session already exists — passes ctx.request directly (not cloned) because it is awaited synchronously and is the only consumer of the request at that point . Callsites that call it from endpoints with cloneRequest: true still benefit from the cloned ctx.request.
Key Source References#
- PR #9619 — fix(email-verification): clone request before passing to sendVerificationEmail callback — full description of the bug and all changed files
sign-up.ts—signUpEmailendpointsign-in.ts—signInEmailendpointemail-verification.ts—sendVerificationEmailandverifyEmailendpoints