[GH-ISSUE #4736] MCP OAuth flow skips consent redirect despite consentPage being set #18671

Closed
opened 2026-04-15 17:14:41 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @werkamsus on GitHub (Sep 18, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/4736

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure BetterAuth with the MCP plugin exactly as below (auth server running at http://localhost:3000, frontend at http://localhost:5173):
   import { betterAuth } from "better-auth";
   import { mcp, jwt } from "better-auth/plugins";

   const APP_URL = "http://localhost:5173";
   const AUTH_URL = "http://localhost:3000";

   const mcpLoginPage = new URL("/sign-in", APP_URL).toString();
   const mcpConsentPage = new URL("/oauth/consent", APP_URL).toString();

   export const auth = betterAuth({
     secret: "test-secret",
     options: { baseURL: AUTH_URL },
     plugins: [
       jwt(),
       mcp({
         loginPage: mcpLoginPage,
         resource: AUTH_URL,
         oidcConfig: {
           loginPage: mcpLoginPage,
           consentPage: mcpConsentPage,
           allowDynamicClientRegistration: true,
         },
       }),
     ],
   });
  1. Build a minimal React/Vite frontend that mounts a route at /oauth/consent which simply posts to /oauth2/consent when the user clicks “Allow”.

  2. Register an MCP OAuth client (PKCE enabled, skipConsent=false) and initiate the authorization request with prompt=consent.

  3. Complete the BetterAuth login at /sign-in.

  4. Observe that the browser is redirected straight back to the original redirect_uri with an authorization code; the consent screen at /oauth/consent never renders. No oidc_consent_prompt cookie is set and /oauth2/consent is never hit.

Current vs. Expected behavior

Following the steps above, I expected BetterAuth to 302 to https://localhost:5173/oauth/consent?...,
set the oidc_consent_prompt cookie, and wait for the /oauth2/consent POST before issuing the authorization code.

Instead, the MCP plugin skips the consent branch entirely and returns an authorization code immediately,
so users can’t approve scopes.

What version of Better Auth are you using?

1.3.11

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.0.0: Mon Aug 25 21:17:54 PDT 2025; root:xnu-12377.1.9~3/RELEASE_ARM64_T6041",
    "release": "25.0.0",
    "cpuCount": 14,
    "cpuModel": "Apple M4 Pro",
    "totalMemory": "48.00 GB",
    "freeMemory": "0.82 GB"
  },
  "node": {
    "version": "v24.6.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.2.21"
  },
  "frameworks": [
    {
      "name": "react",
      "version": "19.1.0"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "^1.3.11",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

const genericOAuthWorkspaceConfig = {
  providerId: "googleWorkspace",
  discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration",
  clientId: env.AUTH_GOOGLE_CLIENT_ID,
  clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
  scopes: [
    "openid",
    "email",
    "profile",
    "https://www.googleapis.com/auth/admin.directory.user.readonly",
    "https://www.googleapis.com/auth/admin.directory.group.readonly",
    "https://www.googleapis.com/auth/admin.directory.group.member.readonly",
    "https://www.googleapis.com/auth/admin.directory.userschema.readonly",
    "https://www.googleapis.com/auth/admin.directory.user.security",
    "https://www.googleapis.com/auth/admin.reports.audit.readonly",
  ],
  accessType: "offline",
  prompt: "consent" as const,
  pkce: true,
};

// Initialize betterAuth
// Explicitly annotate the type using ReturnType to avoid TS2742 error.
// This error occurs in composite projects when TypeScript tries to generate
// declaration files for inferred types that rely on non-portable internal paths
// from dependencies. We'll monitor this pattern for potential issues if
// `betterAuth`'s exported types change significantly.
export const auth: ReturnType<typeof betterAuth> = betterAuth({
  secret: env.BETTER_AUTH_SECRET, // Use validated env
  disabledPaths: ["/token"], // Disable default /token endpoint for OIDC compliance
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      user: users,
      session: sessions,
      account: accounts,
      verification: verifications,
      passkey: passkeys,
      organization: organizations,
      member: members,
      invitation: invitations,
      jwks: jwks,
      oauthApplication: oauthApplications,
      oauthAccessToken: oauthAccessTokens,
      oauthConsent: oauthConsents,
      // Map the actual table names that Better Auth is looking for
      auth_users: users,
      auth_sessions: sessions,
      auth_accounts: accounts,
      auth_verifications: verifications,
      auth_passkeys: passkeys,
      auth_organizations: organizations,
      auth_members: members,
      auth_invitations: invitations,
      auth_jwks: jwks,
      auth_oauth_applications: oauthApplications,
      auth_oauth_access_tokens: oauthAccessTokens,
      auth_oauth_consents: oauthConsents,
    },
  }),
  advanced: {
    database: {
      generateId: false,
    },
  },
  // Setting BETTER_AUTH_URL to app URL (with trailing slashes for security)
  trustedOrigins: [env.BETTER_AUTH_URL, env.APP_URL], // Allow both backend and frontend URLs
  emailAndPassword: {
    // Disabled per security audit DOY-Q223-04
    // Email/password authentication bypasses organization whitelist
    // and doesn't require email verification
    enabled: false,
  },
  session: {
    cookieCache: {
      enabled: false,
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["google", "googleWorkspace"],
    },
  },
  socialProviders: {
    google: {
      // Only enable if both keys exist
      clientId: env.AUTH_GOOGLE_CLIENT_ID,
      clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
      scope: ["openid", "email", "profile"],
      mapProfileToUser: (profile) => {
        return {
          name: profile.name || profile.given_name || profile.email?.split("@")[0] || "User",
          image: profile.picture || undefined,
        };
      },
    },
  },
  plugins: [
    // Add test session plugin only in test mode (includes production/staging checks)
    ...(isTestUtilsEnabled() ? [testSessionPlugin()] : []),

    // JWT plugin for OIDC token signing
    jwt({
      disableSettingJwtHeader: true, // For OAuth compliance
    }),

    // MCP plugin for OAuth MCP support
    mcp({
      loginPage: "/sign-in",
      resource: env.BETTER_AUTH_URL,
      oidcConfig: {
        loginPage: "/sign-in",
        consentPage: "/oauth/consent",

        allowDynamicClientRegistration: true,
      },
    }),

    genericOAuth({
      config: [genericOAuthWorkspaceConfig],
    }),
....

Additional context

No response

Originally created by @werkamsus on GitHub (Sep 18, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/4736 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure BetterAuth with the MCP plugin exactly as below (auth server running at http://localhost:3000, frontend at http://localhost:5173): ``` import { betterAuth } from "better-auth"; import { mcp, jwt } from "better-auth/plugins"; const APP_URL = "http://localhost:5173"; const AUTH_URL = "http://localhost:3000"; const mcpLoginPage = new URL("/sign-in", APP_URL).toString(); const mcpConsentPage = new URL("/oauth/consent", APP_URL).toString(); export const auth = betterAuth({ secret: "test-secret", options: { baseURL: AUTH_URL }, plugins: [ jwt(), mcp({ loginPage: mcpLoginPage, resource: AUTH_URL, oidcConfig: { loginPage: mcpLoginPage, consentPage: mcpConsentPage, allowDynamicClientRegistration: true, }, }), ], }); ``` 2. Build a minimal React/Vite frontend that mounts a route at /oauth/consent which simply posts to /oauth2/consent when the user clicks “Allow”. 3. Register an MCP OAuth client (PKCE enabled, skipConsent=false) and initiate the authorization request with prompt=consent. 4. Complete the BetterAuth login at /sign-in. 5. Observe that the browser is redirected straight back to the original redirect_uri with an authorization code; the consent screen at /oauth/consent never renders. No oidc_consent_prompt cookie is set and /oauth2/consent is never hit. ### Current vs. Expected behavior Following the steps above, I expected BetterAuth to 302 to https://localhost:5173/oauth/consent?..., set the oidc_consent_prompt cookie, and wait for the /oauth2/consent POST before issuing the authorization code. Instead, the MCP plugin skips the consent branch entirely and returns an authorization code immediately, so users can’t approve scopes. ### What version of Better Auth are you using? 1.3.11 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.0.0: Mon Aug 25 21:17:54 PDT 2025; root:xnu-12377.1.9~3/RELEASE_ARM64_T6041", "release": "25.0.0", "cpuCount": 14, "cpuModel": "Apple M4 Pro", "totalMemory": "48.00 GB", "freeMemory": "0.82 GB" }, "node": { "version": "v24.6.0", "env": "development" }, "packageManager": { "name": "bun", "version": "1.2.21" }, "frameworks": [ { "name": "react", "version": "19.1.0" } ], "databases": null, "betterAuth": { "version": "^1.3.11", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript const genericOAuthWorkspaceConfig = { providerId: "googleWorkspace", discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration", clientId: env.AUTH_GOOGLE_CLIENT_ID, clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET, scopes: [ "openid", "email", "profile", "https://www.googleapis.com/auth/admin.directory.user.readonly", "https://www.googleapis.com/auth/admin.directory.group.readonly", "https://www.googleapis.com/auth/admin.directory.group.member.readonly", "https://www.googleapis.com/auth/admin.directory.userschema.readonly", "https://www.googleapis.com/auth/admin.directory.user.security", "https://www.googleapis.com/auth/admin.reports.audit.readonly", ], accessType: "offline", prompt: "consent" as const, pkce: true, }; // Initialize betterAuth // Explicitly annotate the type using ReturnType to avoid TS2742 error. // This error occurs in composite projects when TypeScript tries to generate // declaration files for inferred types that rely on non-portable internal paths // from dependencies. We'll monitor this pattern for potential issues if // `betterAuth`'s exported types change significantly. export const auth: ReturnType<typeof betterAuth> = betterAuth({ secret: env.BETTER_AUTH_SECRET, // Use validated env disabledPaths: ["/token"], // Disable default /token endpoint for OIDC compliance database: drizzleAdapter(db, { provider: "pg", schema: { user: users, session: sessions, account: accounts, verification: verifications, passkey: passkeys, organization: organizations, member: members, invitation: invitations, jwks: jwks, oauthApplication: oauthApplications, oauthAccessToken: oauthAccessTokens, oauthConsent: oauthConsents, // Map the actual table names that Better Auth is looking for auth_users: users, auth_sessions: sessions, auth_accounts: accounts, auth_verifications: verifications, auth_passkeys: passkeys, auth_organizations: organizations, auth_members: members, auth_invitations: invitations, auth_jwks: jwks, auth_oauth_applications: oauthApplications, auth_oauth_access_tokens: oauthAccessTokens, auth_oauth_consents: oauthConsents, }, }), advanced: { database: { generateId: false, }, }, // Setting BETTER_AUTH_URL to app URL (with trailing slashes for security) trustedOrigins: [env.BETTER_AUTH_URL, env.APP_URL], // Allow both backend and frontend URLs emailAndPassword: { // Disabled per security audit DOY-Q223-04 // Email/password authentication bypasses organization whitelist // and doesn't require email verification enabled: false, }, session: { cookieCache: { enabled: false, }, }, account: { accountLinking: { enabled: true, trustedProviders: ["google", "googleWorkspace"], }, }, socialProviders: { google: { // Only enable if both keys exist clientId: env.AUTH_GOOGLE_CLIENT_ID, clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET, scope: ["openid", "email", "profile"], mapProfileToUser: (profile) => { return { name: profile.name || profile.given_name || profile.email?.split("@")[0] || "User", image: profile.picture || undefined, }; }, }, }, plugins: [ // Add test session plugin only in test mode (includes production/staging checks) ...(isTestUtilsEnabled() ? [testSessionPlugin()] : []), // JWT plugin for OIDC token signing jwt({ disableSettingJwtHeader: true, // For OAuth compliance }), // MCP plugin for OAuth MCP support mcp({ loginPage: "/sign-in", resource: env.BETTER_AUTH_URL, oidcConfig: { loginPage: "/sign-in", consentPage: "/oauth/consent", allowDynamicClientRegistration: true, }, }), genericOAuth({ config: [genericOAuthWorkspaceConfig], }), .... ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-15 17:14:41 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 18, 2025):

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

This is a known issue with the MCP plugin in BetterAuth. The MCP flow often returns a JSON response with a redirect URL (e.g., to your consentPage) instead of performing an HTTP 302 redirect. This means your frontend is responsible for detecting this JSON response and programmatically navigating to the provided URL. If your frontend does not handle this, the consent screen will be skipped and the flow will continue without user approval—even if skipConsent is false and prompt=consent is set. This pattern is intentional for SPA/mobile compatibility, but it can break expected OAuth flows if not handled explicitly in the frontend [source 1] [source 2].

To fix this, update your frontend to check for a response like { redirect: true, url: "..." } after login or authorization, and then set window.location = url to continue the flow. This will ensure the consent screen is shown as expected. If you want the backend to always perform HTTP redirects, you may need to patch the MCP plugin or follow workarounds discussed in the linked issues.

Let me know if you want example code for handling this in your React/Vite frontend.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3304918355 --> @dosubot[bot] commented on GitHub (Sep 18, 2025): <!-- Greeting --> Hi @werkamsus! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known issue with the MCP plugin in BetterAuth. The MCP flow often returns a JSON response with a redirect URL (e.g., to your consentPage) instead of performing an HTTP 302 redirect. This means your frontend is responsible for detecting this JSON response and programmatically navigating to the provided URL. If your frontend does not handle this, the consent screen will be skipped and the flow will continue without user approval—even if skipConsent is false and prompt=consent is set. This pattern is intentional for SPA/mobile compatibility, but it can break expected OAuth flows if not handled explicitly in the frontend [[source 1]](https://github.com/better-auth/better-auth/issues/3714) [[source 2]](https://github.com/better-auth/better-auth/issues/2914). To fix this, update your frontend to check for a response like `{ redirect: true, url: "..." }` after login or authorization, and then set `window.location = url` to continue the flow. This will ensure the consent screen is shown as expected. If you want the backend to always perform HTTP redirects, you may need to patch the MCP plugin or follow workarounds discussed in the linked issues. Let me know if you want example code for handling this in your React/Vite frontend. <!-- 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/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/4a807d39-e3b6-4179-952a-b2732225291f?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/4736)
Author
Owner

@Berndwl commented on GitHub (Sep 19, 2025):

I have the same issue, it's because the consent change is added in the oidc-provider:
a208c09894/packages/better-auth/src/plugins/oidc-provider/authorize.ts

And the same logic was not added to the mcp plugin (which looks like it using copy pasted code segments from the above):
a208c09894/packages/better-auth/src/plugins/mcp/authorize.ts

I guess/hope it will be fixed if https://github.com/better-auth/better-auth/pull/4163 goes through 🙂

<!-- gh-comment-id:3312019249 --> @Berndwl commented on GitHub (Sep 19, 2025): I have the same issue, it's because the consent change is added in the oidc-provider: https://github.com/better-auth/better-auth/blob/a208c0989455cba0843cd34679618c457fe05c62/packages/better-auth/src/plugins/oidc-provider/authorize.ts And the same logic was not added to the mcp plugin (which looks like it using copy pasted code segments from the above): https://github.com/better-auth/better-auth/blob/a208c0989455cba0843cd34679618c457fe05c62/packages/better-auth/src/plugins/mcp/authorize.ts I guess/hope it will be fixed if https://github.com/better-auth/better-auth/pull/4163 goes through 🙂
Author
Owner

@sreuter commented on GitHub (Oct 17, 2025):

This seems like quite a big security concern if I'm not mistaken?

<!-- gh-comment-id:3413390062 --> @sreuter commented on GitHub (Oct 17, 2025): This seems like quite a big security concern if I'm not mistaken?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#18671