Secure API requests with OAuth DPoP
For the highest level of security when connecting to the Tango API, you can add DPoP (Demonstrating Proof of Possession, RFC 9449) on top of standard OAuth 2.0. DPoP cryptographically binds your access token to a key pair you control. A stolen token is useless without the corresponding private key.
DPoP is optional and can be adopted gradually. Platforms start in opportunistic mode (DPoP proofs are validated when present, but not required) before switching to strict mode (all requests must include a valid proof). Contact your Tango account team to enable DPoP for your platform.
Prerequisite:Use OAuth for a more secure connection first. You still need OAuth client credentials and a service account—DPoP adds a layer of security on top of this flow; it does not replace it.
How DPoP protects your application
DPoP helps protect against several common token-based security risks by ensuring tokens can only be used by the intended client:
| Threat | DPoP protection |
|---|---|
| Token stolen from logs or memory | Prevents misuse because the attacker cannot generate a valid proof without the private key. |
| Man-in-the-middle token capture | Binds the token to the client’s key pair, making intercepted tokens unusable. |
| Token replay across endpoints or methods | Requires a unique proof for each request, tied to a specific HTTP method and URI. |
| Clock manipulation | Validates proof timestamps with a strict ±60 second tolerance window. |
| Lateral movement using leaked credentials | Blocks replay attempts through server-side deduplication within a 120-second window. |
How DPoP works
DPoP adds a proof-of-possession layer to OAuth by binding tokens to a cryptographic key. The following flow shows how to generate proofs, obtain a token, and securely call the Tango API:
- Generate a key pair: Create an asymmetric key pair once per session or application lifecycle.
- Create a DPoP proof JWT: Generate a short-lived, signed assertion tied to the specific HTTP request you are about to make.
- Acquire a DPoP-bound token: Include the DPoP proof in your token request (POST /oauth/token) using the DPoP HTTP header. Tango's authorization server returns a DPoP-bound token containing a cnf.jkt claim (a thumbprint of your public key).
- Call the Tango API with DPoP: For each API request, include the following headers:
- Authorization: DPoP <access_token>
- DPoP:
Step 1: Generate a key pair
Generate a key pair once per session or application lifecycle. EC P-256 (ES256) is recommended for the best balance of security and performance. RSA (RS256, RS384, RS512, PS256, PS384, PS512) is also supported.
# Generate EC P-256 private key
openssl ecparam -name prime256v1 -genkey -noout -out dpop_private.pem
# Derive the public key
openssl ec -in dpop_private.pem -pubout -out dpop_public.pem
Security note:Securely store your private key in an HSM, secure enclave, or encrypted secrets manager (e.g., AWS Secrets Manager, Azure Key Vault, HashiCorp Vault). Do not transmit, log, or hard-code the key. The key pair can be reused across token requests. DPoP proofs must be generated per request.
Step 2: Create a DPoP Proof JWT
A DPoP Proof is a compact JSON Web Token (JWT) that you generate for each request. It proves that the client making the request controls the private key associated with the access token. A DPoP proof JWT follows the standard JWT structure:
<header>.<payload>.<signature>A. Header
The header defines the token type (must be dpop+jwt), signing algorithm, and includes your public key (which the server uses to verify the signature):
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "<base64url-encoded x coordinate>",
"y": "<base64url-encoded y coordinate>"
}
}
B1. Payload (token request proof)
B1. Payload (token request proof)
The payload contains details about the specific HTTP request used to obtain an access token. Here's an example:
{
"jti": "a-fresh-uuid-per-request",
"htm": "POST",
"htu": "https://sandbox-auth.tangocard.com/oauth/token",
"iat": 1718000000
}
B2. Payload (API calls proof)
B2. Payload (API calls proof)
For API calls, the payload includes the same fields as above, with an additional ath claim that binds the proof to the access token. Here's an example:
{
"jti": "another-fresh-uuid",
"htm": "GET",
"htu": "https://integration-api.tangocard.com/raas/v2/customers",
"iat": 1718000050,
"ath": "<base64url(SHA-256(access_token))>"
}
C. Signature
C. Signature
ES256 signature over the header and payload, using your private key.
Notes:
- The
jwkheader contains your public key only—never include private key material.- The
htuvalue must include the scheme, host, and path with no query string.- The
athclaim is required for API calls (but not for token requests). It equalsbase64url(SHA-256(access_token)), which binds the proof to the specific access token.- Generate a new
jti(UUID) for every request—the server blocks reused values within a 120-second window.- Keep your system clock synchronized (NTP recommended). The server allows a maximum ±60 seconds skew on
iattimestamp.
Step 3: Acquire a DPoP-bound token
Use the same POST {URI}/oauth/token endpoint as standard OAuth, adding the DPoP header:
curl --request POST \
--url https://sandbox-auth.tangocard.com/oauth/token \
--header 'Accept: application/json' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'DPoP: <dpop_proof_jwt>' \
--data client_id=<your_client_id> \
--data client_secret=<your_client_secret> \
--data username=<service_account_username> \
--data 'password=<service_account_password>' \
--data scope=raas.all \
--data audience=https://api.tangocard.com/ \
--data grant_type=passwordThe token proof must include:
-
htm=POST -
htu: the full token endpoint URL (/oauth/token)Do not include
athin the token request proof.athis only required for API call proofs, not token requests.
Response
Response
The following data is returned from the /oauth/token endpoint after you request a token:
{
"access_token": "<string>",
"scope": "raas.all",
"expires_in": 86400,
"token_type": "DPoP"
}The token_type of DPoP confirms the token is bound to your key pair. The authorization server has embedded cnf.jkt (your public key thumbprint) in the token.
Step 4: Call the Tango API with DPoP
For every API call, generate a fresh DPoP proof for that specific request. Include the ath claim (SHA-256 hash of your access token) in every API proof.
Here's an example request:
curl \
--header "Accept: application/json" \
--header "Authorization: DPoP <your_access_token>" \
--header "DPoP: <fresh_dpop_proof_jwt>" \
https://integration-api.tangocard.com/raas/v2/customers
Important:Use
Authorization: DPoP, notAuthorization: Bearer. Bearer tokens and DPoP tokens are distinct.
Enforcement modes
DPoP enforcement is configured per-platform by your Tango account team. It determines whether proofs are optional or required.
| Mode | Behavior |
|---|---|
| Opportunistic (default during migration) | DPoP proofs are validated when present; requests without a DPoP header are still allowed. Use this to test your implementation before full enforcement. |
| Strict | All API requests must include a valid DPoP proof. Requests without a proof are rejected with dpop_required. |
During migration, use opportunistic mode to validate your DPoP flow while non-DPoP clients continue to work. Coordinate the switch to strict mode with your Tango account team once all clients are migrated.
Error responses
When DPoP validation fails, the server returns a 401 Unauthorized with structured error information per RFC 9449 §7.2:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="<error_code>", error_description="<reason>"
X-Pop-Decision-Stage: pop-validation
The following table outlines common errors and how to resolve them:
| Error Code | Cause | Resolution |
|---|---|---|
dpop_required | No proof provided in strict mode | Add the DPoP header with a valid proof JWT |
missing_dpop_proof | Bound token sent without a proof | Include a DPoP header matching the bound key |
malformed_proof | Proof is not a valid JWT or jwk header is missing | Verify compact JWT serialization and include jwk in the header |
missing_required_claim | Proof is missing jti, htm, htu, or iat | Ensure all four claims are present in the payload |
missing_ath | ath claim missing from API call proof | Include ath: base64url(SHA-256(access_token)) in API proofs |
invalid_signature | Proof signature does not verify against embedded JWK | Ensure signing key matches the jwk in the header |
invalid_typ | typ header is not dpop+jwt | Set JWT typ to exactly dpop+jwt |
htm_mismatch | htm ≠ actual HTTP method | Ensure htm matches the request method (case-insensitive) |
htu_mismatch | htu ≠ actual request URI | Use scheme + host + path only — no query string |
iat_out_of_range | iat is outside ±60 seconds of server time | Synchronize clock via NTP; generate proofs immediately before sending |
ath_mismatch | ath ≠ SHA-256 of the access token | Recompute ath from the exact access token string |
cnf_jkt_mismatch | Proof signing key ≠ key bound in the token | Use the same key pair that was used at token issuance |
replayed_dpop_proof | Same jti reused within 120 seconds | Generate a fresh UUID for every request |
Migrate to DPoP
You can migrate from your existing integration to use DPoP instead of (or in addition to) standard OAuth behavior (Bearer tokens). With OAuth, tokens can potentially be reused if stolen, but with DPoP, tokens are tied to your app and therefore much more secure.
To migrate to DPoP:
- Generate a stable key pair for your application. Store the private key securely.
- Update your token request to include a DPoP proof in the
DPoPheader. The response will havetoken_type: "DPoP". - Update all API calls:
- To use
Authorization: DPoP{token}instead ofAuthorization: Bearer{token} - To include a fresh
DPoPheader proof with every request (withath)
- To use
- Test in opportunistic mode so nothing breaks yet. Your platform will validate proofs while still allowing non-DPoP requests.
- Turn in on fully: coordinate the activation of strict mode with your Tango account team once all clients have been migrated.
FAQs
Here are the most frequently questions asked:
Q: Can I reuse the same key pair across multiple tokens?
Yes. The key pair is long-lived (per session or application). You request new tokens bound to the same key. Only the DPoP proofs are single-use.
Q: What happens if my clock is out of sync?
Proofs with iat more than 60 seconds from the server's time are rejected with iat_out_of_range. Use NTP to keep clocks synchronized and generate proofs immediately before sending.
Q: Do I need a DPoP proof when refreshing a token?
Yes. When refreshing a token, send a DPoP proof to the token endpoint (htm=POST, htu=<token_endpoint>). The new token will be bound to the same key.
Q: What algorithms are supported?
ES256, ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512. EC (ES256) is recommended for the best balance of security and performance.
Q: Is the DPoP header required on every single API request?
Yes. Once strict mode is enabled for your platform, every API request must include a valid, fresh DPoP proof.
Suggested pseudocode
Here's a simplified example of how the flow works; an easy-to-read, language-like example showing the logic of the integration. It's not the exact code that you copy and run:
# 1. Generate key pair (once per session or application lifecycle)
private_key = generate_ec_key(P-256)
# 2. Acquire DPoP-bound token
token_proof = sign_dpop_jwt(
private_key,
htm="POST",
htu="https://sandbox-auth.tangocard.com/oauth/token",
jti=uuid(),
iat=now()
# Note: no 'ath' claim on token request proofs
)
access_token = request_token(auth_server, headers={"DPoP": token_proof})
# Response: { "access_token": "...", "token_type": "DPoP" }
# 3. Call API (generate a fresh proof per request, including 'ath')
api_proof = sign_dpop_jwt(
private_key,
htm="GET",
htu="https://integration-api.tangocard.com/raas/v2/customers",
ath=base64url(sha256(access_token)),
jti=uuid(),
iat=now()
)
response = http_get(
"https://integration-api.tangocard.com/raas/v2/customers",
headers={
"Authorization": f"DPoP {access_token}",
"DPoP": api_proof
}
)