node adapter re-serializes pre-parsed req.body and can corrupt form-urlencoded payloads #2885

Closed
opened 2026-03-13 10:25:35 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @ikemHood on GitHub (Feb 17, 2026).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create an Express app using Better Auth node handler (toNodeHandler(auth)), with global JSON body parsing middleware enabled before auth routes (e.g. app.use(express.json())).
  2. Add OAuth provider plugin and expose /oauth2/token.
  3. Send a POST request to /oauth2/token with Content-Type: application/x-www-form-urlencoded.
  4. In this setup, Express may set req.body to {} before Better Auth reads raw body.
  5. Better Auth node adapter path then detects req.body !== undefined and re-serializes req.body using JSON.stringify(req.body), so the request body forwarded to Better Auth becomes {} (or form-equivalent { "{}": "" }).
  6. Endpoint validation fails because expected form fields (such as grant_type) are missing, even though the original HTTP request had form-encoded data.

Current vs. Expected behavior

Current behavior:

  • If upstream middleware already parsed the body (or set req.body), Better Auth’s node adapter re-serializes req.body and uses that as request body.
  • For form-urlencoded OAuth token requests, this can replace valid raw form data with {} and break endpoint parsing/validation.
  • In practice this caused /oauth2/token to fail with missing grant_type until middleware ordering was changed.

Expected behavior:

  • Better Auth should robustly handle pre-parsed Node/Express requests and avoid destructive re-serialization.
  • If req.body is already present, adapter logic should preserve original semantics (or skip overriding raw body when content-type/raw stream indicates form data), instead of blindly JSON.stringify-ing arbitrary objects.
  • At minimum, parsed-body handling should not corrupt form-urlencoded payloads.

What version of Better Auth are you using?

1.4.18

System info

OS: macOS 15.1.1 (24B91), Darwin 24.1.0, arm64
Node.js: v22.11.0
npm: 11.4.2
pnpm: 10.17.0
better-auth: 1.4.18
@better-auth/oauth-provider: 1.4.18

Which area(s) are affected? (Select all that apply)

  • Backend
  • Package

Auth config (if applicable)

import express from "express";
import { toNodeHandler } from "better-auth/node";
import { betterAuth } from "better-auth";
import { oauthProvider } from "@better-auth/oauth-provider";

const auth = betterAuth({
  plugins: [oauthProvider({ loginPage: "/oauth/login", consentPage: "/oauth/consent" })],
});

const app = express();

// Repro trigger: global parser before auth routes
app.use(express.json());

app.all("/api/auth/*", toNodeHandler(auth));

Additional context

  • We fixed it application-side by moving auth routes before JSON parser and scoping JSON parser away from auth endpoints.
  • But this behavior is subtle and easy to hit in common Express setups.
  • The observed adapter behavior (from runtime stack/source) is: if req.body !== undefined, it uses JSON.stringify(req.body) and sends that as body to Better Auth.
  • We think Better Auth should defensively avoid this corruption path.
