feat(oauth-provider): Support pairwise subject identifiers (OIDC Core Section 8) #2960

Closed
opened 2026-03-13 10:30:38 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @gustavovalverde on GitHub (Mar 2, 2026).

Is your feature request related to a problem?

The @better-auth/oauth-provider plugin documents awareness of pairwise subject identifiers in the OIDCMetadata type (types/oauth.ts:206-213):

/**
 * Supported subject types.
 *
 * pairwise: the subject identifier is unique to the client
 * public: the subject identifier is unique to the server
 *
 * only `public` is supported.
 */
subject_types_supported: "public"[];

However, the implementation always uses sub: user.id directly — the same value for every relying party. Any two RPs that receive tokens for the same user see an identical sub and can trivially correlate users across services.

This is a compliance gap with OIDC Core Section 8, which defines two subject identifier types:

  • public (current behavior): same sub for all clients
  • pairwise: sub is unique per client, preventing cross-RP linkability

Pairwise identifiers are required by privacy-preserving OIDC deployments (e.g., age verification under CNIL's "double anonymity" framework, eIDAS 2.0 wallet ecosystems) and are supported by all major OIDC providers (Keycloak, Auth0, Azure AD, Google).

Describe the solution you'd like

1. Per-client subject_type field

Add subject_type?: "public" | "pairwise" to the OAuthClient interface and the oauthClient database schema. RPs set this during DCR or manual registration.

2. Pairwise sub computation

When a client has subject_type: "pairwise", compute a pseudonymous identifier instead of using user.id:

sub = Base64URL(SHA-256(sectorIdentifier + "." + userId + "." + salt))

Per OIDC Core Section 8.1, sectorIdentifier is derived from the client's sector_identifier_uri (if registered) or the host component of the registered redirect URI. The salt is a server-side secret.

3. Fix sub placement in token construction

Currently, sub: user.id is placed after the customClaims spread in both createJwtAccessToken (token.ts:100) and createIdToken (token.ts:160), meaning custom claims hooks cannot override sub either:

// token.ts — createIdToken (line 154-166)
const payload: JWTPayload = {
  ...customClaims,
  ...userClaims,     // has sub: user.id
  auth_time, acr, iss,
  sub: user.id,      // hardcoded last → overwrites any custom sub
  aud, nonce, iat, exp,
};

The computed sub should be resolved before payload construction:

const resolvedSub = client.subject_type === "pairwise"
  ? computePairwiseSub(user.id, client, serverSecret)
  : user.id;

const payload: JWTPayload = {
  ...customClaims,
  ...userClaims,
  sub: resolvedSub,
  // ...
};

This also applies to userNormalClaims (userinfo.ts:26) and introspection endpoints (introspect.ts).

4. Update discovery metadata

metadata.ts:90 currently hardcodes subject_types_supported: ["public"]. When configured with a pairwise secret, advertise both:

{ "subject_types_supported": ["public", "pairwise"] }

5. Plugin configuration

Add an optional pairwiseSecret to OAuthOptions:

oauthProvider({
  pairwiseSecret: process.env.PAIRWISE_SECRET, // enables pairwise support
})

If not provided, pairwise client registrations should be rejected and discovery advertises only ["public"].

Describe alternatives you've considered

  • Override sub via customIdTokenClaims: Not possible — sub: user.id comes after ...customClaims in the payload spread and overwrites it.
  • pnpm patch the spread order: Works short-term but fragile across upgrades.
  • Deploy a separate OIDC proxy (Keycloak, Hydra) for pairwise support: Adds operational complexity for a feature that belongs in the auth layer.

Additional context

Relevant specs:

The codebase already acknowledges this gap (types/oauth.ts:206-213 and oidc-provider/types.ts:499-505). The OAuthClient index signature ([key: string]: unknown) allows storing subject_type on clients today, but the runtime never reads it.

Affected files:

File Change
types/oauth.ts Add subject_type to OAuthClient, widen subject_types_supported type
types/index.ts Add subjectType to SchemaClient and DB schema
token.ts Compute pairwise sub in createJwtAccessToken and createIdToken
userinfo.ts Use computed sub in userNormalClaims
introspect.ts Use computed sub in introspection responses
metadata.ts Conditionally advertise ["public", "pairwise"]
register.ts Accept and validate subject_type in DCR

Happy to contribute a PR if this direction aligns with the project's roadmap.

Originally created by @gustavovalverde on GitHub (Mar 2, 2026). ## Is your feature request related to a problem? The `@better-auth/oauth-provider` plugin documents awareness of pairwise subject identifiers in the `OIDCMetadata` type (`types/oauth.ts:206-213`): ```typescript /** * Supported subject types. * * pairwise: the subject identifier is unique to the client * public: the subject identifier is unique to the server * * only `public` is supported. */ subject_types_supported: "public"[]; ``` However, the implementation always uses `sub: user.id` directly — the same value for every relying party. Any two RPs that receive tokens for the same user see an identical `sub` and can trivially correlate users across services. This is a compliance gap with [OIDC Core Section 8](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes), which defines two subject identifier types: - **public** (current behavior): same `sub` for all clients - **pairwise**: `sub` is unique per client, preventing cross-RP linkability Pairwise identifiers are required by privacy-preserving OIDC deployments (e.g., age verification under CNIL's "double anonymity" framework, eIDAS 2.0 wallet ecosystems) and are supported by all major OIDC providers (Keycloak, Auth0, Azure AD, Google). ## Describe the solution you'd like ### 1. Per-client `subject_type` field Add `subject_type?: "public" | "pairwise"` to the `OAuthClient` interface and the `oauthClient` database schema. RPs set this during DCR or manual registration. ### 2. Pairwise `sub` computation When a client has `subject_type: "pairwise"`, compute a pseudonymous identifier instead of using `user.id`: ``` sub = Base64URL(SHA-256(sectorIdentifier + "." + userId + "." + salt)) ``` Per [OIDC Core Section 8.1](https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg), `sectorIdentifier` is derived from the client's `sector_identifier_uri` (if registered) or the host component of the registered redirect URI. The `salt` is a server-side secret. ### 3. Fix `sub` placement in token construction Currently, `sub: user.id` is placed _after_ the `customClaims` spread in both `createJwtAccessToken` (`token.ts:100`) and `createIdToken` (`token.ts:160`), meaning custom claims hooks cannot override `sub` either: ```typescript // token.ts — createIdToken (line 154-166) const payload: JWTPayload = { ...customClaims, ...userClaims, // has sub: user.id auth_time, acr, iss, sub: user.id, // hardcoded last → overwrites any custom sub aud, nonce, iat, exp, }; ``` The computed `sub` should be resolved before payload construction: ```typescript const resolvedSub = client.subject_type === "pairwise" ? computePairwiseSub(user.id, client, serverSecret) : user.id; const payload: JWTPayload = { ...customClaims, ...userClaims, sub: resolvedSub, // ... }; ``` This also applies to `userNormalClaims` (`userinfo.ts:26`) and introspection endpoints (`introspect.ts`). ### 4. Update discovery metadata `metadata.ts:90` currently hardcodes `subject_types_supported: ["public"]`. When configured with a pairwise secret, advertise both: ```json { "subject_types_supported": ["public", "pairwise"] } ``` ### 5. Plugin configuration Add an optional `pairwiseSecret` to `OAuthOptions`: ```typescript oauthProvider({ pairwiseSecret: process.env.PAIRWISE_SECRET, // enables pairwise support }) ``` If not provided, pairwise client registrations should be rejected and discovery advertises only `["public"]`. ## Describe alternatives you've considered - **Override `sub` via `customIdTokenClaims`**: Not possible — `sub: user.id` comes after `...customClaims` in the payload spread and overwrites it. - **`pnpm patch` the spread order**: Works short-term but fragile across upgrades. - **Deploy a separate OIDC proxy** (Keycloak, Hydra) for pairwise support: Adds operational complexity for a feature that belongs in the auth layer. ## Additional context Relevant specs: - [OIDC Core Section 8 — Subject Identifier Types](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) - [OIDC Core Section 8.1 — Pairwise Identifier Algorithm](https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg) - [RFC 7591 Section 2 — `subject_type` in client metadata](https://datatracker.ietf.org/doc/html/rfc7591#section-2) The codebase already acknowledges this gap (`types/oauth.ts:206-213` and `oidc-provider/types.ts:499-505`). The `OAuthClient` index signature (`[key: string]: unknown`) allows storing `subject_type` on clients today, but the runtime never reads it. **Affected files:** | File | Change | |------|--------| | `types/oauth.ts` | Add `subject_type` to `OAuthClient`, widen `subject_types_supported` type | | `types/index.ts` | Add `subjectType` to `SchemaClient` and DB schema | | `token.ts` | Compute pairwise `sub` in `createJwtAccessToken` and `createIdToken` | | `userinfo.ts` | Use computed `sub` in `userNormalClaims` | | `introspect.ts` | Use computed `sub` in introspection responses | | `metadata.ts` | Conditionally advertise `["public", "pairwise"]` | | `register.ts` | Accept and validate `subject_type` in DCR | Happy to contribute a PR if this direction aligns with the project's roadmap.
GiteaMirror added the enhancement label 2026-03-13 10:30:38 -05:00
Author
Owner

@gustavovalverde commented on GitHub (Mar 2, 2026):

Closing to recreate as a bug.

@gustavovalverde commented on GitHub (Mar 2, 2026): Closing to recreate as a bug.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2960