Skip to main content

private_key_jwt client assertion (RFC 7523 §3)

The shape

When the agent (or any confidential client) presents client_assertion at /oauth/token:

  • Header: alg=EdDSA (preferred), typ=JWT
  • iss = sub = client_id
  • aud = "https://as.oid4pay.com/oauth/token" (token endpoint URL exactly)
  • exp set to ≤ 300s from iat
  • jti unique; AS rejects replay via oid4ac_client_assertion_jti_seen table
  • Signed by a key DIFFERENT from the DPoP key (separation of duties; declared in DCR as private_key_jwt_key)

POST /oauth/token wire shape

POST /oauth/token HTTP/1.1
Host: as.oid4pay.com
Content-Type: application/x-www-form-urlencoded
DPoP: <DPoP proof>

grant_type=authorization_code
&code=<...>
&code_verifier=<PKCE>
&client_id=<agent_client_id>
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed JWT>

Assertion body

{
  "iss": "client_abc",
  "sub": "client_abc",
  "aud": "https://as.oid4pay.com/oauth/token",
  "iat": 1747260300,
  "exp": 1747260600,
  "jti": "01HJ9XK0YN0K6V6S8Y8E5P5W6Y"
}

Two-key model

The agent's DCR (RFC 7591) registration declares TWO separate public keys in its JWKS:

The Wallet Portal /agents/setup ritual collects both. Using a single key for both surfaces would couple the blast radius if either is compromised; the separation is mandatory.

Worked example

// Node: produce a private_key_jwt for the token endpoint.
import { SignJWT } from "jose";

const assertion = await new SignJWT({})
  .setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
  .setIssuer(clientId)
  .setSubject(clientId)
  .setAudience("https://as.oid4pay.com/oauth/token")
  .setIssuedAt()
  .setExpirationTime("5m")
  .setJti(crypto.randomUUID())
  .sign(privateKeyJwtKey);

Errors

CodeCause
invalid_clientAssertion signature failed, aud wrong, or jti replayed.
invalid_requestclient_assertion_type not exactly urn:ietf:params:oauth:client-assertion-type:jwt-bearer.