[PR #8836] feat(oauth): add private_key_jwt client authentication (RFC 7523) #16489

Closed
opened 2026-04-13 10:32:27 -05:00 by GiteaMirror · 0 comments
Owner

Original Pull Request: https://github.com/better-auth/better-auth/pull/8836

State: closed
Merged: Yes


Summary

End-to-end private_key_jwt client authentication per RFC 7523, covering both sides of the OAuth exchange: server-side assertion verification in @better-auth/oauth-provider, and client-side assertion signing in the core OAuth2 primitives, SSO plugin, and generic OAuth plugin.

Closes #5935
Closes #6053

What changed

Server-side verification (@better-auth/oauth-provider): the token, introspect, and revoke endpoints accept client_assertion + client_assertion_type parameters. Clients registered with token_endpoint_auth_method: "private_key_jwt" provide their public keys via jwks or jwks_uri at registration; the server verifies assertion signatures, enforces jti single-use via the verification table, caps assertion lifetime, and rejects any attempt to fall back to secret-based auth.

Client-side signing (@better-auth/core, @better-auth/sso, generic-oauth): a signClientAssertion() utility constructs RFC 7523 JWTs. The SSO plugin resolves private keys at runtime via a resolvePrivateKey callback (supporting HSM/KMS without storing keys in the database) or inline via defaultSSO. Discovery correctly selects private_key_jwt when the IdP requires it.

Security

  • SSRF protection: HTTPS-only jwks_uri, private/reserved IP rejection (IPv4, IPv6, IPv4-mapped IPv6), cloud metadata blocking, redirect disabled, trusted-origin enforcement
  • JWKS caching: 5-minute TTL with stale-while-revalidate; automatic refetch on key rotation (verify-then-refetch-then-retry)
  • JTI replay prevention: tombstones stored until assertion exp; in-flight deduplication via process-local Set; double-check on create failure for multi-instance resilience
  • Auth method enforcement: private_key_jwt clients cannot authenticate with client_secret; switching auth methods clears opposing credentials
  • Assertion lifetime: exp required, capped by assertionMaxLifetime (default 5 min), advisory iat check when present
  • Algorithm restriction: only asymmetric algorithms accepted (10 algorithms shared between client signer and server verifier via single ASSERTION_SIGNING_ALGORITHMS constant); HS256 and none rejected

Architecture decisions

  • Single algorithm source of truth: ASSERTION_SIGNING_ALGORITHMS exported from @better-auth/core, consumed by server verifier and metadata; AssertionSigningAlgorithm type derived from it
  • JWK auto-extraction: signClientAssertion falls back to privateKeyJwk.kid and privateKeyJwk.alg per RFC 7517, reducing configuration surface
  • Shared ClientAssertionConfig type: generic-oauth imports from core instead of duplicating the type
  • SSO key material separation: OIDCConfig (DB-persisted) holds metadata (privateKeyId, privateKeyAlgorithm); key material lives in defaultSSO.privateKey or resolvePrivateKey callback, never in the database
  • Deduplicated patterns: resolveAssertionParams() replaces 3 identical assertion-building blocks; destructureCredentials() replaces 5 identical credential-parsing blocks; CLIENT_ASSERTION_TYPE constant replaces 4 hardcoded URIs; buildClientAssertion() in generic-oauth replaces 2 inline spreads
**Original Pull Request:** https://github.com/better-auth/better-auth/pull/8836 **State:** closed **Merged:** Yes --- ## Summary End-to-end `private_key_jwt` client authentication per [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), covering both sides of the OAuth exchange: server-side assertion verification in `@better-auth/oauth-provider`, and client-side assertion signing in the core OAuth2 primitives, SSO plugin, and generic OAuth plugin. Closes #5935 Closes #6053 ## What changed **Server-side verification** (`@better-auth/oauth-provider`): the token, introspect, and revoke endpoints accept `client_assertion` + `client_assertion_type` parameters. Clients registered with `token_endpoint_auth_method: "private_key_jwt"` provide their public keys via `jwks` or `jwks_uri` at registration; the server verifies assertion signatures, enforces `jti` single-use via the verification table, caps assertion lifetime, and rejects any attempt to fall back to secret-based auth. **Client-side signing** (`@better-auth/core`, `@better-auth/sso`, `generic-oauth`): a `signClientAssertion()` utility constructs RFC 7523 JWTs. The SSO plugin resolves private keys at runtime via a `resolvePrivateKey` callback (supporting HSM/KMS without storing keys in the database) or inline via `defaultSSO`. Discovery correctly selects `private_key_jwt` when the IdP requires it. ## Security - **SSRF protection**: HTTPS-only `jwks_uri`, private/reserved IP rejection (IPv4, IPv6, IPv4-mapped IPv6), cloud metadata blocking, redirect disabled, trusted-origin enforcement - **JWKS caching**: 5-minute TTL with stale-while-revalidate; automatic refetch on key rotation (verify-then-refetch-then-retry) - **JTI replay prevention**: tombstones stored until assertion `exp`; in-flight deduplication via process-local Set; double-check on create failure for multi-instance resilience - **Auth method enforcement**: `private_key_jwt` clients cannot authenticate with `client_secret`; switching auth methods clears opposing credentials - **Assertion lifetime**: `exp` required, capped by `assertionMaxLifetime` (default 5 min), advisory `iat` check when present - **Algorithm restriction**: only asymmetric algorithms accepted (10 algorithms shared between client signer and server verifier via single `ASSERTION_SIGNING_ALGORITHMS` constant); HS256 and `none` rejected ## Architecture decisions - **Single algorithm source of truth**: `ASSERTION_SIGNING_ALGORITHMS` exported from `@better-auth/core`, consumed by server verifier and metadata; `AssertionSigningAlgorithm` type derived from it - **JWK auto-extraction**: `signClientAssertion` falls back to `privateKeyJwk.kid` and `privateKeyJwk.alg` per RFC 7517, reducing configuration surface - **Shared `ClientAssertionConfig` type**: generic-oauth imports from core instead of duplicating the type - **SSO key material separation**: `OIDCConfig` (DB-persisted) holds metadata (`privateKeyId`, `privateKeyAlgorithm`); key material lives in `defaultSSO.privateKey` or `resolvePrivateKey` callback, never in the database - **Deduplicated patterns**: `resolveAssertionParams()` replaces 3 identical assertion-building blocks; `destructureCredentials()` replaces 5 identical credential-parsing blocks; `CLIENT_ASSERTION_TYPE` constant replaces 4 hardcoded URIs; `buildClientAssertion()` in generic-oauth replaces 2 inline spreads
GiteaMirror added the pull-request label 2026-04-13 10:32:27 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#16489