diff --git a/.changeset/register-application-type.md b/.changeset/register-application-type.md
new file mode 100644
index 0000000000..6006357916
--- /dev/null
+++ b/.changeset/register-application-type.md
@@ -0,0 +1,5 @@
+---
+"@better-auth/oauth-provider": minor
+---
+
+Accept `application_type` (OIDC Registration §2, required of MCP clients by the MCP 2026-07-28 spec) in dynamic client registration. When provided, redirect URIs are constrained to the declared type: `native` clients may use custom schemes, loopback `http`, or `https`; `web` clients require `https` on non-loopback hosts. Omitting the parameter keeps prior behavior.
diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx
index dbd3e0e1dc..1c69d01468 100644
--- a/docs/content/docs/plugins/oauth-provider.mdx
+++ b/docs/content/docs/plugins/oauth-provider.mdx
@@ -422,7 +422,7 @@ oauthProvider({
To enable open client registration without a Better Auth session, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response.
- For MCP public-client identity, [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991) are [available](/docs/plugins/cimd).
+ For MCP public-client identity, use [Client ID Metadata Documents](/docs/plugins/cimd). The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) deprecates Dynamic Client Registration in favor of CIMD; DCR remains supported for backwards compatibility.
```ts title="auth.ts"
@@ -448,6 +448,8 @@ const client = await authClient.oauth2.register({
For all endpoint parameters, see [RFC 7591 Registration](https://datatracker.ietf.org/doc/html/rfc7591#section-2).
+When `application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) is provided, redirect URIs are constrained to what the declared type allows: `native` clients may use custom schemes, loopback `http` redirects, or claimed `https` links — plain `http` on a routable host is rejected; `web` clients require `https` redirect URIs on non-loopback hosts. MCP clients are required to send an appropriate `application_type` by the [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints). When omitted, no `application_type` constraints are applied.
+
Note the following parameters are not yet supported:
* `sector_identifier_uri`
diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts
index 74475f728b..acd01374bb 100644
--- a/packages/oauth-provider/src/register.test.ts
+++ b/packages/oauth-provider/src/register.test.ts
@@ -1424,3 +1424,98 @@ describe("oauth register - protected dynamic registration", async () => {
expect(tokenBody.user_id).toBeUndefined();
});
});
+
+describe("oauth register - application_type", async () => {
+ const authServerBaseUrl = "http://localhost:3000";
+ const { customFetchImpl } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ allowDynamicClientRegistration: true,
+ allowUnauthenticatedClientRegistration: true,
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const register = (body: Record) =>
+ customFetchImpl(`${authServerBaseUrl}/api/auth/oauth2/register`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ it("registers a native client with loopback and custom-scheme redirect URIs", async () => {
+ const response = await register({
+ application_type: "native",
+ token_endpoint_auth_method: "none",
+ redirect_uris: [
+ "http://localhost:3005/callback",
+ "http://127.0.0.1:3005/callback",
+ "com.example.app:/callback",
+ ],
+ });
+ expect(response.status).toBe(201);
+ const body = (await response.json()) as OAuthClient;
+ expect(body.application_type).toBe("native");
+ });
+
+ it("rejects a native client with an http redirect URI on a routable host", async () => {
+ const response = await register({
+ application_type: "native",
+ token_endpoint_auth_method: "none",
+ redirect_uris: ["http://example.com/callback"],
+ });
+ expect(response.status).toBe(400);
+ const body = (await response.json()) as { error?: string };
+ expect(body.error).toBe("invalid_redirect_uri");
+ });
+
+ it("rejects a web client with an http redirect URI", async () => {
+ const response = await register({
+ application_type: "web",
+ redirect_uris: ["http://example.com/callback"],
+ });
+ expect(response.status).toBe(400);
+ const body = (await response.json()) as { error?: string };
+ expect(body.error).toBe("invalid_redirect_uri");
+ });
+
+ it("rejects a web client with a loopback redirect URI", async () => {
+ const response = await register({
+ application_type: "web",
+ redirect_uris: ["https://localhost:3005/callback"],
+ });
+ expect(response.status).toBe(400);
+ const body = (await response.json()) as { error?: string };
+ expect(body.error).toBe("invalid_redirect_uri");
+ });
+
+ it("registers a web client with an https redirect URI", async () => {
+ const response = await register({
+ application_type: "web",
+ redirect_uris: ["https://rp.example.com/callback"],
+ });
+ expect(response.status).toBe(201);
+ const body = (await response.json()) as OAuthClient;
+ expect(body.application_type).toBe("web");
+ });
+
+ it("keeps prior behavior when application_type is omitted", async () => {
+ // OIDC Registration defaults application_type to "web", but enforcing
+ // that default would break existing localhost integrations; constraints
+ // only apply when the parameter is sent explicitly.
+ const response = await register({
+ redirect_uris: ["http://localhost:5000/api/auth/callback/test"],
+ });
+ expect(response.status).toBe(201);
+ const body = (await response.json()) as OAuthClient;
+ expect(body.application_type).toBeUndefined();
+ });
+});
diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts
index b00117c3f3..bde6684d10 100644
--- a/packages/oauth-provider/src/register.ts
+++ b/packages/oauth-provider/src/register.ts
@@ -295,6 +295,35 @@ export async function checkOAuthClient(
});
}
+ // OIDC Registration §2 / MCP SEP-837: when `application_type` is sent,
+ // constrain redirect URIs to what the declared type allows. Only enforced
+ // when the parameter is explicit, so registrations predating this check
+ // keep working.
+ if (clientWithDefaults.application_type) {
+ for (const uri of clientWithDefaults.redirect_uris ?? []) {
+ const url = new URL(uri);
+ const isHttp = url.protocol === "http:";
+ const isHttps = url.protocol === "https:";
+ const isLoopback = (isHttp || isHttps) && isLoopbackHost(url.hostname);
+ if (clientWithDefaults.application_type === "native") {
+ // Native clients redirect to custom schemes, loopback http
+ // interception servers, or claimed https links — never plain
+ // http on a routable host.
+ if (isHttp && !isLoopback) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_redirect_uri",
+ error_description: `native clients must not use http redirect URIs on non-loopback hosts: ${uri}`,
+ });
+ }
+ } else if (!isHttps || isLoopback) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_redirect_uri",
+ error_description: `web clients require https redirect URIs on non-loopback hosts: ${uri}`,
+ });
+ }
+ }
+ }
+
// Validate correlation between grant_types and response_types
const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
for (const grantType of grantTypes) {
diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts
index 291f89429c..178cf83c0b 100644
--- a/packages/oauth-provider/src/types/oauth.ts
+++ b/packages/oauth-provider/src/types/oauth.ts
@@ -387,6 +387,17 @@ export interface OAuthClient {
grant_types?: GrantType[];
response_types?: "code"[];
// | "token" // NEVER SUPPORT - deprecated in OAuth 2.1
+ /**
+ * OIDC Registration application type, constraining redirect URI schemes and
+ * hosts when provided: `native` clients use custom schemes, loopback `http`
+ * redirects, or claimed `https` links; `web` clients require `https` on a
+ * non-loopback host. Distinct from `type`, which tracks the RFC 6749 client
+ * profile.
+ *
+ * @default "web"
+ * @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
+ */
+ application_type?: "web" | "native";
//---- RFC6749 Spec ----//
public?: boolean;
type?: "web" | "native" | "user-agent-based";
diff --git a/packages/oauth-provider/src/types/zod.ts b/packages/oauth-provider/src/types/zod.ts
index dfd879fdcf..be22e9fe13 100644
--- a/packages/oauth-provider/src/types/zod.ts
+++ b/packages/oauth-provider/src/types/zod.ts
@@ -207,6 +207,10 @@ export const clientRegistrationRequestSchema = z.object({
grant_types: z.array(z.string().trim().min(1)).min(1).optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
+ // OIDC Registration §2 / MCP SEP-837: constrains redirect URI schemes and
+ // hosts when provided. Distinct from `type`, which tracks the RFC 6749
+ // client profile.
+ application_type: z.enum(["web", "native"]).optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),