Bind tokens to a key with DPoP
A DPoP-bound token works only with a proof signed by the client's private key. Someone who copies the token cannot use it without that key. Use this page to bind your client's tokens and to require the binding.
Before you start
POLICYARC_URLandPOLICYARC_ADMIN_KEY, as on Register a client.curl,jqand Node.js 18 or later.
Make proofs
A proof is a short JWT, signed with the client's key, that names the HTTP method and URL of one request. This script keeps an ES256 key in dpop-key.json and prints a new proof each time it runs. Pass an access token as the third argument when you call a resource, and the proof carries its hash in ath.
cat > dpop.mjs <<'EOF'
import {createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign} from 'node:crypto';
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
const [method, url, accessToken] = process.argv.slice(2);
if (!existsSync('dpop-key.json')) {
const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
writeFileSync('dpop-key.json', JSON.stringify(privateKey.export({format: 'jwk'})), {mode: 0o600});
}
const jwk = JSON.parse(readFileSync('dpop-key.json', 'utf8'));
const b64 = (data) => Buffer.from(data).toString('base64url');
const header = {typ: 'dpop+jwt', alg: 'ES256', jwk: {kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y}};
const claims = {jti: randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000)};
if (accessToken) claims.ath = b64(createHash('sha256').update(accessToken).digest());
const input = `${b64(JSON.stringify(header))}.${b64(JSON.stringify(claims))}`;
const key = createPrivateKey({key: jwk, format: 'jwk'});
console.log(`${input}.${b64(sign('sha256', Buffer.from(input), {key, dsaEncoding: 'ieee-p1363'}))}`);
EOF
node dpop.mjs POST "$POLICYARC_URL/token" | grep -qE '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$' ||
{ echo 'Expected a signed proof' >&2; false; }
Register a client that must use DPoP
Set dpop_bound_access_tokens to true at registration. From then on every token request from this client needs a valid proof. This client is also entitled to call /introspect, so it can check its own token later.
resp=$(curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
-d '{"client_name": "bound-agent", "redirect_uris": ["https://bound-agent.example/cb"], "dpop_bound_access_tokens": true}')
export DPOP_CLIENT_ID=$(jq -er .client_id <<<"$resp") DPOP_CLIENT_SECRET=$(jq -er .client_secret <<<"$resp")
curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$DPOP_CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
Get a bound token
Send the proof in the DPoP header. Its htu is the token endpoint URL and its htm is POST. The block waits a few seconds for the new entitlement first.
sleep 5
proof=$(node dpop.mjs POST "$POLICYARC_URL/token")
resp=$(curl -fsS -u "$DPOP_CLIENT_ID:$DPOP_CLIENT_SECRET" "$POLICYARC_URL/token" -H "DPoP: $proof" \
-d grant_type=client_credentials -d scope=introspection)
echo "$resp"
jq -e '.token_type == "DPoP"' <<<"$resp" >/dev/null || { echo 'Expected token_type DPoP' >&2; false; }
export DPOP_TOKEN=$(jq -er .access_token <<<"$resp")
export USED_PROOF="$proof"
The response has "token_type": "DPoP". A token issued without a valid proof says Bearer.
See a bad proof refused
This block asks again with no proof, and then with the proof it already used.
for proof in '' "$USED_PROOF"; do
resp=$(curl -sS -w '\n%{http_code}' -u "$DPOP_CLIENT_ID:$DPOP_CLIENT_SECRET" "$POLICYARC_URL/token" \
${proof:+-H "DPoP: $proof"} -d grant_type=client_credentials -d scope=introspection)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }
done
Both return HTTP 400 {"error":"access_denied","error_description":"Access denied"}. The /token audit row for each has reason dpop_proof_required. The server records each proof's jti, so a proof works once. A proof for the wrong URL is refused the same way.
The server also refuses a proof whose typ is not dpop+jwt, whose alg is not in the allowed list, whose iat is more than 60 seconds from the server's clock, or that has an ath claim at /token.
Check the binding
Introspection shows that the token is bound and to which key.
resp=$(curl -fsS -u "$DPOP_CLIENT_ID:$DPOP_CLIENT_SECRET" "$POLICYARC_URL/introspect" -d token="$DPOP_TOKEN")
echo "$resp"
jq -e '.active == true and .token_type == "DPoP" and (.cnf.jkt | length > 0)' <<<"$resp" >/dev/null ||
{ echo 'Expected an active DPoP token with cnf.jkt' >&2; false; }
cnf.jkt is the SHA-256 thumbprint of the client's public key.
Use the token at a resource
Send Authorization: DPoP <token> and a new proof for that request. The proof's htm and htu name the resource call, and its ath is the token's hash. With the script above, that is node dpop.mjs GET <resource URL> "$DPOP_TOKEN".
- The managed gateway checks the proof on every call it forwards to an upstream. A missing or wrong proof gets HTTP 401. See How the managed gateway works.
- Your own resource server must check the proof itself. Introspect the token, then verify the proof's signature,
htm,htu,iat,jtiandath, and compare the thumbprint of itsjwkwithcnf.jkt.
Require DPoP
| To require a proof from | Set |
|---|---|
| One client, from its registration | "dpop_bound_access_tokens": true at /register |
| Every client | {"require_dpop": true} in pbac.operator.controls.dpop |
| A list of clients | {"require_dpop_for_clients": ["<client id>"]} in the same row |
When none of these applies, a proof is optional. A valid proof still binds the token. An invalid proof does not fail the request. We replayed a proof for a client that was not required to use DPoP and got HTTP 200 with "token_type": "Bearer". Check token_type in every response.
For a public client, the refresh token is bound to the same key. A refresh then needs a proof from that key, or it returns invalid_grant.
Server settings
| Variable | Default | Effect |
|---|---|---|
PBAC_DPOP_ENABLED | true | When false, the server ignores DPoP headers. |
PBAC_DPOP_SIGNING_ALGS | ES256,RS256 | Proof algorithms the server accepts |
PBAC_DPOP_IAT_WINDOW_SECONDS | 60 | How far a proof's iat may be from the server's clock |
PBAC_DPOP_NONCE_ENABLED | false | When true, a proof needs a nonce from the server |
PBAC_DPOP_NONCE_TTL_SECONDS | 300 | How long a server nonce stays valid |
With nonces on, a proof without one gets HTTP 400 use_dpop_nonce and a DPoP-Nonce response header. Put that value in the proof's nonce claim and send the request again. We read the nonce flow in the code and have not run it.
Send feedback about this page