Series (Part 6 of 8): After mastering the payment data flow in Part 5, this article focuses on the API security layer — where a single design flaw can lead to token theft and unauthorized fund transfers.
What is FAPI 2.0 DPoP Implementation?
The Financial-grade API (FAPI) 2.0 standard mandates the use of sender-constrained tokens via DPoP or mTLS to prevent token theft. Deploying mTLS in Kubernetes adds 1-3ms of latency for the initial handshake, but this drops to <0.1ms with connection pooling and HTTP Keep-Alive.
Why Aren’t OAuth 2.0 Bearer Tokens Enough for Fintech?
Bearer tokens have a fundamental vulnerability: anyone holding the token can use it — just like cash. If an attacker intercepts a bearer token:
- Replay attack: They can use the token to call APIs at any time during its lifetime.
- Token theft: Stealing from memory, logs, or network sniffing → usable until it expires.
FAPI 2.0 solves this using Sender-Constrained Tokens:
| Mechanism | Principle | Protects Against |
|---|---|---|
| DPoP (Demonstrating Proof-of-Possession) | The token is bound to a public key. The client must prove private key ownership on every request. | Token theft — a stolen token is useless without the private key. |
| mTLS (Mutual TLS) | The token is bound to the client certificate thumbprint. The server verifies the cert. | Man-in-the-middle, token theft. |
| PAR (Pushed Authorization Requests) | Authorization parameters are sent directly to the AS, not via the URL (preventing parameter injection). | Authorization code injection. |
| JARM (JWT Secured Authorization Response) | The response from the AS is signed → tamper-proof. | Parameter tampering. |
DPoP: The Mathematical Mechanism
DPoP works by requiring the client to sign a proof JWT for every HTTP request, binding it to:
- The client’s public key (in the JWT header).
- The specific HTTP method and URI.
- A timestamp (
iat) and a uniquejti. - The access token hash (
ath) — preventing token injection attacks.
DPoP JWT Structure
Header (base64url):
{
"alg": "ES256", // Elliptic Curve P-256 — required by FAPI
"typ": "dpop+jwt", // DPoP-specific type
"jwk": { // Client's PUBLIC key (embedded in header)
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
Payload (base64url):
{
"jti": "unique-uuid-prevents-replay", // Must be unique per request
"htm": "POST", // HTTP method
"htu": "https://api.bank.vn/transfers", // URI (no query string)
"iat": 1718689200, // Issued at (Unix timestamp)
"ath": "base64url(SHA256(access_token))" // Access token hash
}
Signature: ES256(privateKey, base64(header) + "." + base64(payload))
Node.js DPoP Implementation (ES256)
const crypto = require('crypto');
/**
* generateDPoPProof — Generates a DPoP proof JWT for every HTTP request
* @param {string} privateKeyPem - EC private key PEM
* @param {Object} publicKeyJwk - Public key JWK (embedded in header)
* @param {string} httpMethod - 'GET', 'POST', etc.
* @param {string} httpUri - Full URI (no query params)
* @param {string|null} accessToken - Access token to generate the ath claim
* @returns {string} DPoP proof JWT
*/
function generateDPoPProof(privateKeyPem, publicKeyJwk, httpMethod, httpUri, accessToken = null) {
const header = {
alg: 'ES256',
typ: 'dpop+jwt',
jwk: publicKeyJwk // Embedded public key
};
const payload = {
jti: crypto.randomUUID(), // Unique per request — prevents replay
htm: httpMethod.toUpperCase(),
htu: httpUri.split('?')[0], // Strip query params
iat: Math.floor(Date.now() / 1000) // Current Unix timestamp
};
// Bind DPoP proof with access token (if available)
if (accessToken) {
const hash = crypto.createHash('sha256')
.update(accessToken, 'ascii')
.digest();
payload.ath = hash.toString('base64url');
}
// Encode header and payload
const base64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signingInput = `${base64Header}.${base64Payload}`;
// Sign with EC private key
const sign = crypto.createSign('SHA256');
sign.update(signingInput);
const signature = sign.sign(privateKeyPem, 'base64url');
return `${signingInput}.${signature}`;
}
// Usage:
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// Convert public key to JWK
const publicKeyJwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
// Generate proof for each request
const accessToken = 'eyJhbGci...'; // Token from authorization server
const dpopProof = generateDPoPProof(
privateKey,
publicKeyJwk,
'POST',
'https://api.bank.vn/v1/transfers',
accessToken
);
// HTTP Request headers:
// DPoP: <dpopProof>
// Authorization: DPoP <accessToken>
Go DPoP Verification (Server-side)
package dpop
import (
"crypto/sha256"
"encoding/base64"
"errors"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
type DPoPVerifier struct {
// Track used JTIs to prevent replay attacks
usedJTIs *RecentJTICache // sliding window cache, e.g. 5 minutes
}
func (v *DPoPVerifier) VerifyDPoP(
dpopHeader string,
method string,
uri string,
accessToken string,
) error {
// 1. Parse JWT and extract JWK from header
token, err := jwt.ParseString(dpopHeader,
jwt.WithValidate(false), // Manual validation below
)
if err != nil {
return errors.New("invalid dpop jwt")
}
// 2. Verify alg is ES256 or RS256 (FAPI forbids weak algs)
rawAlg, _ := token.Get("alg")
if alg, ok := rawAlg.(string); !ok || (alg != "ES256" && alg != "RS256") {
return errors.New("unsupported algorithm")
}
// 3. Verify typ = "dpop+jwt"
if token.JwtID() == "" {
return errors.New("missing jti")
}
// 4. Check replay: jti must not have been used
if v.usedJTIs.Contains(token.JwtID()) {
return errors.New("dpop replay detected: jti already used")
}
// 5. Verify iat is not older than 30 seconds (prevent delayed replay)
issuedAt, _ := token.Get(jwt.IssuedAtKey)
if t, ok := issuedAt.(time.Time); ok {
if time.Since(t) > 30*time.Second {
return errors.New("dpop token expired")
}
}
// 6. Verify htm and htu match the actual request
if htm, _ := token.Get("htm"); htm != method {
return errors.New("dpop htm mismatch")
}
if htu, _ := token.Get("htu"); htu != uri {
return errors.New("dpop htu mismatch")
}
// 7. Verify ath = SHA256(access_token)
hash := sha256.Sum256([]byte(accessToken))
expectedAth := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
if ath, _ := token.Get("ath"); ath != expectedAth {
return errors.New("dpop ath mismatch: token binding invalid")
}
// 8. Verify signature with JWK embedded in header
// (extracted via jwt.ParseString with key extraction)
// 9. Mark JTI as used
v.usedJTIs.Add(token.JwtID(), 30*time.Second)
return nil
}
FAPI 2.0 Mandatory Parameters
Source: OpenID FAPI 2.0 Profile
Entropy Requirements
- nonce: Minimum 128 bits of entropy (≥16 random characters from a secure PRNG).
- state: Minimum 128 bits of entropy — must be verified when receiving the authorization response.
PKCE Requirements
code_challenge_methodmust beS256. Never useplain.code_verifiermust be 43-128 characters from[A-Z a-z 0-9 -._~].
Client Authentication (Token Endpoint)
| Method | Mechanism | Security Level |
|---|---|---|
private_key_jwt | Client signs a JWT assertion with a private key | ✅ FAPI compliant |
tls_client_auth | mTLS with a client certificate | ✅ FAPI compliant |
client_secret_basic | HTTP Basic Auth | ❌ Not allowed in FAPI |
client_secret_post | Secret in POST body | ❌ Not allowed in FAPI |
mTLS Latency: Kubernetes Benchmark
Source: Linkerd Performance Benchmarks.
| Scenario | Latency Overhead | Notes |
|---|---|---|
| Initial mTLS handshake | 1-3ms | Certificate exchange + key negotiation |
| Subsequent requests (Keep-Alive) | <0.1ms | Session reuse, no re-handshake |
| Expired session (reconnect) | 1-3ms | Full handshake again |
| OCSP stapling | +0.5-1ms | Real-time certificate revocation check |
Kubernetes mTLS with Linkerd sidecar:
# Linkerd annotation to automatically enable mTLS
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
annotations:
linkerd.io/inject: enabled # Linkerd sidecar injection
spec:
template:
metadata:
annotations:
linkerd.io/inject: enabled
config.linkerd.io/proxy-cpu-request: "100m"
config.linkerd.io/proxy-memory-request: "20Mi"
# All traffic between pods will be automatically mTLS encrypted
# No changes required in the application code
With connection pooling (production recommendation):
// Go HTTP client with mTLS and connection pool
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caPool,
MinVersion: tls.VersionTLS13,
}
transport := &http.Transport{
TLSClientConfig: tlsConfig,
MaxIdleConns: 100, // Pool 100 idle connections
MaxIdleConnsPerHost: 10, // 10 per host
IdleConnTimeout: 90 * time.Second,
// Keep-Alive: subsequent requests do not need to re-handshake → <0.1ms overhead
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
PAR (Pushed Authorization Requests)
In traditional OAuth 2.0, authorization parameters are sent via URL redirect:
❌ Insecure:
https://auth.bank.vn/authorize?
response_type=code&
client_id=xxx&
redirect_uri=https://app.example.com/callback&
scope=transfer:write&
...
→ The URL can be logged, shared, or tampered with via referrer headers
PAR pushes parameters directly to the Authorization Server endpoint:
✅ PAR Flow:
Step 1: Client POSTs parameters to the AS /par endpoint (authenticated)
POST /par
Authorization: DPoP <client_assertion>
DPoP: <proof>
Body: response_type=code&scope=transfer:write&...
Response: { "request_uri": "urn:ietf:params:oauth:request_uri:random", "expires_in": 60 }
Step 2: Client redirects the user with ONLY the request_uri
https://auth.bank.vn/authorize?
client_id=xxx&
request_uri=urn:ietf:params:oauth:request_uri:random
→ No sensitive parameters exposed in the URL
QA & SDET Testing Strategy
Test 1: DPoP Token Replay Attack Simulation
func TestDPoPTokenReplayAttack(t *testing.T) {
// Get valid access token and DPoP proof
accessToken, dpopProof := getValidTokenAndProof("POST", "/v1/transfers")
// Request 1: Valid → Success
resp1 := makeRequest(accessToken, dpopProof, "POST", "/v1/transfers")
assert.Equal(t, 200, resp1.StatusCode)
// Request 2: Use SAME dpopProof (replay attack)
resp2 := makeRequest(accessToken, dpopProof, "POST", "/v1/transfers")
// Must be rejected: jti has already been used
assert.Equal(t, 401, resp2.StatusCode)
assert.Contains(t, resp2.Body, "dpop_replay_detected")
}
Test 2: Key Thumbprint Mismatch (Stolen Token)
func TestStolenTokenWithWrongKey(t *testing.T) {
// Get valid token (bound to key pair A)
accessToken := getAccessToken(keyPairA)
// Attacker possesses the access token but does not have private key A
// Generate DPoP proof with key pair B (attacker's key)
attackerProof := generateDPoPProof(keyPairB.Private, keyPairB.Public,
"POST", "/v1/transfers", accessToken)
// Request with stolen token + wrong key
resp := makeRequest(accessToken, attackerProof, "POST", "/v1/transfers")
// Must be rejected: thumbprint mismatch
assert.Equal(t, 401, resp.StatusCode)
assert.Contains(t, resp.Body, "dpop_key_mismatch")
}
Test 3: mTLS Certificate Expiry
# Generate expired certificate for testing
openssl req -x509 -nodes -days -1 -newkey rsa:2048 \
-keyout expired.key -out expired.crt \
-subj "/CN=test-client"
# Test with expired cert
curl --cert expired.crt --key expired.key \
https://api.bank.vn/v1/transfers
# Expectation: TLS handshake failure, connection rejected
# Server response: 400 Bad Request or TLS alert
FAPI 2.0 Security Checklist
## Pre-deployment Security Gate
### Authorization Server
- [ ] PAR endpoint enabled and enforced
- [ ] JARM (signed response) enabled
- [ ] nonce and state entropy ≥ 128 bits
- [ ] PKCE: S256 only, plain forbidden
### Client Authentication
- [ ] private_key_jwt or tls_client_auth only
- [ ] client_secret_* not allowed
### DPoP Verification (Resource Server)
- [ ] Verify alg ∈ {ES256, RS256, PS256}
- [ ] Verify typ = "dpop+jwt"
- [ ] Verify jti is not reused (replay prevention)
- [ ] Verify iat is within 30 seconds
- [ ] Verify htm = actual HTTP method
- [ ] Verify htu = actual URI
- [ ] Verify ath = SHA256(access_token)
- [ ] Verify signature with embedded JWK
### mTLS (if used)
- [ ] TLS 1.3 minimum
- [ ] Certificate pinning for known clients
- [ ] OCSP stapling enabled
- [ ] Connection pool for latency optimization
💡 Read more: Streaming Fraud Detection — Fraud detection using a FAPI-secured event stream.
FAQ
DPoP or mTLS — which should I choose?
It depends on the client type:
- DPoP: Better for browser-based clients and mobile apps — no certificate management required.
- mTLS: Better for server-to-server (B2B APIs) and payment gateways — cert rotation can be automated.
FAPI 2.0 allows both. Many implementations support both to let clients choose.
Does mTLS affect Kubernetes auto-scaling?
Yes, but a service mesh like Linkerd or Istio handles cert rotation automatically. When a new pod spins up, the sidecar automatically negotiates an mTLS cert from the control plane — this is completely transparent to the application code.
Where should the DPoP private key be stored in a mobile app?
iOS: Secure Enclave (hardware-backed key storage). Android: StrongBox or Android Keystore (hardware-backed when supported by the device). The private key must never be exported out of the secure enclave.
Up Next: Part 7 — Streaming Fraud Detection — Apache Flink CEP patterns, RocksDB memory tuning, async ML inference, and achieving <100ms fraud scoring SLAs.