Demonstrating Proof of Possession (DPoP)

Demonstrating Proof of Possession (DPoP) in OAuth 2.0 ensures that authorization codes and access tokens can only be used by the original client to which they were issued for.

It prevents intercepted tokens/codes from being reused by attackers (replay attacks), which is especially useful for public clients like SPAs or mobile apps.

This security mechanism is defined in RFC 9449.

How it works

The DPoP mechanism enables a client to prove possession of a private key when obtaining and using both authorization codes and access tokens in a FAPI 2.0 flow.

At the start of each authorization request, the client generates an ephemeral signing key pair. Using this key pair, the client creates a signed JWT (the DPoP Proof JWT) and sends it in the DPoP header to the authorization server. Presenting this proof demonstrates that the client holds the private key it claims to and that it is initiating the request directly.

The authorization server binds the client’s presented public key to the resulting credential.

  • For an authorization code grant, the server binds the key to the issued authorization code, ensuring that only the client holding the private key can redeem it at the token endpoint.

  • For an access token, the server embeds the client’s public key into the token, restricting its use to the legitimate client with the private key.

When the client later redeems the authorization code or invokes protected resource endpoints with an access token, it must again present a new DPoP proof JWT alongside its credential. The server verifies the proof and ensures that the bound credential is only usable by the same client instance that created the original request.

Generating a DPoP Proof JWT

First, clients need to generate an ephemeral signing key pair. Currently, Corppass only supports EC keys.

Next, clients need to construct the signed JWT as per the following specifications:

DPoP Request Header

The DPoP proof JWT should be base64-encoded and sent with the DPoP request header.

DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6IkVDIiwieCI6IlBFZHFyLTFSemZ2QkZsazE0U085UkZSSm1uV2FGNlc3YlZPZ2Y3Wk9KUVkiLCJ5IjoieGVBeklzcjlTbmk1RWVnVE5xZHFZblg1V08yTm5wTHBOTXFHTEoxSjRDWSIsImNydiI6IlAtMjU2In19.eyJpYXQiOjE3NDQxODQ5ODgsImp0aSI6IjVlNmU1NTA5LTcwZTMtNGJiNy1hZDMwLTRlMTc2ZmZkM2NhOCIsImh0bSI6IlBPU1QiLCJodHUiOiJodHRwczovL2lkLmNvcnBwYXNzLmdvdi5zZy9tZ2Evc3BzL29hdXRoL29hdXRoMjAvdG9rZW4ifQ.fggiYkc6lBwIKgpZDJjSA2c3_vkrxWHxJ_7WN9RMpyqjfgOCB1Cwlvdcw7AsGNF1nDsi59s8DHUHnM6a9warbA

DPoP JWT Header Structure

Example of a DPoP proof JWT header:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  }
}

Fields in a DPoP proof JWT header:

Field
Description

typ

Type of token. Always set to "dpop+jwt".

alg

Signing algorithm. The supported values are ES256, ES256K, ES384, ES512.

jwk

The public key corresponding to the private key used to sign the JWT. Must match the key used in the DPoP-bound token.

DPoP JWT Payload Structure

Example of a DPoP proof JWT payload:

{
  "htm": "POST",
  "htu": "https://id.corppass.gov.sg/mga/sps/oauth/oauth20/token",
  "iat": 1744184988,
  "jti": "5e6e5509-70e3-4bb7-ad30-4e176ffd3ca8",
  "ath": "hKzf0cQEXAMPLEUXxKXJvHr_F34Pp7pg6LFaP_LZ7Jw"
}

Fields in a DPoP proof JWT payload:

Claim
Required
Description

htm

Yes

The HTTP method of the request (e.g., POST)

htu

Yes

The full HTTP URI of the request (e.g., https://id.corppass.gov.sg/mga/sps/oauth/oauth20/token).

iat

Yes

Issued-at time (epoch timestamp). Used to detect replayed proofs.

jti

Yes

A unique identifier for the proof. Prevents replay attacks. Must be unique for each JWT.

ath

No

Required only when accessing protected resource endpoints (e.g. /userinfo).

This claim contains the base64url-encoded SHA-256 hash of the DPoP-bound access token and is used to bind the proof to that specific token. Read section below on how to compute this value.

How to compute the Access Token Hash (ath) claim

The ath (Access Token Hash) claim is a mandatory field in the DPoP proof JWT for requests to protected resource endpoints (eg. /userinfo).

It cryptographically binds the proof to the access token being used in a request, preventing replay attacks and misuse of tokens.

Steps:

  1. Start with the raw access token string you received from the Token endpoint.

  2. Compute a SHA-256 hash of the token.

  3. Base64URL-encode the hash (no padding).

Example (Pseudo-code)

const crypto = require("crypto");

function computeATH(accessToken) {
  const accessToken = "eyJhbGciOi...";
  const ath = Buffer.from(crypto.createHash("sha256")
           .update(accessToken).digest()
           ).toString("base64url");
           
  return ath;
}

Example Output

"ath": "3rEbqHhURQcbfV3zM9sl1rsBzAcjT9TKqX3akHLZ9Nc"

Example payload with ath

{
  "htu": "https://id.corppass.gov.sg/authorization-info",
  "htm": "POST",
  "iat": 1712681904,
  "jti": "6ef56d85-e469-4bc3-a508-05d8f94f780e",
  "ath": "3rEbqHhURQcbfV3zM9sl1rsBzAcjT9TKqX3akHLZ9Nc"
}

Last updated