API with DPoP and Sender-Constrained Access Tokens
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:
- During user authentication, the client sends a
dpop_jktparameter in its authorization request, with the thumbprint of its DPoP signing key. - The client sends a DPoP proof JWT in its token request, whose public key must match that sent earlier in the
dpop_jktparameter. - The client receives an opaque access token.
- The client calls an API with the opaque access token and also sends a fresh DPoP proof.
- The API gateway uses the Phantom Token Pattern to introspect the opaque access token and get a sender-constrained JWT access token.
- The API gateway runs a sender-constrained token plugin to implement DPoP proof of possession, with the help of a distributed cache.
- 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.
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.
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.
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.
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.
{"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.
services:- name: orders-apiurl: http://example-api:3001routes:- name: orders-apihosts:- api.demo.examplepaths:- /ordersplugins:- name: phantom-tokenconfig:introspection_endpoint: http://idsvr:8443/oauth/v2/oauth-introspectclient_id: introspect-clientclient_secret: Password1token_cache_seconds: 900scheme: DPoP- name: dpop-sender-constrainedconfig:cache_server: rediscache_port: 6379time_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.
function _M.run(config)local auth_header = ngx.req.get_headers()['Authorization']if not auth_header thenerror_response('invalid_token')endlocal access_token_jwt = auth_header:match("^%s*[Dd][Pp][Oo][Pp]%s+(.+)%s*$")if not access_token_jwt thenerror_response('invalid_token')endlocal dpop_proof_jwt = ngx.req.get_headers()['DPoP']if not dpop_proof_jwt thenerror_response('invalid_dpop_proof')endlocal dpop_jwt_objdpop_jwt_obj, error_code, error_reason = validate_dpop_proof_jwt(dpop_proof_jwt)if error_code thenngx.log(ngx.WARN, error_reason)error_response(error_code)endlocal at_jwt_objat_jwt_obj, error_code, error_reason = validate_access_token_jwt(access_token_jwt, dpop_jwt_obj)if error_code thenngx.log(ngx.WARN, error_reason)error_response(error_code)endlocal cache = redis:new()ok, err = cache:connect(config.cache_server, config.cache_port)if not ok thenngx.log(ngx.WARN, 'Cache connection failure: ', err)error_response('server_error')endlocal ok, error_code, error_reason = validate_nonce(dpop_jwt_obj.payload.nonce, cache, config)if error_code thenngx.log(ngx.WARN, error_reason)error_response(error_code)endlocal ok, error_code, error_reason = validate_jti(at_jwt_obj.payload.cnf.jkt, dpop_jwt_obj.payload.jti, cache, config)if error_code thenngx.log(ngx.WARN, error_reason)error_response(error_code)endngx.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.
| URL | Description |
|---|---|
https://admin.demo.example/admin | The admin endpoint for the Curity Identity Server |
https://login.demo.example | The base URL for OAuth endpoints of the Curity Identity Server |
https://api.demo.example/orders | An 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.
{"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.
[{"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.
Customer Stories
Learn how organizations run identity and API security at scale.
Read customer storiesWas this helpful?