Originally created by @ikemHood on GitHub (Feb 17, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create an Express app using Better Auth node handler (`toNodeHandler(auth)`), with global JSON body parsing middleware enabled before auth routes (e.g. `app.use(express.json())`). 2. Add OAuth provider plugin and expose `/oauth2/token`. 3. Send a POST request to `/oauth2/token` with `Content-Type: application/x-www-form-urlencoded`. 4. In this setup, Express may set `req.body` to `{}` before Better Auth reads raw body. 5. Better Auth node adapter path then detects `req.body !== undefined` and re-serializes `req.body` using `JSON.stringify(req.body)`, so the request body forwarded to Better Auth becomes `{}` (or form-equivalent `{ "{}": "" }`). 6. Endpoint validation fails because expected form fields (such as `grant_type`) are missing, even though the original HTTP request had form-encoded data. ### Current vs. Expected behavior Current behavior: - If upstream middleware already parsed the body (or set `req.body`), Better Auth’s node adapter re-serializes `req.body` and uses that as request body. - For form-urlencoded OAuth token requests, this can replace valid raw form data with `{}` and break endpoint parsing/validation. - In practice this caused `/oauth2/token` to fail with missing `grant_type` until middleware ordering was changed. Expected behavior: - Better Auth should robustly handle pre-parsed Node/Express requests and avoid destructive re-serialization. - If `req.body` is already present, adapter logic should preserve original semantics (or skip overriding raw body when content-type/raw stream indicates form data), instead of blindly `JSON.stringify`-ing arbitrary objects. - At minimum, parsed-body handling should not corrupt form-urlencoded payloads. ### What version of Better Auth are you using? 1.4.18 ### System info ```bash OS: macOS 15.1.1 (24B91), Darwin 24.1.0, arm64 Node.js: v22.11.0 npm: 11.4.2 pnpm: 10.17.0 better-auth: 1.4.18 @better-auth/oauth-provider: 1.4.18 ``` ### Which area(s) are affected? (Select all that apply) - Backend - Package ### Auth config (if applicable) ```typescript import express from "express"; import { toNodeHandler } from "better-auth/node"; import { betterAuth } from "better-auth"; import { oauthProvider } from "@better-auth/oauth-provider"; const auth = betterAuth({ plugins: [oauthProvider({ loginPage: "/oauth/login", consentPage: "/oauth/consent" })], }); const app = express(); // Repro trigger: global parser before auth routes app.use(express.json()); app.all("/api/auth/*", toNodeHandler(auth)); ``` ### Additional context - We fixed it application-side by moving auth routes before JSON parser and scoping JSON parser away from auth endpoints. - But this behavior is subtle and easy to hit in common Express setups. - The observed adapter behavior (from runtime stack/source) is: if `req.body !== undefined`, it uses `JSON.stringify(req.body)` and sends that as body to Better Auth. - We think Better Auth should defensively avoid this corruption path.
GiteaMirror added the adapter label 2026-03-13 10:25:35 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Feb 17, 2026):

This is a known issue caused by Express body parsing middleware consuming the request stream before Better Auth can read it. You've already found the correct workaround.

Root Cause: When express.json() runs before the Better Auth handler, it consumes the request stream. For application/x-www-form-urlencoded requests, it sets req.body = {} (since it only parses JSON content-type). The node adapter then detects req.body !== undefined and re-serializes it, corrupting the original form data.

Recommended Fix: Mount Better Auth handler before any body parsing middleware:

// Correct order - Better Auth first
app.all('/api/auth/*splat', toNodeHandler(auth));
app.use(express.json());

Or conditionally skip body parsing for auth routes:

app.use((req, res, next) => {
  if (req.path.startsWith('/api/auth/')) {
    return next(); // Skip body parsing for Better Auth routes
  }
  express.json()(req, res, next);
});

app.all('/api/auth/*splat', toNodeHandler(auth));

This was discussed in issue #3295 where similar behavior caused sign-in endpoints to hang indefinitely.

