API with DPoP and Sender-Constrained Access Tokens

Advanced Security
QualityAvailability
Download on GitHub
On this page

The DPoP Overview article explains how Demonstrating Proof of Possession (DPoP) enables clients to get sender-constrained access tokens and call APIs. Any OAuth client, including public clients, can use DPoP, to protect against token theft. This tutorial provides a deployment example to enable a high security internet API, and explains the moving parts.

Note

The example uses the Curity Identity Server, but you can run the code against any standards-based authorization server.

Components

The deployment uses the following components, with a simple Node.js console client and a simple Node.js API. The Curity Identity Server acts as the authorization server, to issue sender constrained access tokens and validate DPoP requests. The API gateway uses a plugin to implement resource server DPoP validation.

The example deployment demonstrates the following steps:

  1. During user authentication, the client sends a dpop_jkt parameter in its authorization request, with the thumbprint of its DPoP signing key.
  2. The client sends a DPoP proof JWT in its token request, whose public key must match that sent earlier in the dpop_jkt parameter.
  3. The client receives an opaque access token.
  4. The client calls an API with the opaque access token and also sends a fresh DPoP proof.
  5. The API gateway uses the Phantom Token Pattern to introspect the opaque access token and get a sender-constrained JWT access token.
  6. The API gateway runs a sender-constrained token plugin to implement DPoP proof of possession, with the help of a distributed cache.
  7. API developers receive a JWT access token, validate it and use its claims for business authorization.

DPoP Client Code

Clients should use a respected library to implement DPoP, which should result in only minimal code changes. The example uses a simple Node.js console client, with the following operations from the Node.js dpop library. The console client stores separate authorization server and resource server nonces, and mostly uses one-liners to add DPoP parameters to its code flow.

typescript
1234567891011121314151617181920
import {calculateThumbprint, generateKeyPair, generateProof, KeyPair} from 'dpop';
export class DPopUtility {
private keypair: KeyPair | null = null;
public authorizationServerNonce: string | undefined;
public resourceServerNonce: string | undefined;
public async initialize(): Promise<void> {
this.keypair = await generateKeyPair('ES256', {extractable: false});
}
public async getDpopJkt(): Promise<string> {
return await calculateThumbprint(this.keypair!.publicKey)
}
public async getProofJwt(url: string, method: string, nonce: string | undefined, accessToken: string | undefined) {
return await generateProof(this.keypair!, url, method, nonce, accessToken);
}
}

The other important client-side change is to handle HTTP 400 challenge responses from the authorization server and APIs. The code example uses the following logic to call the Curity Identity Server's token endpoint and handle HTTP 400 responses.

typescript
123456789101112131415161718192021222324252627282930313233343535
public async backChannelRequest(code: string, dpop: DPopUtility): Promise<string> {
let dpopProofJwt = await dpop.getProofJwt(this.metadata.token_endpoint, 'POST', undefined, undefined);
const formData = new URLSearchParams();
formData.append('grant_type', 'authorization_code');
formData.append('client_id', this.configuration.dpopClientId);
formData.append('redirect_uri', this.redirectUri!);
formData.append('code', code);
formData.append('code_verifier', this.codeVerifier!);
const options: RequestInit = {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': dpopProofJwt,
},
body: formData.toString(),
};
let response = await fetch(this.metadata.token_endpoint, options);
if (response.status === 400) {
const dpopNonce = response.headers.get('dpop-nonce');
if (dpopNonce) {
dpop.authorizationServerNonce = dpopNonce;
dpopProofJwt = await dpop.getProofJwt(this.metadata.token_endpoint, 'POST', dpopNonce, undefined);
(options.headers as any)['DPoP'] = dpopProofJwt;
response = await fetch(this.metadata.token_endpoint, options);
}
}
...
}

For resource server requests, the code example uses the following logic to call API endpoints and handle HTTP 401 responses. For API requests to the example API, the client uses its resource server nonce. For API requests to the Curity Identity Server, such as to call the OpenID Connect user info endpoint, the client uses its authorization server nonce.

