
# DPoP proofs

DPoP lets a client prove that it holds a private key when using an access token. The client signs a short JWT for each HTTP request. The server checks the signature, the method and URL, and the connection to the access token.

`unjwt/dpop` supplies those proof operations from [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449). It is a separate import; core JWS, JWE and session imports do not include it.

## Create a proof

Keep both halves of the client's key pair. Pass a Web `Request` or an object with `method` and `url`:

```ts
import { createDPoPProof } from "unjwt/dpop";
import { generateJWK } from "unjwt/jwk";

const keyPair = await generateJWK("ES256"); // Keep this pair for the bound token's lifetime.
const target = { method: "POST", url: "https://auth.example.com/token" };

const proof = await createDPoPProof(target, { keyPair });

// Your HTTP code sends this value in the DPoP header.
```

`algorithm` is optional for JWK pairs. The helper uses the private key's `alg`, or the public key's when the private key omits it. Without either, it infers a single choice from the key's curve when possible, such as P-256 → ES256. If several choices remain, supply `algorithm`. RSA keys without `alg` are one example. `CryptoKeyPair` inputs require an explicit `algorithm`.

DPoP requires asymmetric keys. Symmetric JWKs (`kty: "oct"`), secret `CryptoKey`s, HMAC algorithms and `none` are rejected. A supplied algorithm must agree with the key pair's algorithm metadata.

Each call generates a fresh UUID v4 for `jti` (the proof ID) and sets `iat` (creation time) to the current Unix second.

Both can be overridden, for example in tests:

```ts
const proof = await createDPoPProof(target, {
  keyPair,
  algorithm: "ES256",
  jti: "550e8400-e29b-41d4-a716-446655440000",
  iat: 1_700_000_000,
});
```

When providing your own `jti`, use a UUID v4 or at least 96 random bits, and never reuse it for a new proof. Overrides are validated; an empty ID or invalid timestamp is rejected.

`exp` and `nbf` are optional Unix timestamps in seconds. Both are omitted by default:

```ts
const now = Math.floor(Date.now() / 1000);
const proof = await createDPoPProof(target, {
  keyPair,
  exp: now + 30, // Reject at or after this time.
  nbf: now, // Reject before this time.
});
```

When present, verification checks both using `clockTolerance`. The proof must also pass the `iat` / `maxTokenAge` check; setting a later `exp` does not extend that window. Invalid timestamp inputs are rejected.

`keyPair` accepts an asymmetric JWK pair or a `CryptoKeyPair`. The keys must correspond. A private `CryptoKey` may be non-extractable; the public one must be exportable so its public fields can go into the proof. Creation does not derive a public key or test the pair by verifying its own signature.

The helper sets `typ: "dpop+jwt"`, includes the public key, and adds `htm` (method) and `htu` (URL). It preserves method case and removes the URL's query and fragment. No HTTP call occurs.

For a protected API call, also pass the exact access token:

```ts
const proof = await createDPoPProof(
  { method: "GET", url: "https://api.example.com/orders?page=2" },
  {
    keyPair,
    accessToken: { token: accessToken },
    nonce, // Include when this server requires a nonce.
  },
);
```

This adds `ath`, a SHA-256 digest of the token. A nonce is a value supplied by the server that the client copies into its next proof. Your HTTP layer reads nonce headers and handles retries.

## Verify a proof

Verification takes the proof string first, followed by the HTTP target and its options:

```ts
import { verifyDPoPProof } from "unjwt/dpop";

const { payload, thumbprint } = await verifyDPoPProof(
  proof,
  { method: "POST", url: "https://auth.example.com/token" },
  { algorithms: ["ES256"], maxTokenAge: 60 },
);
```

`target` accepts a Web `Request` or `{ method, url }`. Use the externally visible URL when behind a trusted proxy.

The result contains the checked proof claims and the signing key's SHA-256 thumbprint: a stable identifier computed from its public fields. An authorization server can use that identifier when binding a token to the client key. The proof's public key must carry its members in canonical unpadded base64url, so one key always yields one thumbprint.

| Option | Meaning |
| --- | --- |
| `algorithms` | Required list of accepted asymmetric signing algorithms. HMAC and `none` are rejected. |
| `maxTokenAge` | Required positive number of seconds a proof may be old. Choose a brief window. |
| `clockTolerance` | Non-negative seconds allowed for clock differences; defaults to `0`. Applies to future and old proofs. |
| `currentDate` | Clock used for verification; defaults to the current time. Useful for tests. |
| `nonce` | Expected server nonce. When supplied, a missing or different nonce fails with `ERR_DPOP_NONCE_INVALID`. |
| `accessToken` | `{ token, thumbprint }`: the exact token and its trusted key binding, checked together. |
| `consumeProof` | Optional callback for an atomic replay check; described below. |

