Table of Contents

DPoP-bound access token

The client proves possession of a key with every request: a DPoP header carrying a JWT signed with its private key and naming the request's method and URL. The access token it gets is bound to that key (cnf.jkt, the key's thumbprint), so a stolen token is useless without the key. The client is the demo host's dpop-nonce-local-api-client, held to server-issued nonces, so both the token endpoint and the API answer its first proof with use_dpop_nonce and a DPoP-Nonce to sign into the next one. The API is the host's own /local-api. The client's key here is RSA, made fresh for each run.

Follows: RFC 9449.

Recorded from the demo host (src/Fdyay.IdentityServer.Host) by the DPoPFlow test on every CI run. A value in braces, such as {access_token_1}, is one that differs on every run; the same placeholder is the same value wherever it appears. See how these pages are made.

sequenceDiagram
    participant Client
    participant AuthorizationServer as Authorization Server
    participant LocalAPI as Local API
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 400 Bad Request, use_dpop_nonce
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 200 OK
    Client->>LocalAPI: GET /local-api
    LocalAPI-->>Client: 401 Unauthorized, use_dpop_nonce
    Client->>LocalAPI: GET /local-api
    LocalAPI-->>Client: 200 OK

1. The client asks for a token with a proof

The server wants a nonce of its own in the proof, so it refuses this one and supplies the nonce.

Client → Authorization Server

POST /connect/token HTTP/1.1
Authorization: Basic ZHBvcC1ub25jZS1sb2NhbC1hcGktY2xpZW50OnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
DPoP: {dpop_proof_1}

grant_type=client_credentials
&scope=IdentityServerApi

{dpop_proof_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "jwk": {
    "kty": "RSA",
    "n": "{rsa_modulus_1}",
    "e": "AQAB"
  },
  "typ": "dpop+jwt"
}
{
  "iat": {iat},
  "htm": "POST",
  "htu": "http://localhost/connect/token",
  "jti": "{jti_1}"
}

Authorization Server → Client

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=UTF-8
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache
DPoP-Nonce: {dpop_nonce_1}

{
  "error": "use_dpop_nonce",
  "error_description": "Missing 'nonce' value."
}

2. The client asks again, with the nonce in a new proof

The token is a DPoP token, bound to the client's key.

Client → Authorization Server

POST /connect/token HTTP/1.1
Authorization: Basic ZHBvcC1ub25jZS1sb2NhbC1hcGktY2xpZW50OnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
DPoP: {dpop_proof_2}

grant_type=client_credentials
&scope=IdentityServerApi

{dpop_proof_2} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "jwk": {
    "kty": "RSA",
    "n": "{rsa_modulus_1}",
    "e": "AQAB"
  },
  "typ": "dpop+jwt"
}
{
  "iat": {iat},
  "htm": "POST",
  "htu": "http://localhost/connect/token",
  "jti": "{jti_2}",
  "nonce": "{dpop_nonce_1}"
}

Authorization Server → Client

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache

{
  "access_token": "{access_token_1}",
  "token_type": "DPoP",
  "expires_in": 3600,
  "scope": "IdentityServerApi"
}

{access_token_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "at+jwt"
}
{
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 3600},
  "jti": "{jti_3}",
  "client_id": "dpop-nonce-local-api-client",
  "scope": "IdentityServerApi",
  "cnf": {
    "jkt": "{jkt_1}"
  }
}

3. The client calls the API

With the token under the DPoP scheme and a proof for this request, which also carries the token's hash (ath). The API holds the client to a nonce as well, and supplies one.

Client → Local API

GET /local-api HTTP/1.1
Authorization: DPoP {access_token_1}
DPoP: {dpop_proof_3}

{dpop_proof_3} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "jwk": {
    "kty": "RSA",
    "n": "{rsa_modulus_1}",
    "e": "AQAB"
  },
  "typ": "dpop+jwt"
}
{
  "iat": {iat},
  "htm": "GET",
  "htu": "http://localhost/local-api",
  "jti": "{jti_4}",
  "ath": "{ath_1}"
}

Local API → Client

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="Missing 'nonce' value."
DPoP-Nonce: {dpop_nonce_2}

4. The client calls the API again, with the API's nonce

Client → Local API

GET /local-api HTTP/1.1
Authorization: DPoP {access_token_1}
DPoP: {dpop_proof_4}

{dpop_proof_4} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "jwk": {
    "kty": "RSA",
    "n": "{rsa_modulus_1}",
    "e": "AQAB"
  },
  "typ": "dpop+jwt"
}
{
  "iat": {iat},
  "htm": "GET",
  "htu": "http://localhost/local-api",
  "jti": "{jti_5}",
  "ath": "{ath_1}",
  "nonce": "{dpop_nonce_2}"
}

Local API → Client

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
  "client": "dpop-nonce-local-api-client",
  "scopes": [
    "IdentityServerApi"
  ]
}