Chuỗi bài (Phần 6 của 8): Sau khi nắm vững payment data flow ở Phần 5, bài này tập trung vào lớp bảo mật API — nơi mà một lỗ hổng thiết kế có thể dẫn đến token theft và unauthorized fund transfers.
⚠️ Lưu ý: Bài viết này tổng hợp từ documentation chính thức, engineering blogs, và benchmark papers đã công bố. Các con số latency và schema design phản ánh tài liệu nguồn tại thời điểm viết. Hãy verify với kiến trúc sư hoặc lead engineer của team trước khi áp dụng vào hệ thống production.
FAPI 2.0 DPoP Implementation Là Gì?
Chuẩn Financial-grade API (FAPI) 2.0 bắt buộc sử dụng sender-constrained tokens thông qua DPoP hoặc mTLS để chống đánh cắp token. Triển khai mTLS trong Kubernetes làm tăng 1-3ms độ trễ handshake ban đầu, nhưng sẽ giảm xuống <0.1ms với connection pooling và HTTP Keep-Alive.
Tại Sao OAuth 2.0 Bearer Tokens Không Đủ Cho Fintech?
Bearer tokens có một vulnerability cơ bản: bất kỳ ai có token đều có thể dùng nó — giống như tiền mặt. Nếu attacker intercept một bearer token:
- Replay attack: Dùng token đó để call API bất cứ lúc nào trong lifetime của token
- Token theft: Steal from memory, logs, or network sniffing → dùng mãi cho đến khi expire
FAPI 2.0 giải quyết bằng Sender-Constrained Tokens:
| Mechanism | Nguyên lý | Bảo vệ khỏi |
|---|---|---|
| DPoP (Demonstrating Proof-of-Possession) | Token được bind với public key. Client phải prove private key ownership mỗi request. | Token theft — stolen token vô dụng nếu không có private key |
| mTLS (Mutual TLS) | Token được bind với client certificate thumbprint. Server verify cert. | Man-in-the-middle, token theft |
| PAR (Pushed Authorization Requests) | Authorization parameters gửi trực tiếp đến AS, không qua URL (tránh parameter injection) | Authorization code injection |
| JARM (JWT Secured Authorization Response) | Response từ AS được ký → tamper-proof | Parameter tampering |
DPoP: Cơ Chế Toán Học
DPoP hoạt động bằng cách require client ký một proof JWT cho mỗi HTTP request, bind vào:
- Public key của client (trong JWT header)
- HTTP method và URI cụ thể
- Timestamp (
iat) và unique jti - Access token hash (
ath) — prevent token injection attacks
DPoP JWT Structure
Header (base64url):
{
"alg": "ES256", // Elliptic Curve P-256 — required bởi 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 — Tạo DPoP proof JWT cho mỗi 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 để tạo 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 — prevent replay
htm: httpMethod.toUpperCase(),
htu: httpUri.split('?')[0], // Strip query params
iat: Math.floor(Date.now() / 1000) // Current Unix timestamp
};
// Bind DPoP proof với access token (nếu có)
if (accessToken) {
const hash = crypto.createHash('sha256')
.update(accessToken, 'ascii')
.digest();
payload.ath = hash.toString('base64url');
}
// Encode header và payload
const base64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signingInput = `${base64Header}.${base64Payload}`;
// Sign với EC private key
const sign = crypto.createSign('SHA256');
sign.update(signingInput);
const signature = sign.sign(privateKeyPem, 'base64url');
return `${signingInput}.${signature}`;
}
// Sử dụng:
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// Convert public key sang JWK
const publicKeyJwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
// Generate proof cho mỗi request
const accessToken = 'eyJhbGci...'; // Token từ 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 để 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 và extract JWK từ header
token, err := jwt.ParseString(dpopHeader,
jwt.WithValidate(false), // Manual validation below
)
if err != nil {
return errors.New("invalid dpop jwt")
}
// 2. Verify alg là ES256 hoặc 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 phải chưa được dùng
if v.usedJTIs.Contains(token.JwtID()) {
return errors.New("dpop replay detected: jti already used")
}
// 5. Verify iat không quá 30 giây (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 và htu khớp với 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 với 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
Nguồn: OpenID FAPI 2.0 Profile
Entropy Requirements
- nonce: Minimum 128 bits entropy (≥16 random characters từ secure PRNG).
- state: Minimum 128 bits entropy — phải được verify khi nhận authorization response.
PKCE Requirements
code_challenge_methodphải làS256. Tuyệt đối không dùngplain.code_verifierphải là 43-128 ký tự từ[A-Z a-z 0-9 -._~].
Client Authentication (Token Endpoint)
| Method | Cơ chế | Security Level |
|---|---|---|
private_key_jwt | Client ký JWT assertion bằng private key | ✅ FAPI compliant |
tls_client_auth | mTLS với client certificate | ✅ FAPI compliant |
client_secret_basic | HTTP Basic Auth | ❌ Không được phép trong FAPI |
client_secret_post | Secret trong POST body | ❌ Không được phép trong FAPI |
mTLS Latency: Benchmark Kubernetes
Nguồn: 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 với Linkerd sidecar:
# Linkerd annotation để enable mTLS tự động
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"
# Toàn bộ traffic giữa pods sẽ được mTLS encrypted tự động
# Không cần thay đổi application code
Với connection pooling (khuyến nghị production):
// Go HTTP client với mTLS và 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 không cần re-handshake → <0.1ms overhead
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
PAR (Pushed Authorization Requests)
Trong traditional OAuth 2.0, authorization parameters được gửi qua URL redirect:
❌ Không an toàn:
https://auth.bank.vn/authorize?
response_type=code&
client_id=xxx&
redirect_uri=https://app.example.com/callback&
scope=transfer:write&
...
→ URL có thể bị log, shared, hoặc tampered với referrer headers
PAR gửi parameters trực tiếp đến Authorization Server endpoint:
✅ PAR Flow:
Step 1: Client POST parameters → 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 redirect user với chỉ request_uri
https://auth.bank.vn/authorize?
client_id=xxx&
request_uri=urn:ietf:params:oauth:request_uri:random
→ Không có sensitive parameters trong URL
QA & SDET Testing Strategy
Test 1: DPoP Token Replay Attack Simulation
func TestDPoPTokenReplayAttack(t *testing.T) {
// Lấy access token và DPoP proof hợp lệ
accessToken, dpopProof := getValidTokenAndProof("POST", "/v1/transfers")
// Request 1: Hợp lệ → thành công
resp1 := makeRequest(accessToken, dpopProof, "POST", "/v1/transfers")
assert.Equal(t, 200, resp1.StatusCode)
// Request 2: Dùng CÙNG dpopProof (replay attack)
resp2 := makeRequest(accessToken, dpopProof, "POST", "/v1/transfers")
// Phải bị từ chối: jti đã được dùng
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) {
// Lấy token hợp lệ (bound với key pair A)
accessToken := getAccessToken(keyPairA)
// Attacker có access token nhưng không có private key A
// Tạo DPoP proof bằng key pair B (attacker's key)
attackerProof := generateDPoPProof(keyPairB.Private, keyPairB.Public,
"POST", "/v1/transfers", accessToken)
// Request với stolen token + wrong key
resp := makeRequest(accessToken, attackerProof, "POST", "/v1/transfers")
// Phải bị từ chối: thumbprint mismatch
assert.Equal(t, 401, resp.StatusCode)
assert.Contains(t, resp.Body, "dpop_key_mismatch")
}
Test 3: mTLS Certificate Expiry
# Tạo expired certificate để test
openssl req -x509 -nodes -days -1 -newkey rsa:2048 \
-keyout expired.key -out expired.crt \
-subj "/CN=test-client"
# Test với expired cert
curl --cert expired.crt --key expired.key \
https://api.bank.vn/v1/transfers
# Kỳ vọng: TLS handshake failure, connection rejected
# Server response: 400 Bad Request hoặc TLS alert
FAPI 2.0 Security Checklist
## Pre-deployment Security Gate
### Authorization Server
- [ ] PAR endpoint enabled và enforced
- [ ] JARM (signed response) enabled
- [ ] nonce và state entropy ≥128 bits
- [ ] PKCE: S256 only, plain forbidden
### Client Authentication
- [ ] private_key_jwt hoặc tls_client_auth only
- [ ] client_secret_* không được phép
### DPoP Verification (Resource Server)
- [ ] Verify alg ∈ {ES256, RS256, PS256}
- [ ] Verify typ = "dpop+jwt"
- [ ] Verify jti không reused (replay prevention)
- [ ] Verify iat trong 30 giây
- [ ] Verify htm = actual HTTP method
- [ ] Verify htu = actual URI
- [ ] Verify ath = SHA256(access_token)
- [ ] Verify signature với embedded JWK
### mTLS (nếu dùng)
- [ ] TLS 1.3 minimum
- [ ] Certificate pinning cho known clients
- [ ] OCSP stapling enabled
- [ ] Connection pool cho latency optimization
📚 Xem thêm: Streaming Fraud Detection — fraud detection dùng FAPI-secured event stream
FAQ
DPoP hay mTLS — nên chọn cái nào?
Phụ thuộc vào client type:
- DPoP: Tốt hơn cho browser-based clients, mobile apps — không cần certificate management.
- mTLS: Tốt hơn cho server-to-server (B2B APIs), payment gateways — cert rotation có thể automated.
FAPI 2.0 cho phép cả hai. Nhiều implementations hỗ trợ cả hai để clients lựa chọn.
mTLS có ảnh hưởng đến auto-scaling Kubernetes không?
Có, nhưng Linkerd/Istio service mesh handle cert rotation tự động. Khi pod mới spin up, sidecar tự negotiate mTLS cert từ control plane — transparent với application code.
Nên store private key DPoP ở đâu trong mobile app?
iOS: Secure Enclave (hardware-backed key storage). Android: StrongBox hoặc Android Keystore (hardware-backed khi device support). Private key không bao giờ được export ra ngoài secure enclave.
Tiếp theo: Phần 7 — Streaming Fraud Detection — Apache Flink CEP patterns, RocksDB memory tuning, ML async inference, và đạt SLA <100ms fraud scoring.