[GH-ISSUE #8588] oauth-provider should support unauthenticated confidential DCR for client_secret_post / client_secret_basic #28450

Closed
opened 2026-04-17 19:53:52 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @romaincointepas on GitHub (Mar 13, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8588

Originally assigned to: @gustavovalverde on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure oauth-provider with:

    allowDynamicClientRegistration: true,
    allowUnauthenticatedClientRegistration: true,
    
  2. Use an OAuth client that attempts unauthenticated dynamic client registration
    as a confidential client, for example this real payload from Claude custom
    connectors:

    {
      "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
      "token_endpoint_auth_method": "client_secret_post",
      "grant_types": ["authorization_code", "refresh_token"],
      "response_types": ["code"],
      "scope": "offline_access mcp:tools",
      "client_name": "Claude"
    }
    
  3. POST that payload to /auth/oauth2/register.

  4. Observe that the registration fails with:

    401 Unauthorized
    
  5. Change only:

    "token_endpoint_auth_method": "none"
    

    and retry the same registration flow.

  6. Observe that registration succeeds as an unauthenticated public client.

Current vs. Expected behavior

Current behavior:

  • oauth-provider advertises client_secret_basic and
    client_secret_post in token_endpoint_auth_methods_supported
  • but unauthenticated dynamic client registration only works when
    token_endpoint_auth_method === "none"
  • if the client tries unauthenticated registration with
    client_secret_post or client_secret_basic, the provider returns
    401 Unauthorized

Expected behavior:

  • either oauth-provider should support unauthenticated confidential
    dynamic client registration for at least:
    • client_secret_post
    • client_secret_basic
  • or the package should clearly document that unauthenticated DCR is
    public-client-only, even though those confidential token endpoint auth
    methods are advertised in metadata

Why this is confusing:

Metadata currently advertises:

token_endpoint_auth_methods_supported: [
  ...(overrides?.public_client_supported
    ? (["none"] satisfies TokenEndpointAuthMethod[])
    : []),
  "client_secret_basic",
  "client_secret_post",
],

Source:

But the registration implementation rejects unauthenticated clients unless
they are public clients (token_endpoint_auth_method === "none"), so hosted
OAuth clients like Claude custom connectors fail even when
allowUnauthenticatedClientRegistration is enabled.

What version of Better Auth are you using?

1.5.5

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:15 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6000",
    "release": "25.3.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Pro",
    "totalMemory": "32.00 GB",
    "freeMemory": "0.36 GB"
  },
  "node": {
    "version": "v24.14.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.9.0"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.2.0-canary.95"
    },
    {
      "name": "react",
      "version": "^19.2.4"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.20.0"
    }
  ],
  "betterAuth": {
    "version": "Unknown",
    "config": null,
    "error": "Converting circular structure to JSON\n    --> starting at object with constructor 'Stripe'\n    |     property 'account' -> object with constructor 'Constructor'\n    --- property '_stripe' closes the circle"
  }
}

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

Backend, Package

Auth config (if applicable)

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

export const auth = betterAuth({
  plugins: [
    oauthProvider({
      allowDynamicClientRegistration: true,
      allowUnauthenticatedClientRegistration: true,
      scopes: ["offline_access", "mcp:tools"],
      clientRegistrationAllowedScopes: ["offline_access", "mcp:tools"],
      clientRegistrationDefaultScopes: ["offline_access"],
    }),
  ],
});

Additional context

  • This surfaced while integrating a remote MCP server with Claude custom
    connectors.

  • The failing registration payload is a real request captured from Claude:

    {
      "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
      "token_endpoint_auth_method": "client_secret_post",
      "grant_types": ["authorization_code", "refresh_token"],
      "response_types": ["code"],
      "scope": "offline_access mcp:tools",
      "client_name": "Claude"
    }
    
  • Public DCR works correctly in the same app when the client sends
    token_endpoint_auth_method: "none".

  • This looks related to the general metadata/behavior mismatch discussed in:

  • npx auth info --json reports betterAuth.version as Unknown in this app
    because config serialization hits a circular Stripe object, but both
    better-auth and @better-auth/oauth-provider installed versions are
    1.5.5.

  • I am not saying the MCP spec requires unauthenticated confidential DCR, only
    that hosted OAuth clients in the wild use this pattern and currently fail
    against oauth-provider.

