Express: better-auth returns 404 when using toNodeHandler(auth) inside a subrouter in Express #1669

Closed
opened 2026-03-13 08:54:52 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @leonardomalzacher on GitHub (Aug 8, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Hi there...

I'm encountering an issue when trying to use better-auth with an Express subrouter. When registering the auth handler directly on the app, everything works fine. But once I move the route into a subrouter (under /api), I consistently get 404 responses for all /api/auth/... paths.

index.ts

import "dotenv/config";
import cors from "cors";
import express from "express";
import morgan from "morgan";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
import apiRouter from "./api/index.js";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();
app.use(express.urlencoded({ extended: false, limit: "200mb" }));
app.use(morgan("dev"));

if (process.env.NODE_ENV === "production") {
    app.use(express.static(path.join(__dirname, "public")));
} else {
    app.use(
        cors({
            origin: "http://localhost:5173",
            credentials: true,
        })
    );
}
// app.all("/api/auth/*splat", toNodeHandler(auth)); <- works fine
// app.all("/api/auth/{*splat}", toNodeHandler(auth)); <- works fine
app.use("/api", apiRouter); // causes 404 issues (see apiRouter)

app.use(express.json({ limit: "200mb" }));

const port = process.env.PORT || 3000;
app.listen(port, () => {
    console.log(`Server is running on port ${port}`);
});

apiRouter

import { toNodeHandler } from "better-auth/node";
import express from "express";
import { auth } from "../lib/auth.js";

const router = express.Router();

router.all("/auth/*splat", toNodeHandler(auth));
// router.all("/auth/{*splat}", toNodeHandler(auth)); <- same issue
export default router;

normally this should work fine or am I missing something?

Current vs. Expected behavior

Using toNodeHandler(auth) inside a subrouter like apiRouter should work for routes under /api/auth/*.

Any ideas on what might be causing this, or if there's a workaround I'm missing?

What version of Better Auth are you using?

1.3.4

Provide environment information

OS: MacOS 15.5
Node Version: 22.14.0
Express Version: 5.1.0

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"

export const auth = betterAuth({
    database: prismaAdapter(prisma, {
        provider: "postgresql",
    }),
    socialProviders: {
        microsoft: {
            clientId,
            clientSecret,
            tenantId,
        },
    },
    trustedOrigins
});

Additional context

No response

Originally created by @leonardomalzacher on GitHub (Aug 8, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Hi there... I'm encountering an issue when trying to use `better-auth` with an `Express subrouter`. When registering the auth handler directly on the app, everything works fine. But once I move the route into a `subrouter` (under /api), I consistently get `404` responses for all `/api/auth/...` paths. index.ts ```ts import "dotenv/config"; import cors from "cors"; import express from "express"; import morgan from "morgan"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; import apiRouter from "./api/index.js"; import { toNodeHandler } from "better-auth/node"; import { auth } from "./lib/auth.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); app.use(express.urlencoded({ extended: false, limit: "200mb" })); app.use(morgan("dev")); if (process.env.NODE_ENV === "production") { app.use(express.static(path.join(__dirname, "public"))); } else { app.use( cors({ origin: "http://localhost:5173", credentials: true, }) ); } // app.all("/api/auth/*splat", toNodeHandler(auth)); <- works fine // app.all("/api/auth/{*splat}", toNodeHandler(auth)); <- works fine app.use("/api", apiRouter); // causes 404 issues (see apiRouter) app.use(express.json({ limit: "200mb" })); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Server is running on port ${port}`); }); ``` apiRouter ```ts import { toNodeHandler } from "better-auth/node"; import express from "express"; import { auth } from "../lib/auth.js"; const router = express.Router(); router.all("/auth/*splat", toNodeHandler(auth)); // router.all("/auth/{*splat}", toNodeHandler(auth)); <- same issue export default router; ``` normally this should work fine or am I missing something? ### Current vs. Expected behavior Using `toNodeHandler(auth)` inside a `subrouter` like apiRouter should work for routes under `/api/auth/*`. Any ideas on what might be causing this, or if there's a workaround I'm missing? ### What version of Better Auth are you using? 1.3.4 ### Provide environment information ```bash OS: MacOS 15.5 Node Version: 22.14.0 Express Version: 5.1.0 ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), socialProviders: { microsoft: { clientId, clientSecret, tenantId, }, }, trustedOrigins }); ``` ### Additional context _No response_
GiteaMirror added the express label 2026-03-13 08:54:52 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Aug 8, 2025):

Hi @leonardomalzacher! I'm Dosu and I’m helping the better-auth team.

This happens because better-auth's toNodeHandler expects to be mounted at the exact path prefix matching the client requests and the baseURL in your config. When you use a subrouter (e.g., router.all('/auth/') in a router mounted at /api), Express strips the /api prefix before the handler sees the request, so better-auth receives /auth/ instead of /api/auth/* and returns 404s. There’s no built-in support for automatic path adjustment in this scenario (see discussion).

To fix this, either:

  • Mount toNodeHandler(auth) directly at the full path on the app, e.g. app.all('/api/auth/{*any}', toNodeHandler(auth)) for Express v5 (docs), or
  • Use a middleware before the handler to rewrite req.url, adding the missing prefix (e.g., /api) if you must use subrouters or a proxy that strips it (example workaround).

Also, make sure to mount better-auth before express.json() or bodyParser.json(), as those can interfere and cause 404s or hanging requests (reference).

More details and examples are in the linked issues above.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Aug 8, 2025): <!-- Greeting --> Hi @leonardomalzacher! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This happens because better-auth's toNodeHandler expects to be mounted at the exact path prefix matching the client requests and the baseURL in your config. When you use a subrouter (e.g., router.all('/auth/*') in a router mounted at /api), Express strips the /api prefix before the handler sees the request, so better-auth receives /auth/* instead of /api/auth/* and returns 404s. There’s no built-in support for automatic path adjustment in this scenario ([see discussion](https://github.com/better-auth/better-auth/issues/3215)). To fix this, either: - Mount toNodeHandler(auth) directly at the full path on the app, e.g. `app.all('/api/auth/{*any}', toNodeHandler(auth))` for Express v5 ([docs](https://github.com/better-auth/better-auth/issues/2975)), or - Use a middleware before the handler to rewrite req.url, adding the missing prefix (e.g., /api) if you must use subrouters or a proxy that strips it ([example workaround](https://github.com/better-auth/better-auth/issues/3215)). Also, make sure to mount better-auth before express.json() or bodyParser.json(), as those can interfere and cause 404s or hanging requests ([reference](https://github.com/better-auth/better-auth/issues/3295)). More details and examples are in the linked issues above. <!-- 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/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/f7c8446d-1c55-4ea2-bb09-7b0fc0ff5631?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&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/3887)
Author
Owner

@Kinfe123 commented on GitHub (Aug 8, 2025):

Can you send you auth config ? you can configure your basePath to /auth since it is /api/auth by default if there is no path component mentioned on the baseURL

@Kinfe123 commented on GitHub (Aug 8, 2025): Can you send you auth config ? you can configure your basePath to `/auth` since it is `/api/auth` by default if there is no path component mentioned on the baseURL
Author
Owner

@niklas-der commented on GitHub (Aug 8, 2025):

This seems to be an issue within better-call. The toNodeHandler passes a static base URL to getRequest (see 5ea5ddfd9d/src/adapters/node/node.ts (L10)). This will result in base being equal to something like http://localhost:3000. getRequest then builds the native Request object and passes the wrong URL, since request.url is only the path relative to the sub-router path in the case of express.js request objects:

return new Request(base + request.url, {
	// @ts-expect-error
	duplex: "half",
	method: request.method,
	body: get_raw_body(request, bodySizeLimit),
	headers: request.headers as Record<string, string>,
});

So request.url will be /auth/xxx (since the sub-router maps /api) and the full URL therefore http://localhost:3000/auth/xxx instead of http://localhost:3000/api/auth/xxx. I'd suggest that the full URL should instead be built like this if the incoming request is an Express request:

const fullUrl = base + req.baseUrl + req.url;
@niklas-der commented on GitHub (Aug 8, 2025): This seems to be an issue within [better-call](https://github.com/Bekacru/better-call). The `toNodeHandler` passes a static base URL to `getRequest` (see https://github.com/Bekacru/better-call/blob/5ea5ddfd9dbcbdd7481fcfbc876b3e959b5ce14a/src/adapters/node/node.ts#L10). This will result in `base` being equal to something like `http://localhost:3000`. `getRequest` then builds the native `Request` object and passes the wrong URL, since `request.url` is only the path relative to the sub-router path in the case of express.js request objects: ```ts return new Request(base + request.url, { // @ts-expect-error duplex: "half", method: request.method, body: get_raw_body(request, bodySizeLimit), headers: request.headers as Record<string, string>, }); ``` So `request.url` will be `/auth/xxx` (since the sub-router maps `/api`) and the full URL therefore `http://localhost:3000/auth/xxx` instead of `http://localhost:3000/api/auth/xxx`. I'd suggest that the full URL should instead be built like this if the incoming request is an Express request: ```ts const fullUrl = base + req.baseUrl + req.url; ```
Author
Owner

@Kinfe123 commented on GitHub (Aug 9, 2025):

if that is the case it is a Yes. It just identifies that using req.url inside a subrouter drops the mount path, which is why you’re seeing 404.

@Kinfe123 commented on GitHub (Aug 9, 2025): if that is the case it is a Yes. It just identifies that using req.url inside a subrouter drops the mount path, which is why you’re seeing 404.
Author
Owner

@Kinfe123 commented on GitHub (Aug 9, 2025):

should be fixed on the latest release of better-call.

@Kinfe123 commented on GitHub (Aug 9, 2025): should be fixed on the latest release of better-call.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1669