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_idaud = "https://as.oid4pay.com/oauth/token"(token endpoint URL exactly)expset to ≤ 300s fromiatjtiunique; AS rejects replay viaoid4ac_client_assertion_jti_seentable- 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:
dpop_jwk: signs DPoP proofs.private_key_jwt_jwk: signsclient_assertionJWTs.
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
| Code | Cause |
|---|---|
invalid_client | Assertion signature failed, aud wrong, or jti replayed. |
invalid_request | client_assertion_type not exactly urn:ietf:params:oauth:client-assertion-type:jwt-bearer. |