diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx
index b81fdf76fe..ca152c0e34 100644
--- a/docs/content/docs/plugins/oauth-provider.mdx
+++ b/docs/content/docs/plugins/oauth-provider.mdx
@@ -1368,6 +1368,56 @@ oauthProvider({
```
+### Pairwise Subject Identifiers
+
+By default, the `sub` (subject) claim in tokens uses the user's internal ID, which is the same across all clients. This is the **public** subject type per [OIDC Core Section 8](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes).
+
+You can enable **pairwise** subject identifiers so each client receives a unique, unlinkable `sub` for the same user. This prevents relying parties from correlating users across services.
+
+```ts title="auth.ts"
+oauthProvider({
+ pairwiseSecret: "your-256-bit-secret", // [!code highlight]
+})
+```
+
+When `pairwiseSecret` is configured, the server advertises both `"public"` and `"pairwise"` in the discovery endpoint's `subject_types_supported`. Clients opt in by setting `subject_type: "pairwise"` at registration.
+
+#### Per-Client Configuration
+
+```ts title="register-client.ts"
+const response = await auth.api.createOAuthClient({
+ headers,
+ body: {
+ client_name: 'Privacy-Sensitive App',
+ redirect_uris: ['https://app.example.com/callback'],
+ token_endpoint_auth_method: 'client_secret_post',
+ subject_type: 'pairwise', // Enable pairwise sub for this client
+ }
+});
+```
+
+#### How It Works
+
+Pairwise identifiers are computed using HMAC-SHA256 over the **sector identifier** (the host of the client's first redirect URI) and the user ID, keyed with `pairwiseSecret`. This means:
+
+- Two clients with different redirect URI hosts always receive different `sub` values for the same user
+- Two clients sharing the same redirect URI host receive the **same** pairwise `sub` (per OIDC Core Section 8.1)
+- The same client always receives the same `sub` for the same user (deterministic)
+
+Pairwise `sub` appears in:
+- `id_token`
+- `/oauth2/userinfo` response
+- Token introspection (`/oauth2/introspect`)
+
+JWT access tokens always use the real user ID as `sub`, since resource servers may need to look up users directly.
+
+
+**Limitations:**
+- `sector_identifier_uri` is not yet supported. All `redirect_uris` for a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration.
+- `pairwiseSecret` must be at least 32 characters long.
+- Rotating `pairwiseSecret` will change all pairwise `sub` values, breaking existing RP sessions. Treat this secret as permanent once set.
+
+
### MCP
You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/specification/draft/basic/authorization) simply by adding a resource server which directs users to this OAuth 2.1 authorization server.
@@ -1577,6 +1627,12 @@ Table Name: `oauthClient`
description: "Field that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.",
isOptional: true,
},
+ {
+ name: "subjectType",
+ type: "string",
+ description: "Subject identifier type for this client. Set to \"pairwise\" to receive unique, unlinkable sub claims per user. Requires pairwiseSecret to be configured on the server.",
+ isOptional: true,
+ },
{
name: "scopes",
type: "string[]",
diff --git a/packages/oauth-provider/src/introspect.ts b/packages/oauth-provider/src/introspect.ts
index 491904ff56..5683d33a40 100644
--- a/packages/oauth-provider/src/introspect.ts
+++ b/packages/oauth-provider/src/introspect.ts
@@ -18,6 +18,7 @@ import {
getJwtPlugin,
getStoredToken,
parseClientMetadata,
+ resolveSubjectIdentifier,
validateClientCredentials,
} from "./utils";
@@ -371,6 +372,27 @@ export async function validateAccessToken(
});
}
+/**
+ * Resolves pairwise sub on an introspection payload.
+ * Applied at the presentation layer so internal validation functions
+ * keep real user.id (needed for user lookup in /userinfo).
+ */
+async function resolveIntrospectionSub(
+ opts: OAuthOptions,
+ payload: JWTPayload,
+ client: SchemaClient,
+): Promise {
+ if (payload.active && payload.sub) {
+ const resolvedSub = await resolveSubjectIdentifier(
+ payload.sub as string,
+ client,
+ opts,
+ );
+ return { ...payload, sub: resolvedSub };
+ }
+ return payload;
+}
+
export async function introspectEndpoint(
ctx: GenericEndpointContext,
opts: OAuthOptions,
@@ -429,7 +451,7 @@ export async function introspectEndpoint(
token,
client.clientId,
);
- return payload;
+ return resolveIntrospectionSub(opts, payload, client);
} catch (error) {
if (error instanceof APIError) {
if (token_type_hint === "access_token") {
@@ -452,7 +474,7 @@ export async function introspectEndpoint(
refreshToken.token,
client.clientId,
);
- return payload;
+ return resolveIntrospectionSub(opts, payload, client);
} catch (error) {
if (error instanceof APIError) {
if (token_type_hint === "refresh_token") {
diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts
index 50d7ab4886..213dec4385 100644
--- a/packages/oauth-provider/src/metadata.ts
+++ b/packages/oauth-provider/src/metadata.ts
@@ -89,7 +89,9 @@ export function oidcServerMetadata(
claims_supported:
opts?.advertisedMetadata?.claims_supported ?? opts?.claims ?? [],
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
- subject_types_supported: ["public"],
+ subject_types_supported: opts.pairwiseSecret
+ ? ["public", "pairwise"]
+ : ["public"],
id_token_signing_alg_values_supported: jwtPluginOptions?.jwks?.keyPairConfig
?.alg
? [jwtPluginOptions?.jwks?.keyPairConfig?.alg]
diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts
index 92cb0f2bf5..005b2f9ff6 100644
--- a/packages/oauth-provider/src/oauth.ts
+++ b/packages/oauth-provider/src/oauth.ts
@@ -118,6 +118,13 @@ export const oauthProvider = >(options: O) => {
clientRegistrationAllowedScopes,
};
+ // Validate pairwiseSecret minimum length
+ if (opts.pairwiseSecret && opts.pairwiseSecret.length < 32) {
+ throw new BetterAuthError(
+ "pairwiseSecret must be at least 32 characters long for adequate HMAC-SHA256 security",
+ );
+ }
+
// TODO: device_code grant also allows for refresh tokens
if (
opts.grantTypes &&
@@ -1131,6 +1138,7 @@ export const oauthProvider = >(options: O) => {
.default(["code"])
.optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
+ subject_type: z.enum(["public", "pairwise"]).optional(),
}),
metadata: {
openapi: {
diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts
index 1229c201fd..8cbcfb6332 100644
--- a/packages/oauth-provider/src/oauthClient/index.ts
+++ b/packages/oauth-provider/src/oauthClient/index.ts
@@ -57,6 +57,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) =>
skip_consent: z.boolean().optional(),
enable_end_session: z.boolean().optional(),
require_pkce: z.boolean().optional(),
+ subject_type: z.enum(["public", "pairwise"]).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
metadata: {
diff --git a/packages/oauth-provider/src/pairwise.test.ts b/packages/oauth-provider/src/pairwise.test.ts
new file mode 100644
index 0000000000..fedae75724
--- /dev/null
+++ b/packages/oauth-provider/src/pairwise.test.ts
@@ -0,0 +1,569 @@
+import { createAuthClient } from "better-auth/client";
+import { generateRandomString } from "better-auth/crypto";
+import {
+ createAuthorizationCodeRequest,
+ createAuthorizationURL,
+} from "better-auth/oauth2";
+import { jwt } from "better-auth/plugins/jwt";
+import { getTestInstance } from "better-auth/test";
+import { APIError } from "better-call";
+import { decodeJwt } from "jose";
+import { beforeAll, describe, expect, it } from "vitest";
+import { oauthProviderClient } from "./client";
+import { oauthProvider } from "./oauth";
+import type { OAuthClient } from "./types/oauth";
+
+describe("pairwise subject identifiers", async () => {
+ const authServerBaseUrl = "http://localhost:3000";
+ const rpBaseUrl = "http://localhost:5000";
+ const rpBaseUrl2 = "http://localhost:6000";
+ const validAudience = "https://myapi.example.com";
+
+ const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt({
+ jwt: {
+ issuer: authServerBaseUrl,
+ },
+ }),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-pairwise-secret-key-32chars!!",
+ validAudiences: [validAudience],
+ allowDynamicClientRegistration: true,
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ const client = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: {
+ customFetchImpl,
+ headers,
+ },
+ });
+
+ let pairwiseClientA: OAuthClient | null;
+ let pairwiseClientB: OAuthClient | null;
+ let publicClient: OAuthClient | null;
+ let sameHostClientA: OAuthClient | null;
+
+ const redirectUriA = `${rpBaseUrl}/api/auth/oauth2/callback/test-a`;
+ const redirectUriB = `${rpBaseUrl2}/api/auth/oauth2/callback/test-b`;
+ const redirectUriSameHost = `${rpBaseUrl}/api/auth/oauth2/callback/test-same`;
+ const redirectUriPublic = `${rpBaseUrl}/api/auth/oauth2/callback/test-public`;
+
+ beforeAll(async () => {
+ pairwiseClientA = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUriA],
+ scope: "openid profile email offline_access",
+ skip_consent: true,
+ subject_type: "pairwise",
+ },
+ });
+ expect(pairwiseClientA?.client_id).toBeDefined();
+
+ pairwiseClientB = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUriB],
+ scope: "openid profile email offline_access",
+ skip_consent: true,
+ subject_type: "pairwise",
+ },
+ });
+ expect(pairwiseClientB?.client_id).toBeDefined();
+
+ publicClient = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUriPublic],
+ scope: "openid profile email offline_access",
+ skip_consent: true,
+ },
+ });
+ expect(publicClient?.client_id).toBeDefined();
+
+ sameHostClientA = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUriSameHost],
+ scope: "openid profile email offline_access",
+ skip_consent: true,
+ subject_type: "pairwise",
+ },
+ });
+ expect(sameHostClientA?.client_id).toBeDefined();
+ });
+
+ async function getTokensForClient(
+ oauthClient: OAuthClient,
+ redirectUri: string,
+ overrides?: {
+ resource?: string;
+ },
+ ) {
+ const codeVerifier = generateRandomString(32);
+ const url = await createAuthorizationURL({
+ id: "test",
+ options: {
+ clientId: oauthClient.client_id,
+ clientSecret: oauthClient.client_secret!,
+ redirectURI: redirectUri,
+ },
+ redirectURI: "",
+ authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
+ state: "test-state",
+ scopes: ["openid", "profile", "email", "offline_access"],
+ codeVerifier,
+ });
+
+ let callbackRedirectUrl = "";
+ await client.$fetch(url.toString(), {
+ headers,
+ onError(context) {
+ callbackRedirectUrl = context.response.headers.get("Location") || "";
+ },
+ });
+ const callbackUrl = new URL(callbackRedirectUrl);
+ const code = callbackUrl.searchParams.get("code")!;
+
+ const { body, headers: reqHeaders } = createAuthorizationCodeRequest({
+ code,
+ codeVerifier,
+ redirectURI: redirectUri,
+ options: {
+ clientId: oauthClient.client_id,
+ clientSecret: oauthClient.client_secret!,
+ redirectURI: redirectUri,
+ },
+ resource: overrides?.resource,
+ });
+
+ const tokens = await client.$fetch<{
+ access_token?: string;
+ id_token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+ token_type?: string;
+ scope?: string;
+ }>("/oauth2/token", {
+ method: "POST",
+ body,
+ headers: reqHeaders,
+ });
+
+ return tokens;
+ }
+
+ it("should produce different sub across pairwise clients (cross-RP unlinkability)", async () => {
+ const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA);
+ const tokensB = await getTokensForClient(pairwiseClientB!, redirectUriB);
+
+ const idTokenA = decodeJwt(tokensA.data!.id_token!);
+ const idTokenB = decodeJwt(tokensB.data!.id_token!);
+
+ expect(idTokenA.sub).toBeDefined();
+ expect(idTokenB.sub).toBeDefined();
+ // Different sectors โ different pairwise sub
+ expect(idTokenA.sub).not.toBe(idTokenB.sub);
+ });
+
+ it("should produce same sub for same pairwise client (determinism)", async () => {
+ const tokens1 = await getTokensForClient(pairwiseClientA!, redirectUriA);
+ const tokens2 = await getTokensForClient(pairwiseClientA!, redirectUriA);
+
+ const idToken1 = decodeJwt(tokens1.data!.id_token!);
+ const idToken2 = decodeJwt(tokens2.data!.id_token!);
+
+ expect(idToken1.sub).toBe(idToken2.sub);
+ });
+
+ it("should return user.id as sub for public client (fallback)", async () => {
+ const publicTokens = await getTokensForClient(
+ publicClient!,
+ redirectUriPublic,
+ );
+ const pairwiseTokens = await getTokensForClient(
+ pairwiseClientA!,
+ redirectUriA,
+ );
+
+ const publicIdToken = decodeJwt(publicTokens.data!.id_token!);
+ const pairwiseIdToken = decodeJwt(pairwiseTokens.data!.id_token!);
+
+ expect(publicIdToken.sub).toBeDefined();
+ // Public sub differs from pairwise sub for same user
+ expect(publicIdToken.sub).not.toBe(pairwiseIdToken.sub);
+ });
+
+ it("should produce same pairwise sub for clients on same host (sector isolation)", async () => {
+ const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA);
+ const tokensSameHost = await getTokensForClient(
+ sameHostClientA!,
+ redirectUriSameHost,
+ );
+
+ const idTokenA = decodeJwt(tokensA.data!.id_token!);
+ const idTokenSameHost = decodeJwt(tokensSameHost.data!.id_token!);
+
+ // Same host (localhost) โ same sector โ same pairwise sub
+ expect(idTokenA.sub).toBe(idTokenSameHost.sub);
+ });
+
+ it("should have consistent sub between id_token and userinfo", async () => {
+ const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA);
+ const idToken = decodeJwt(tokens.data!.id_token!);
+
+ const userinfo = await client.$fetch<{ sub?: string }>("/oauth2/userinfo", {
+ method: "GET",
+ headers: {
+ authorization: `Bearer ${tokens.data!.access_token}`,
+ },
+ });
+
+ expect(userinfo.data?.sub).toBe(idToken.sub);
+ });
+
+ it("should return pairwise sub in opaque access token introspection", async () => {
+ const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA);
+
+ const introspection = await client.oauth2.introspect(
+ {
+ client_id: pairwiseClientA!.client_id,
+ client_secret: pairwiseClientA!.client_secret,
+ token: tokens.data!.access_token!,
+ token_type_hint: "access_token",
+ },
+ {
+ headers: {
+ accept: "application/json",
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ },
+ );
+
+ const idToken = decodeJwt(tokens.data!.id_token!);
+ expect(introspection.data?.active).toBe(true);
+ expect(introspection.data?.sub).toBe(idToken.sub);
+ });
+
+ it("should preserve pairwise sub after token refresh", async () => {
+ const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA);
+ const originalIdToken = decodeJwt(tokens.data!.id_token!);
+
+ const refreshBody = new URLSearchParams({
+ grant_type: "refresh_token",
+ client_id: pairwiseClientA!.client_id,
+ client_secret: pairwiseClientA!.client_secret!,
+ refresh_token: tokens.data!.refresh_token!,
+ });
+
+ const refreshResponse = await client.$fetch<{
+ access_token?: string;
+ id_token?: string;
+ refresh_token?: string;
+ }>("/oauth2/token", {
+ method: "POST",
+ body: refreshBody,
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ });
+
+ expect(refreshResponse.data?.id_token).toBeDefined();
+ const refreshedIdToken = decodeJwt(refreshResponse.data!.id_token!);
+ expect(refreshedIdToken.sub).toBe(originalIdToken.sub);
+ });
+
+ it("should keep user.id in JWT access token sub (not pairwise)", async () => {
+ const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA, {
+ resource: validAudience,
+ });
+
+ const accessToken = decodeJwt(tokens.data!.access_token!);
+ const idToken = decodeJwt(tokens.data!.id_token!);
+
+ // JWT access token uses real user.id for user lookup
+ expect(accessToken.sub).toBeDefined();
+ expect(accessToken.sub).not.toBe(idToken.sub);
+ });
+});
+
+describe("pairwise DCR validation", async () => {
+ const authServerBaseUrl = "http://localhost:3000";
+ const rpBaseUrl = "http://localhost:5000";
+ const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/test`;
+
+ it("should reject pairwise subject_type when pairwiseSecret not configured", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ await expect(
+ auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUri],
+ subject_type: "pairwise",
+ },
+ }),
+ ).rejects.toThrow(APIError);
+ });
+
+ it("should accept pairwise subject_type when pairwiseSecret is configured", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-secret-for-dcr-test-32chars!",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ const response = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUri],
+ subject_type: "pairwise",
+ skip_consent: true,
+ },
+ });
+
+ expect(response?.client_id).toBeDefined();
+ expect(response?.subject_type).toBe("pairwise");
+ });
+
+ it("should default to public when no subject_type specified", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-secret-for-dcr-test-32chars!",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ const response = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [redirectUri],
+ skip_consent: true,
+ },
+ });
+
+ expect(response?.client_id).toBeDefined();
+ expect(response?.subject_type).toBeUndefined();
+ });
+
+ it("should reject pairwise client with redirect_uris on different hosts", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-secret-for-dcr-test-32chars!",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ await expect(
+ auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [
+ "https://app-a.example.com/callback",
+ "https://app-b.example.com/callback",
+ ],
+ subject_type: "pairwise",
+ },
+ }),
+ ).rejects.toThrow(APIError);
+ });
+
+ it("should accept pairwise client with redirect_uris on the same host", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-secret-for-dcr-test-32chars!",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ const response = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ redirect_uris: [
+ "https://app.example.com/callback-a",
+ "https://app.example.com/callback-b",
+ ],
+ subject_type: "pairwise",
+ skip_consent: true,
+ },
+ });
+
+ expect(response?.client_id).toBeDefined();
+ expect(response?.subject_type).toBe("pairwise");
+ });
+
+ it("should round-trip subject_type through DCR", async () => {
+ const { signInWithTestUser, customFetchImpl } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-secret-for-dcr-test-32chars!",
+ allowDynamicClientRegistration: true,
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+ const dcrClient = createAuthClient({
+ plugins: [oauthProviderClient()],
+ baseURL: authServerBaseUrl,
+ fetchOptions: {
+ customFetchImpl,
+ headers,
+ },
+ });
+
+ const response = await dcrClient.$fetch("/oauth2/register", {
+ method: "POST",
+ body: {
+ redirect_uris: [redirectUri],
+ subject_type: "pairwise",
+ token_endpoint_auth_method: "none",
+ },
+ });
+
+ expect(response.data?.subject_type).toBe("pairwise");
+ });
+});
+
+describe("pairwise configuration validation", () => {
+ it("should reject pairwiseSecret shorter than 32 characters", () => {
+ expect(() =>
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "too-short",
+ }),
+ ).toThrow("pairwiseSecret must be at least 32 characters");
+ });
+
+ it("should accept pairwiseSecret of 32+ characters", () => {
+ expect(() =>
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "a-valid-secret-that-is-32-chars!",
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe("pairwise metadata", async () => {
+ const authServerBaseUrl = "http://localhost:3000";
+
+ it("should include pairwise in subject_types_supported when secret configured", async () => {
+ const { auth } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ pairwiseSecret: "test-pairwise-metadata-secret!!!",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const metadata = await auth.api.getOpenIdConfig();
+ expect(metadata.subject_types_supported).toEqual(["public", "pairwise"]);
+ });
+
+ it("should only include public when no pairwise secret", async () => {
+ const { auth } = await getTestInstance({
+ baseURL: authServerBaseUrl,
+ plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ ],
+ });
+
+ const metadata = await auth.api.getOpenIdConfig();
+ expect(metadata.subject_types_supported).toEqual(["public"]);
+ });
+});
diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts
index 94ae1113de..05a830c4a2 100644
--- a/packages/oauth-provider/src/register.ts
+++ b/packages/oauth-provider/src/register.ts
@@ -110,6 +110,45 @@ export async function checkOAuthClient(
});
}
+ // Validate subject_type
+ if (client.subject_type !== undefined) {
+ if (
+ client.subject_type !== "public" &&
+ client.subject_type !== "pairwise"
+ ) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client_metadata",
+ error_description: `subject_type must be "public" or "pairwise"`,
+ });
+ }
+ if (client.subject_type === "pairwise" && !opts.pairwiseSecret) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client_metadata",
+ error_description:
+ "pairwise subject_type requires server pairwiseSecret configuration",
+ });
+ }
+ // Per OIDC Core ยง8.1, when multiple redirect_uris have different hosts,
+ // a sector_identifier_uri is required (not yet supported). Reject registration
+ // until sector_identifier_uri support is added.
+ if (
+ client.subject_type === "pairwise" &&
+ client.redirect_uris &&
+ client.redirect_uris.length > 1
+ ) {
+ const hosts = new Set(
+ client.redirect_uris.map((uri: string) => new URL(uri).host),
+ );
+ if (hosts.size > 1) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client_metadata",
+ error_description:
+ "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host.",
+ });
+ }
+ }
+ }
+
// Check requested application scopes
const requestedScopes = (client?.scope as string | undefined)
?.split(" ")
@@ -263,6 +302,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient {
skip_consent: skipConsent,
enable_end_session: enableEndSession,
require_pkce: requirePKCE,
+ subject_type: subjectType,
reference_id: referenceId,
metadata: inputMetadata,
// All other metadata
@@ -317,6 +357,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient {
skipConsent,
enableEndSession,
requirePKCE,
+ subjectType,
referenceId,
metadata,
};
@@ -364,6 +405,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient {
skipConsent,
enableEndSession,
requirePKCE,
+ subjectType,
referenceId,
metadata, // in JSON format
} = input;
@@ -417,6 +459,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient {
skip_consent: skipConsent ?? undefined,
enable_end_session: enableEndSession ?? undefined,
require_pkce: requirePKCE ?? undefined,
+ subject_type: subjectType ?? undefined,
reference_id: referenceId ?? undefined,
};
}
diff --git a/packages/oauth-provider/src/schema.ts b/packages/oauth-provider/src/schema.ts
index ddf1b063cc..5d116af493 100644
--- a/packages/oauth-provider/src/schema.ts
+++ b/packages/oauth-provider/src/schema.ts
@@ -27,6 +27,10 @@ export const schema = {
type: "boolean",
required: false,
},
+ subjectType: {
+ type: "string",
+ required: false,
+ },
scopes: {
type: "string[]",
required: false,
diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts
index f632f11eee..7d3d493edc 100644
--- a/packages/oauth-provider/src/token.ts
+++ b/packages/oauth-provider/src/token.ts
@@ -22,6 +22,7 @@ import {
getStoredToken,
isPKCERequired,
parseClientMetadata,
+ resolveSubjectIdentifier,
storeToken,
validateClientCredentials,
} from "./utils";
@@ -132,6 +133,7 @@ async function createIdToken(
const iat = Math.floor(Date.now() / 1000);
const exp = iat + (opts.idTokenExpiresIn ?? 36000);
const userClaims = userNormalClaims(user, scopes);
+ const resolvedSub = await resolveSubjectIdentifier(user.id, client, opts);
const authTimeSec =
authTime != null ? Math.floor(authTime.getTime() / 1000) : undefined;
// TODO: this should be validated against the login process
@@ -157,7 +159,7 @@ async function createIdToken(
auth_time: authTimeSec,
acr,
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
- sub: user.id,
+ sub: resolvedSub,
aud: client.clientId,
nonce,
iat,
diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts
index facdfc4a7a..528cd4b669 100644
--- a/packages/oauth-provider/src/types/index.ts
+++ b/packages/oauth-provider/src/types/index.ts
@@ -664,6 +664,14 @@ export interface OAuthOptions<
*/
userinfo?: { window: number; max: number } | false;
};
+ /**
+ * Secret used to compute pairwise subject identifiers (HMAC-SHA256).
+ * When set, clients with `subject_type: "pairwise"` receive unique,
+ * unlinkable `sub` values per sector identifier.
+ *
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg
+ */
+ pairwiseSecret?: string;
}
export interface OAuthAuthorizationQuery {
@@ -907,6 +915,8 @@ export interface SchemaClient<
skipConsent?: boolean;
/** Used to enable client to logout via the `/oauth2/end-session` endpoint */
enableEndSession?: boolean;
+ /** Subject identifier type: "public" (default) or "pairwise" */
+ subjectType?: "public" | "pairwise";
/** Reference to the owner of this client. Eg. Organization, Team, Profile */
referenceId?: string;
/**
diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts
index 760d25d77d..7de56189db 100644
--- a/packages/oauth-provider/src/types/oauth.ts
+++ b/packages/oauth-provider/src/types/oauth.ts
@@ -216,9 +216,9 @@ export interface OIDCMetadata extends AuthServerMetadata {
* pairwise: the subject identifier is unique to the client
* public: the subject identifier is unique to the server
*
- * only `public` is supported.
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
*/
- subject_types_supported: "public"[];
+ subject_types_supported: ("public" | "pairwise")[];
/**
* Supported ID token signing algorithms.
*
@@ -307,6 +307,12 @@ export interface OAuthClient {
* requesting offline_access scope, regardless of this setting.
*/
require_pkce?: boolean;
+ /**
+ * Subject identifier type for this client.
+ *
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
+ */
+ subject_type?: "public" | "pairwise";
//---- All other metadata ----//
reference_id?: string;
[key: string]: unknown;
diff --git a/packages/oauth-provider/src/userinfo.ts b/packages/oauth-provider/src/userinfo.ts
index 109651f408..f204b96261 100644
--- a/packages/oauth-provider/src/userinfo.ts
+++ b/packages/oauth-provider/src/userinfo.ts
@@ -3,6 +3,7 @@ import { APIError } from "better-auth/api";
import type { User } from "better-auth/types";
import { validateAccessToken } from "./introspect";
import type { OAuthOptions, Scope } from "./types";
+import { getClient, resolveSubjectIdentifier } from "./utils";
/**
* Provides shared /userinfo and id_token claims functionality
@@ -80,6 +81,21 @@ export async function userInfoEndpoint(
}
const baseUserClaims = userNormalClaims(user, scopes ?? []);
+
+ // Resolve pairwise sub if server has pairwise enabled and client is configured for it
+ if (opts.pairwiseSecret) {
+ const clientId = (jwt.client_id ?? jwt.azp) as string | undefined;
+ if (clientId) {
+ const client = await getClient(ctx, opts, clientId);
+ if (client) {
+ baseUserClaims.sub = await resolveSubjectIdentifier(
+ user.id,
+ client,
+ opts,
+ );
+ }
+ }
+ }
const additionalInfoUserClaims =
opts.customUserInfoClaims && scopes?.length
? await opts.customUserInfoClaims({ user, scopes, jwt })
diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts
index 4739a598d3..0dc5a61e0a 100644
--- a/packages/oauth-provider/src/utils/index.ts
+++ b/packages/oauth-provider/src/utils/index.ts
@@ -4,6 +4,7 @@ import { base64, base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import {
constantTimeEqual,
+ makeSignature,
symmetricDecrypt,
symmetricEncrypt,
} from "better-auth/crypto";
@@ -418,6 +419,54 @@ export function parsePrompt(prompt: string) {
return new Set(set);
}
+/**
+ * Extracts the sector identifier (hostname) from a client's first redirect URI.
+ *
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg
+ * @internal
+ */
+export function getSectorIdentifier(client: SchemaClient): string {
+ const uri = client.redirectUris?.[0];
+ if (!uri) {
+ throw new BetterAuthError(
+ "Client has no redirect URIs for sector identifier",
+ );
+ }
+ return new URL(uri).host;
+}
+
+/**
+ * Computes a pairwise subject identifier using HMAC-SHA256.
+ *
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg
+ * @internal
+ */
+export async function computePairwiseSub(
+ userId: string,
+ client: SchemaClient,
+ secret: string,
+): Promise {
+ const sectorId = getSectorIdentifier(client);
+ return makeSignature(`${sectorId}.${userId}`, secret);
+}
+
+/**
+ * Returns the appropriate subject identifier for a user+client pair.
+ * Uses pairwise when the client opts in and the server has a secret configured.
+ *
+ * @internal
+ */
+export async function resolveSubjectIdentifier(
+ userId: string,
+ client: SchemaClient,
+ opts: OAuthOptions,
+): Promise {
+ if (client.subjectType === "pairwise" && opts.pairwiseSecret) {
+ return computePairwiseSub(userId, client, opts.pairwiseSecret);
+ }
+ return userId;
+}
+
/**
* Deletes a prompt value
*