Originally created by @romaincointepas on GitHub (Mar 13, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8588 Originally assigned to: @gustavovalverde on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure `oauth-provider` with: ```ts allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, ``` 2. Use an OAuth client that attempts unauthenticated dynamic client registration as a confidential client, for example this real payload from Claude custom connectors: ```json { "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "token_endpoint_auth_method": "client_secret_post", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "offline_access mcp:tools", "client_name": "Claude" } ``` 3. POST that payload to `/auth/oauth2/register`. 4. Observe that the registration fails with: ```http 401 Unauthorized ``` 5. Change only: ```json "token_endpoint_auth_method": "none" ``` and retry the same registration flow. 6. Observe that registration succeeds as an unauthenticated public client. ### Current vs. Expected behavior Current behavior: - `oauth-provider` advertises `client_secret_basic` and `client_secret_post` in `token_endpoint_auth_methods_supported` - but unauthenticated dynamic client registration only works when `token_endpoint_auth_method === "none"` - if the client tries unauthenticated registration with `client_secret_post` or `client_secret_basic`, the provider returns `401 Unauthorized` Expected behavior: - either `oauth-provider` should support unauthenticated confidential dynamic client registration for at least: - `client_secret_post` - `client_secret_basic` - or the package should clearly document that unauthenticated DCR is public-client-only, even though those confidential token endpoint auth methods are advertised in metadata Why this is confusing: Metadata currently advertises: ```ts token_endpoint_auth_methods_supported: [ ...(overrides?.public_client_supported ? (["none"] satisfies TokenEndpointAuthMethod[]) : []), "client_secret_basic", "client_secret_post", ], ``` Source: - https://github.com/better-auth/better-auth/blob/0ba16ccbbc99035453fffbcdf8f2cd37fac0f1cf/packages/oauth-provider/src/metadata.ts#L52 But the registration implementation rejects unauthenticated clients unless they are public clients (`token_endpoint_auth_method === "none"`), so hosted OAuth clients like Claude custom connectors fail even when `allowUnauthenticatedClientRegistration` is enabled. ### What version of Better Auth are you using? 1.5.5 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:15 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6000", "release": "25.3.0", "cpuCount": 10, "cpuModel": "Apple M1 Pro", "totalMemory": "32.00 GB", "freeMemory": "0.36 GB" }, "node": { "version": "v24.14.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.9.0" }, "frameworks": [ { "name": "next", "version": "16.2.0-canary.95" }, { "name": "react", "version": "^19.2.4" } ], "databases": [ { "name": "pg", "version": "^8.20.0" } ], "betterAuth": { "version": "Unknown", "config": null, "error": "Converting circular structure to JSON\n --> starting at object with constructor 'Stripe'\n | property 'account' -> object with constructor 'Constructor'\n --- property '_stripe' closes the circle" } } ``` ### Which area(s) are affected? (Select all that apply) Backend, Package ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { oauthProvider } from "@better-auth/oauth-provider"; export const auth = betterAuth({ plugins: [ oauthProvider({ allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, scopes: ["offline_access", "mcp:tools"], clientRegistrationAllowedScopes: ["offline_access", "mcp:tools"], clientRegistrationDefaultScopes: ["offline_access"], }), ], }); ``` ### Additional context - This surfaced while integrating a remote MCP server with Claude custom connectors. - The failing registration payload is a real request captured from Claude: ```json { "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "token_endpoint_auth_method": "client_secret_post", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "offline_access mcp:tools", "client_name": "Claude" } ``` - Public DCR works correctly in the same app when the client sends `token_endpoint_auth_method: "none"`. - This looks related to the general metadata/behavior mismatch discussed in: - https://github.com/better-auth/better-auth/issues/8423 - https://github.com/better-auth/better-auth/issues/4461 - `npx auth info --json` reports `betterAuth.version` as `Unknown` in this app because config serialization hits a circular `Stripe` object, but both `better-auth` and `@better-auth/oauth-provider` installed versions are `1.5.5`. - I am not saying the MCP spec requires unauthenticated confidential DCR, only that hosted OAuth clients in the wild use this pattern and currently fail against `oauth-provider`.
GiteaMirror added the identity label 2026-04-17 19:53:52 -05:00
Author
Owner

@dvanmali commented on GitHub (Mar 13, 2026):

or the package should clearly document that unauthenticated DCR is
public-client-only, even though those confidential token endpoint auth
methods are advertised in metadata

Only public clients are allowed via unauthenticated DCR for security reasons.

Docs currently states for "public clients (never confidential)" L1047:

Unauthenticated client registration additionally allows for public clients (never confidential) to register without an authorization header. This is especially useful for an MCP to dynamically register themselves as a public client.

Side note: allowUnauthenticatedClientRegistration: true is temporary and will be deprecated, so it shouldn't be utilized in production. You'd likely want the alternative with CIMD #7185.

PR Closable

<!-- gh-comment-id:4056333773 --> @dvanmali commented on GitHub (Mar 13, 2026): > or the package should clearly document that unauthenticated DCR is > public-client-only, even though those confidential token endpoint auth > methods are advertised in metadata Only public clients are allowed via unauthenticated DCR for security reasons. Docs currently states for "public clients (never confidential)" [L1047](https://github.com/dvanmali/better-auth/blob/0ba16ccbbc99035453fffbcdf8f2cd37fac0f1cf/docs/content/docs/plugins/oauth-provider.mdx?plain=1#L1047): > Unauthenticated client registration additionally allows for public clients (never confidential) to register without an authorization header. This is especially useful for an MCP to dynamically register themselves as a public client. Side note: `allowUnauthenticatedClientRegistration: true` is temporary and will be deprecated, so it shouldn't be utilized in production. You'd likely want the alternative with CIMD #7185. PR Closable
Author
Owner

@romaincointepas commented on GitHub (Mar 13, 2026):

Worth noting that a lot of MCP clients currently use and only support DCR:

  • claude code
  • codex (cli and app)
  • opencode
  • mcporter (recommended MCP setup path in openclaw)

and many others, those are the big ones.

claude code desktop app currently uses confidential DCR (so not working right now), i've seen another popular one using it, can't remember which one.

<!-- gh-comment-id:4057927688 --> @romaincointepas commented on GitHub (Mar 13, 2026): Worth noting that a lot of MCP clients currently use and only support DCR: - claude code - codex (cli and app) - opencode - mcporter (recommended MCP setup path in openclaw) and many others, those are the big ones. claude code desktop app currently uses confidential DCR (so not working right now), i've seen another popular one using it, can't remember which one.
Author
Owner

@ebramanti commented on GitHub (Mar 27, 2026):

We hit this same issue with Factory Droid connecting to our MCP server built on @better-auth/oauth-provider.

Droid's @modelcontextprotocol/sdk reads token_endpoint_auth_methods_supported from /.well-known/oauth-authorization-server, sees client_secret_basic advertised, and uses it for DCR. Registration then fails with "Authentication required for confidential client registration."

The metadata is the problem regardless of policy. authServerMetadata() unconditionally includes client_secret_basic and client_secret_post in token_endpoint_auth_methods_supported (source), but the registration endpoint rejects those methods for unauthenticated requests (source). Compliant clients follow the metadata and then get rejected.

CIMD (#7185) doesn't resolve this either, it also blocks client_secret_post/client_secret_basic in CIMD metadata, and the auth server metadata still advertises them.

Our workaround is intercepting /api/auth/oauth2/register and rewriting client_secret_basic/client_secret_post to "none" before forwarding to BetterAuth:

.post("/api/auth/oauth2/register", async ({ auth, request, body }) => {
  if (
    body["token_endpoint_auth_method"] === "client_secret_post" ||
    body["token_endpoint_auth_method"] === "client_secret_basic"
  ) {
    body["token_endpoint_auth_method"] = "none";
  }
  return auth.handler(new Request(request, { body: JSON.stringify(body) }));
})

This is safe in our context because oauth-provider already enforces PKCE S256 for all clients, which fully protects the authorization code exchange. For refresh tokens, there's a theoretical reduction compared to confidential clients (a stolen refresh token doesn't require a secret to use), but oauth-provider mitigates this with refresh token rotation. More importantly, in the context of unauthenticated DCR, the client secret provides no additional trust -- any party can register and receive one, so it's not a pre-established credential. The secret only binds subsequent requests to the same registration, which PKCE and token rotation already handle.

This workaround shouldn't be necessary. If unauthenticated DCR is public-only, token_endpoint_auth_methods_supported should reflect that (only advertise "none" when allowUnauthenticatedClientRegistration is enabled). Alternatively, accept the advertised methods and create public clients from them.

<!-- gh-comment-id:4141707108 --> @ebramanti commented on GitHub (Mar 27, 2026): We hit this same issue with [Factory Droid](https://factory.ai) connecting to our MCP server built on `@better-auth/oauth-provider`. Droid's `@modelcontextprotocol/sdk` reads `token_endpoint_auth_methods_supported` from `/.well-known/oauth-authorization-server`, sees `client_secret_basic` advertised, and uses it for DCR. Registration then fails with "Authentication required for confidential client registration." The metadata is the problem regardless of policy. `authServerMetadata()` unconditionally includes `client_secret_basic` and `client_secret_post` in `token_endpoint_auth_methods_supported` ([source](https://github.com/better-auth/better-auth/blob/0ba16ccbbc99035453fffbcdf8f2cd37fac0f1cf/packages/oauth-provider/src/metadata.ts#L52-L56)), but the registration endpoint rejects those methods for unauthenticated requests ([source](https://github.com/better-auth/better-auth/blob/0ba16ccbbc99035453fffbcdf8f2cd37fac0f1cf/packages/oauth-provider/src/register.ts#L43-L48)). Compliant clients follow the metadata and then get rejected. CIMD (#7185) doesn't resolve this either, it also blocks `client_secret_post`/`client_secret_basic` in CIMD metadata, and the auth server metadata still advertises them. Our workaround is intercepting `/api/auth/oauth2/register` and rewriting `client_secret_basic`/`client_secret_post` to `"none"` before forwarding to BetterAuth: ```ts .post("/api/auth/oauth2/register", async ({ auth, request, body }) => { if ( body["token_endpoint_auth_method"] === "client_secret_post" || body["token_endpoint_auth_method"] === "client_secret_basic" ) { body["token_endpoint_auth_method"] = "none"; } return auth.handler(new Request(request, { body: JSON.stringify(body) })); }) ``` This is safe in our context because `oauth-provider` already enforces PKCE S256 for all clients, which fully protects the authorization code exchange. For refresh tokens, there's a theoretical reduction compared to confidential clients (a stolen refresh token doesn't require a secret to use), but `oauth-provider` mitigates this with refresh token rotation. More importantly, in the context of unauthenticated DCR, the client secret provides no additional trust -- any party can register and receive one, so it's not a pre-established credential. The secret only binds subsequent requests to the same registration, which PKCE and token rotation already handle. This workaround shouldn't be necessary. If unauthenticated DCR is public-only, `token_endpoint_auth_methods_supported` should reflect that (only advertise `"none"` when `allowUnauthenticatedClientRegistration` is enabled). Alternatively, accept the advertised methods and create public clients from them.
Author
Owner

@dvanmali commented on GitHub (Mar 27, 2026):

@ebramanti token_endpoint_auth_methods_supported advertises support methods for the token endpoint but does not suggest the registration method or that requests will be accepted. Currently, the metadata advertises client_secret_basic and client_secret_post because users can create a portal(1) to register confidential client applications, this allows your auth server to know who created those application credentials.

The major issue with unauthenticated confidential clients is that you should know who created those application credentials (fills in the userId/referenceId on the OAuthClient table). CIMD solves the identity issue when it comes to confidential client attempting to register (the flow is a new registration flow, not DCR).


(1) Creating a OAuth portal. This has additional benefits such as obtaining approval for scopes via your own logic or manual approval flow. Examples of this in practice is Google Sign-In and its metadata, Facebook App Creation and its metadata (spec default "client_secret_basic" when unspecified).

<!-- gh-comment-id:4143894436 --> @dvanmali commented on GitHub (Mar 27, 2026): @ebramanti `token_endpoint_auth_methods_supported` advertises support methods for the token endpoint _but does not suggest the registration method or that requests will be accepted_. Currently, the metadata advertises `client_secret_basic` and `client_secret_post` because users can create a portal<sup>(1)</sup> to register confidential client applications, this allows your auth server to know who created those application credentials. The major issue with unauthenticated confidential clients is that you should know who created those application credentials (fills in the `userId`/`referenceId` on the OAuthClient table). CIMD solves the identity issue when it comes to confidential client attempting to register (the flow is a new registration flow, not DCR). ---- <sup>(1)</sup> Creating a OAuth portal. This has additional benefits such as obtaining approval for scopes via your own logic or manual approval flow. Examples of this in practice is [Google Sign-In](https://console.developers.google.com/auth/clients) and its [metadata](https://accounts.google.com/.well-known/openid-configuration), [Facebook App Creation](https://developers.facebook.com/docs/development/create-an-app/) and its [metadata](https://facebook.com/.well-known/openid-configuration) [(spec default "client_secret_basic" when unspecified)](https://datatracker.ietf.org/doc/html/rfc7591#section-2).
Author
Owner

@gustavovalverde commented on GitHub (Apr 11, 2026):

This is now addressed in #9123.

@dvanmali is correct that token_endpoint_auth_methods_supported describes what the token endpoint accepts, not what DCR produces. The metadata is not the problem.

The problem is what happens when a compliant client reads the metadata, picks client_secret_post (as the spec allows), and sends it in the DCR payload. Today that returns a 401, even though allowUnauthenticatedClientRegistration is enabled. When the client omits the field entirely, RFC 7591 Section 2 defaults it to client_secret_basic, which also gets rejected. So unauthenticated DCR only works if the client explicitly requests "none", and most MCP clients don't.

The fix uses the authority RFC 7591 Section 3.2.1 gives to the server: "the authorization server MAY reject or replace any of the client's requested metadata values and substitute them with suitable values." The server overrides the requested method to "none" and communicates that back in the registration response. Compliant clients (including the MCP TypeScript SDK's selectClientAuthMethod()) already adjust based on the response.

This is effectively what @ebramanti's workaround does at the application layer. The PR moves it into the provider where it belongs.

With this approach is worth noting that no security is lost: in unauthenticated DCR, a client_secret provides no meaningful trust since anyone can register and receive one. PKCE S256 (required for all registered clients) is the actual security boundary. Refresh token rotation handles post-issuance binding.

CIMD (#7185) solves a different problem (client identity verification), and both are needed during the transition: CIMD for clients that support it, and graceful method negotiation for those that don't.

<!-- gh-comment-id:4229646666 --> @gustavovalverde commented on GitHub (Apr 11, 2026): This is now addressed in #9123. @dvanmali is correct that `token_endpoint_auth_methods_supported` describes what the token endpoint accepts, not what DCR produces. The metadata is not the problem. The problem is what happens when a compliant client reads the metadata, picks `client_secret_post` (as the spec allows), and sends it in the DCR payload. Today that returns a 401, even though `allowUnauthenticatedClientRegistration` is enabled. When the client omits the field entirely, RFC 7591 Section 2 defaults it to `client_secret_basic`, which also gets rejected. So unauthenticated DCR only works if the client explicitly requests `"none"`, and most MCP clients don't. The fix uses the authority RFC 7591 Section 3.2.1 gives to the server: "the authorization server MAY reject or replace any of the client's requested metadata values and substitute them with suitable values." The server overrides the requested method to `"none"` and communicates that back in the registration response. Compliant clients (including the MCP TypeScript SDK's `selectClientAuthMethod()`) already adjust based on the response. This is effectively what @ebramanti's workaround does at the application layer. The PR moves it into the provider where it belongs. With this approach is worth noting that no security is lost: in unauthenticated DCR, a `client_secret` provides no meaningful trust since anyone can register and receive one. PKCE S256 (required for all registered clients) is the actual security boundary. Refresh token rotation handles post-issuance binding. CIMD (#7185) solves a different problem (client identity verification), and both are needed during the transition: CIMD for clients that support it, and graceful method negotiation for those that don't.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28450