typescript
12345678910111213141516171819202122232425262727
public async getOrders(accessToken: string, dpop: DPopUtility): Promise<any> {
let dpopProofJwt = await dpop.getProofJwt(this.configuration.apiUrl, 'GET', dpop.resourceServerNonce, accessToken);
const options: RequestInit = {
method: 'GET',
headers: {
'Accept': 'application/json',
Authorization: `DPoP ${accessToken}`,
'DPoP': dpopProofJwt,
},
};
let response = await fetch(this.configuration.apiUrl, options);
if (response.status === 401) {
const dpopNonce = response.headers.get('dpop-nonce');
if (dpopNonce) {
dpop.resourceServerNonce = dpopNonce;
dpopProofJwt = await dpop.getProofJwt(this.configuration.apiUrl, 'GET', dpop.resourceServerNonce, accessToken);
(options.headers as any)['DPoP'] = dpopProofJwt;
response = await fetch(this.configuration.apiUrl, options);
}
}
...
}

An organization that uses DPoP to secure internet APIs, such as for B2B use cases, can easily explain these steps in its documentation, to enable clients to onboard reliably and efficiently.

API Code

The API is a minimal Node.js API that uses a simple OAuth filter to validate access tokens. The API does not have any awareness of DPoP, and only focuses on business authorization. The API code demonstrates that in a basic way, to filter fictional orders according to a customer_id claim from its JWT access token payload.

javascript
12345678
app.get('/', (request: express.Request, response: express.Response) => {
const claims: JWTPayload = response.locals.claims;
const authorizedOrders = getOrders(claims['customer_id'] as string);
response.setHeader('content-type', 'application/json');
response.status(200).send(JSON.stringify(authorizedOrders, null, 2));
});

DPoP Infrastructure

The main work for an organization that uses DPoP is the backend infrastructure. The example deployment does that work in an API gateway. At a real organization, this DPoP work might be the responsibility of a platform engineering or DevOps team.

The example deployment uses the Kong API gateway. The HTTP ingress route for the example API uses two plugins. First, a phantom token plugin introspects the access token to get a JWT access token, whose cnf.jkt claim contains the JWK thumbprint of the client's public DPoP signing key. The following example shows the payload of a sender-constrained access token.

json
12345678910111213141516171819
{
"jti": "bf30b56d-56e3-4c19-86f8-12c27f277dbd",
"delegationId": "ac10ce35-05b1-45cb-b291-8169b6b8bbc9",
"exp": 1787066361,
"nbf": 1787065461,
"scope": "openid profile retail/orders",
"iss": "https://login.demo.example/oauth/v2/oauth-anonymous",
"sub": "johndoe",
"aud": [
"dpop-client",
"https://api.demo.example/orders"
],
"iat": 1787065461,
"purpose": "access_token",
"cnf": {
"jkt": "eywqMwZfUtgXL9e-2Cn7sqc7W0B2Dfy_RUAeSf1RkfE"
},
"customer_id": "102"
}

The following Kong route shows the configuration settings for plugins. The dpop-sender-constrained plugin uses a redis cache to store server-provided nonces, as a mechanism to keep DPoP proofs short-lived, and also caches the jti claims of DPoP proof JWTs, to force clients to send a new proof JWT on every API request.

yaml
12345678910111213141516171819202122
services:
- name: orders-api
url: http://example-api:3001
routes:
- name: orders-api
hosts:
- api.demo.example
paths:
- /orders
plugins:
- name: phantom-token
config:
introspection_endpoint: http://idsvr:8443/oauth/v2/oauth-introspect
client_id: introspect-client
client_secret: Password1
token_cache_seconds: 900
scheme: DPoP
- name: dpop-sender-constrained
config:
cache_server: redis
cache_port: 6379
time_to_live_seconds: 300