URL comparison ignores query and fragment, normalizes host and scheme casing, default ports, dot segments and unreserved percent escapes in the path. An escaped slash stays distinct from a path separator. A URL with credentials or a percent escape in its host or port is rejected. The method comparison is case-sensitive.

Freshness always uses `iat` and the supplied age limit. A nonce does not replace this time check. Without an expected `nonce`, a nonce in the proof is only checked for valid syntax.

## Check the access-token binding

Both operations accept an `accessToken` object: creation needs `{ token }`; verification needs `{ token, thumbprint }`.

For a protected resource, **first validate the access token** using your application's token validator. Then supply the token and its authenticated `cnf.jkt` value:

```ts
const verified = await verifyDPoPProof(proof, request, {
  algorithms: ["ES256"],
  maxTokenAge: 60,
  accessToken: {
    token: accessToken,
    thumbprint: trustedTokenClaims.cnf.jkt,
  },
  nonce: expectedNonce,
});
```

This checks both connections: the proof names this exact token, and the proof's signing key matches the key bound to that token. A decoded but unverified `cnf.jkt` is not a trusted binding. Omitting `accessToken` performs proof verification without these token-binding checks, as needed at a token endpoint.

## Prevent replay

Signature and time checks alone do not detect reuse inside the accepted time window. Supply `consumeProof` to check and record a proof in your own store:

```ts
const maxTokenAge = 60;
const clockTolerance = 5;

await verifyDPoPProof(proof, request, {
  algorithms: ["ES256"],
  maxTokenAge,
  clockTolerance,
  accessToken: { token: accessToken, thumbprint: trustedTokenClaims.cnf.jkt },
  consumeProof: ({ payload, thumbprint }) =>
    replayStore.insertIfAbsent({
      key: JSON.stringify([thumbprint, payload.jti]),
      expiresAt: payload.iat + maxTokenAge + clockTolerance,
    }),
});
```

`replayStore` is your application's store. Its check and insert must be atomic across your servers. Return `true` only for a newly recorded proof; `false` rejects it. Keep the record through the final instant when the proof can be accepted, and bound stored key sizes or hash them. The callback runs only after all other checks pass and receives a copy of the result, so changing it does not change what `verifyDPoPProof` returns. A thrown error or any return value other than `true` also rejects the proof.

There is no built-in replay store. Your application owns HTTPS, checking that exactly one DPoP header was received, the `Authorization: DPoP` scheme, access-token validity and permissions, nonce issuance and HTTP errors. Verification does not grant access by itself.

## Errors

Import `JWTError` and `isJWTError` from `unjwt/dpop`. These are the same error types as the core modules. `verifyDPoPProof` throws only these codes:

| Code | Meaning | RFC 9449 error |
| --- | --- | --- |
| `ERR_INVALID_OPTION` | Invalid caller configuration: target, `algorithms`, time limits, expected `nonce`, `accessToken` or `consumeProof`. | None: the server configuration needs fixing. |
| `ERR_DPOP_INVALID` | The proof's form, protected header, embedded key, algorithm (including one outside `algorithms`), signature, `typ`, `crit` or payload is rejected. | `invalid_dpop_proof` |
| `ERR_DPOP_CLAIM_INVALID` | A claim check fails: a missing or malformed claim, `exp` / `nbf`, `htm` / `htu`, the `iat` age window, `ath` or the key binding, a malformed `nonce`. | `invalid_dpop_proof` |
| `ERR_DPOP_NONCE_INVALID` | An expected `nonce` is missing from the proof or different. Checked after every other proof check passes. | `use_dpop_nonce` |
| `ERR_DPOP_REPLAY` | The replay callback rejected the proof or failed. A thrown error is preserved as `cause`. | `invalid_dpop_proof` for a rejected proof; a thrown store failure is a server error. |

When a core JWS, JWK or JWT check rejects the proof, its `JWTError` is kept as `cause`, for example `ERR_JWS_SIGNATURE_INVALID` under `ERR_DPOP_INVALID` or `ERR_JWT_EXPIRED` under `ERR_DPOP_CLAIM_INVALID`. See [Errors](/errors) for the full code list.

```ts
import { isJWTError, verifyDPoPProof } from "unjwt/dpop";

try {
  await verifyDPoPProof(proof, request, { algorithms: ["ES256"], maxTokenAge: 60, nonce });
} catch (error) {
  if (isJWTError(error, "ERR_DPOP_NONCE_INVALID")) {
    // Respond with error "use_dpop_nonce" and a fresh DPoP-Nonce header.
  } else if (isJWTError(error, "ERR_INVALID_OPTION")) {
    throw error;
  } else {
    // Respond with error "invalid_dpop_proof".
  }
}
```