Your suggestion that Better Auth should defensively handle this is valid—the toNodeHandler delegates to the better-call/node library for body parsing, so the fix may need to happen there. A more robust solution could detect form-urlencoded content-type and avoid blindly re-serializing req.body when the content-type doesn't match what was parsed.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Feb 17, 2026): <!-- Answer --> This is a known issue caused by Express body parsing middleware consuming the request stream before Better Auth can read it. You've already found the correct workaround. **Root Cause**: When `express.json()` runs before the Better Auth handler, it consumes the request stream. For `application/x-www-form-urlencoded` requests, it sets `req.body = {}` (since it only parses JSON content-type). The node adapter then detects `req.body !== undefined` and re-serializes it, corrupting the original form data. **Recommended Fix**: Mount Better Auth handler *before* any body parsing middleware: ```javascript // Correct order - Better Auth first app.all('/api/auth/*splat', toNodeHandler(auth)); app.use(express.json()); ``` Or conditionally skip body parsing for auth routes: ```javascript app.use((req, res, next) => { if (req.path.startsWith('/api/auth/')) { return next(); // Skip body parsing for Better Auth routes } express.json()(req, res, next); }); app.all('/api/auth/*splat', toNodeHandler(auth)); ``` This was [discussed in issue #3295](https://github.com/better-auth/better-auth/issues/3295#issuecomment-3046483667) where similar behavior caused sign-in endpoints to hang indefinitely. Your suggestion that Better Auth should defensively handle this is valid—the `toNodeHandler` [delegates to the `better-call/node` library](https://github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/better-auth/src/integrations/node.ts) for body parsing, so the fix may need to happen there. A more robust solution could detect form-urlencoded content-type and avoid blindly re-serializing `req.body` when the content-type doesn't match what was parsed. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d88861f2-122a-447c-ad84-4f26014e18d1?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20known%20issue%20caused%20by%20Express%20body%20parsing%20middleware%20consuming%20the%20request%20stream%20before%20Better%20Auth%20can%20read%20it.%20You%27ve%20already%20found%20the%20correct%20workaround.%0A%0A%2A%2ARoot%20Cause%2A%2A%3A%20When%20%60express.json%28%29%60%20runs%20before%20the%20Better%20Auth%20handler%2C%20it%20consumes%20the%20request%20stream.%20For%20%60application/x-www-form-urlencoded%60%20requests%2C%20it%20sets%20%60req.body%20%3D%20%7B%7D%60%20%28since%20it%20only%20parses%20JSON%20content-type%29.%20The%20node%20adapter%20then%20detects%20%60req.body%20%21%3D%3D%20undefined%60%20and%20re-serializes%20it%2C%20corrupting%20the%20original%20form%20data.%0A%0A%2A%2ARecommended%20Fix%2A%2A%3A%20Mount%20Better%20Auth%20handler%20%2Abefore%2A%20any%20body%20parsing%20middleware%3A%0A%0A%60%60%60javascript%0A//%20Correct%20order%20-%20Better%20Auth%20first%0Aapp.all%28%27/api/auth/%2Asplat%27%2C%20toNodeHandler%28auth%29%29%3B%0Aapp.use%28express.json%28%29%29%3B%0A%60%60%60%0A%0AOr%20conditionally%20skip%20body%20parsing%20for%20auth%20routes%3A%0A%0A%60%60%60javascript%0Aapp.use%28%28req%2C%20res%2C%20next%29%20%3D%3E%20%7B%0A%20%20if%20%28req.path.startsWith%28%27/api/auth/%27%29%29%20%7B%0A%20%20%20%20return%20next%28%29%3B%20//%20Skip%20body%20parsing%20for%20Better%20Auth%20routes%0A%20%20%7D%0A%20%20express.json%28%29%28req%2C%20res%2C%20next%29%3B%0A%7D%29%3B%0A%0Aapp.all%28%27/api/auth/%2Asplat%27%2C%20toNodeHandler%28auth%29%29%3B%0A%60%60%60%0A%0AThis%20was%20%5Bdiscussed%20in%20issue%20%233295%5D%28https%3A//github.com/better-auth/better-auth/issues/3295%23issuecomment-3046483667%29%20where%20similar%20behavior%20caused%20sign-in%20endpoints%20to%20hang%20indefinitely.%0A%0AYour%20suggestion%20that%20Better%20Auth%20should%20defensively%20handle%20this%20is%20valid%E2%80%94the%20%60toNodeHandler%60%20%5Bdelegates%20to%20the%20%60better-call/node%60%20library%5D%28https%3A//github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/better-auth/src/integrations/node.ts%29%20for%20body%20parsing%2C%20so%20the%20fix%20may%20need%20to%20happen%20there.%20A%20more%20robust%20solution%20could%20detect%20form-urlencoded%20content-type%20and%20avoid%20blindly%20re-serializing%20%60req.body%60%20when%20the%20content-type%20doesn%27t%20match%20what%20was%20parsed.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/8017)
Author
Owner

@bytaesu commented on GitHub (Feb 17, 2026):

Hi @ikemHood, I'm checking this

@bytaesu commented on GitHub (Feb 17, 2026): Hi @ikemHood, I'm checking this
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2885