The DPoP Sender Constrained Lua Plugin implements the resource server DPoP validation work. The following Lua script shows the main steps of the DPoP resource server validation. The plugin also verifies that the public key of the current DPoP proof JWT matches the JWT access token's cnf.jkt claim, and that the DPoP proof JWT's ath claim matches the SHA256 hash of the opaque access token.

lua
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
function _M.run(config)
local auth_header = ngx.req.get_headers()['Authorization']
if not auth_header then
error_response('invalid_token')
end
local access_token_jwt = auth_header:match("^%s*[Dd][Pp][Oo][Pp]%s+(.+)%s*$")
if not access_token_jwt then
error_response('invalid_token')
end
local dpop_proof_jwt = ngx.req.get_headers()['DPoP']
if not dpop_proof_jwt then
error_response('invalid_dpop_proof')
end
local dpop_jwt_obj
dpop_jwt_obj, error_code, error_reason = validate_dpop_proof_jwt(dpop_proof_jwt)
if error_code then
ngx.log(ngx.WARN, error_reason)
error_response(error_code)
end
local at_jwt_obj
at_jwt_obj, error_code, error_reason = validate_access_token_jwt(access_token_jwt, dpop_jwt_obj)
if error_code then
ngx.log(ngx.WARN, error_reason)
error_response(error_code)
end
local cache = redis:new()
ok, err = cache:connect(config.cache_server, config.cache_port)
if not ok then
ngx.log(ngx.WARN, 'Cache connection failure: ', err)
error_response('server_error')
end
local ok, error_code, error_reason = validate_nonce(dpop_jwt_obj.payload.nonce, cache, config)
if error_code then
ngx.log(ngx.WARN, error_reason)
error_response(error_code)
end
local ok, error_code, error_reason = validate_jti(at_jwt_obj.payload.cnf.jkt, dpop_jwt_obj.payload.jti, cache, config)
if error_code then
ngx.log(ngx.WARN, error_reason)
error_response(error_code)
end
ngx.req.set_header('Authorization', 'Bearer ' .. access_token_jwt)
end

Run the Example Deployment

The example uses a Docker Compose deployment. To run it, use the GitHub link at the top of this page to clone the repository. Study the README to understand prerequisites and deployment steps. The following URLs are then available for the DPoP client to call.

URLDescription
https://admin.demo.example/adminThe admin endpoint for the Curity Identity Server
https://login.demo.exampleThe base URL for OAuth endpoints of the Curity Identity Server
https://api.demo.example/ordersAn example sender-constrained internet URL for the resource server

Client Configuration

Follow the README instructions to log in to the Admin UI. Navigate to Profiles → Token Service → Clients and edit the client named dpop-client. Navigate to the Security tab and inspect the DPoP settings. The example client overrides default DPoP settings, so that the Curity Identity Server requires DPoP in authorization requests and token requests, and uses token-bound access and refresh tokens.

Run the DPoP Client

Follow the README instructions to run the DPoP client. The console app runs a code flow with a simple username authenticator, so that you can quickly sign in. The client visualizes token details and then calls the Curity Identity Server's user info endpoint with DPoP security and an authorization server-provided nonce.

json
123
{
"sub": "johndoe"
}

Finally, the client calls an example API with DPoP security and a resource server-provided nonce. For this request, the Lua plugin enforces DPoP security so that the API gets improved security without requiring additional work for API developers.

json
123456789101112
[
{
"customerId": "102",
"productId": "XM0922",
"amountUSD": 30000
},
{
"customerId": "102",
"productId": "LK9834",
"amountUSD": 45000
}
]

Conclusion

An end-to-end DPoP implementation, to harden access tokens and prevent token theft, requires only minimal code changes to OAuth clients and does not need to require any changes to API development. The more difficult work is backend infrastructure to validate DPoP proof JWTs. Once complete, you have guarantees that leaked access tokens cannot be successfully replayed.

Architecture

See how Curity fits into modern identity and API architectures.

Explore architecture

Customer Stories

Learn how organizations run identity and API security at scale.

Read customer stories