Tutorial DPoP Node.js: Cegah Pencurian Token OAuth 2.0 & Replay Attack
Poin Kunci Artikel Ini:
- Import JWK client, verifikasi signature DPoP proof.
- Client kirim request tanpa nonce atau pakai nonce lama.
- Client kirim ulang request. Server terima request.
Bearer token pada OAuth 2.0 bekerja seperti uang tunai. Siapa pun pemegang token bisa akses resource terlindungi tanpa bukti kepemilikan. Attacker curi access token lewat Cross-Site Scripting (XSS), Man-in-the-Middle (MitM), log leakage, atau browser storage leakage. Attacker lalu panggil API atas nama korban.
RFC 9449 perkenalkan DPoP (Demonstrating Proof-of-Possession at Application Layer). DPoP ubah bearer token jadi sender-constrained token. Access token terikat kriptografis ke pasangan kunci publik/privat client. Attacker curi token DPoP tetap tidak bisa pakai token tanpa private key client.
Vektor Serangan Bearer Token & Keterbatasan Keamanan
REST API konvensional pakai header Authorization skema Bearer:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...Mekanisme ini punya celah keamanan fatal:
- XSS Exfiltration: Script jahat di client-side baca token dari memory/storage, kirim ke server attacker. Attacker pakai token dari mana saja.
- API Gateway / Reverse Proxy Log Leakage: Token tercatat di access log HTTP server format plaintext. Internal user atau attacker punya akses log langsung pakai token.
- Replay Attack: Attacker cegat HTTP request, kirim ulang request sama persis berulang kali selama token belum expired.
- Token Misuse Across Services: Token untuk Service A dipakai akses Service B karena token tidak terikat ke URI target tertentu.
OAuth 2.1 wajibkan token binding seperti DPoP atau mTLS (Mutual TLS). DPoP berjalan penuh di Application Layer (HTTP). DPoP tidak butuh infrastruktur mTLS rumit pada load balancer atau reverse proxy.
Konsep Kriptografi & Spesifikasi RFC 9449
DPoP mewajibkan client buat header HTTP DPoP tiap request resource. Header berisi JWT umur pendek (30-60 detik), ditandatangani private key client.
1. Header DPoP Proof JWT
Header JWT mengandung parameter wajib:
typ: Wajib bernilaidpop+jwt.alg: Algoritma asymmetric signature (misalES256,EdDSA). RS256 dilarang untuk performa dan keamanan.jwk: Public key client format JSON Web Key. Tidak boleh mengandung private key parameter.
2. Payload DPoP Proof JWT
Payload memuat claim validasi kontekstual:
jti(JWT ID): Identifier unik UUIDv4 cegah replay.htm(HTTP Method): Method request (GET, POST, PUT, DELETE).htu(HTTP URI): Target URI tanpa query string dan fragment.iat(Issued At): Epoch timestamp pembuatan proof.nonce: String acak dari server (opsional, untuk pengetatan replay).
3. Confirmation Claim (cnf) & JWK Thumbprint (jkt)
Authorization Server menghitung SHA-256 thumbprint dari JWK client (RFC 7638), dinamai jkt. Authorization Server selipkan jkt ke access token JWT pada claim cnf.jkt:
{
"sub": "user_123",
"iss": "https://auth.example.com",
"cnf": {
"jkt": "0ZcOCORWFuNu-EjG-jvWhaMqi9zOi-gJ728-kqZwep8"
}
}Resource Server verifikasi jkt dari header DPoP public key cocok dengan cnf.jkt di dalam Access Token. Jika cocok, client pemilik kunci sah.
Perbandingan Keamanan: Bearer vs DPoP vs mTLS
| Fitur / Karakteristik | OAuth 2.0 Bearer | OAuth 2.0 DPoP (RFC 9449) | OAuth 2.0 mTLS (RFC 8705) |
|---|---|---|---|
| Token Binding Layer | Tidak Ada | Application Layer (HTTP Header) | Transport Layer (TLS Handshake) |
| Kebutuhan Infrastruktur | Minimal | Aplikasi (Node.js/Express) saja | Sertifikat X.509, Root CA, Proxy Pass-Through |
| Proteksi XSS Token Theft | Tidak Proteksi | Proteksi Penuh (Private key tak bisa diekspor) | Proteksi Penuh |
| Proteksi Log Leakage | Bocor = Kompromi Total | Bocor = Token Tidak Bisa Dipakai Attacker | Bocor = Token Tidak Bisa Dipakai Attacker |
| Kompleksitas Client | Rendah | Sedang (Sign JWT tiap request) | Tinggi (Manajemen sertifikat TLS) |
Praktik Implementasi DPoP di Node.js & Express.js
1. Instalasi Dependensi
npm install express jose ioredis2. Client-Side DPoP Proof Generator
Client buat keypair EC P-256, lalu generate DPoP proof tiap pemanggilan API:
const { generateKeyPair, SignJWT, exportJWK } = require('jose');
const crypto = require('crypto');
let clientKeyPair = null;
async function getOrCreateKeyPair() {
if (!clientKeyPair) {
clientKeyPair = await generateKeyPair('ES256', { extractable: false });
}
return clientKeyPair;
}
async function createDPoPProof(httpMethod, httpUri, nonce = null) {
const { privateKey, publicKey } = await getOrCreateKeyPair();
const publicJwk = await exportJWK(publicKey);
const jwt = new SignJWT({
htm: httpMethod.toUpperCase(),
htu: httpUri,
nonce: nonce || undefined
})
.setProtectedHeader({
typ: 'dpop+jwt',
alg: 'ES256',
jwk: publicJwk
})
.setIssuedAt()
.setJti(crypto.randomUUID());
return await jwt.sign(privateKey);
}3. Middleware Verifikasi DPoP Server-Side
Urutan validasi ketat wajib diikuti. Kegagalan di satu tahap batalkan seluruh request.
Peringatan Keamanan Validasi Token: Jangan pernah melewatkan verifikasi claim htm dan htu. Abaikan salah satu claim membuat token rentan terhadap serangan Cross-Site Request Forgery (CSRF) dan Token Forwarding Attack antar endpoint.
- Periksa keberadaan header
DPoPdanAuthorization: DPoP <token>. - Decode header DPoP, pastikan
typ === 'dpop+jwt'danjwktersedia. - Import JWK client, verifikasi signature DPoP proof.
- Validasi claim
htmcocok dengan HTTP method request. - Validasi claim
htucocok dengan URL endpoint tanpa query parameter. - Cek timestamp
iat, tolak jika selisih waktu melebihi 60 detik. - Hitung SHA-256 JWK thumbprint dari header
jwk. - Verifikasi Access Token JWT, pastikan claim
cnf.jktpersis sama dengan hasil thumbprint.
const crypto = require('crypto');
const { jwtVerify, importJWK, calculateJwkThumbprint } = require('jose');
// Public Key Auth Server untuk verifikasi Access Token
const AUTH_SERVER_PUBLIC_KEY = /* import Key Object */;
async function dpopValidationMiddleware(req, res, next) {
try {
const dpopHeaderValue = req.headers['dpop'];
const authHeaderValue = req.headers['authorization'];
if (!dpopHeaderValue) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Header DPoP tidak ditemukan' });
}
if (!authHeaderValue || !authHeaderValue.startsWith('DPoP ')) {
return res.status(401).json({ error: 'invalid_token', error_description: 'Skema Authorization harus DPoP' });
}
const accessToken = authHeaderValue.slice(5).trim();
// Parse header JWT DPoP tanpa verifikasi untuk ambil public key
const dpopParts = dpopHeaderValue.split('.');
if (dpopParts.length !== 3) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Format DPoP JWT malformed' });
}
const dpopHeader = JSON.parse(Buffer.from(dpopParts[0], 'base64url').toString());
if (dpopHeader.typ !== 'dpop+jwt' || !dpopHeader.jwk) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Header typ harus dpop+jwt dan jwk wajib ada' });
}
if (dpopHeader.jwk.d) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'JWK tidak boleh mengandung private key' });
}
// Import public key client dari JWK
const clientPublicKey = await importJWK(dpopHeader.jwk, dpopHeader.alg);
// Verifikasi signature DPoP Proof
const { payload: dpopPayload } = await jwtVerify(dpopHeaderValue, clientPublicKey, {
algorithms: ['ES256', 'EdDSA']
});
// Validasi claim HTM (HTTP Method)
if (dpopPayload.htm !== req.method.toUpperCase()) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Claim htm tidak cocok dengan request method' });
}
// Validasi claim HTU (HTTP URI) - Normalisasi hapus query string & fragment
const requestFullUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
if (dpopPayload.htu !== requestFullUrl) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Claim htu tidak cocok dengan request URI' });
}
// Validasi Window Expiry (Clock Skew Max 60 Detik)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - dpopPayload.iat) > 60) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof kedaluwarsa atau clock skew terlalu besar' });
}
// Hitung JWK Thumbprint (jkt)
const computedJkt = await calculateJwkThumbprint(dpopHeader.jwk, 'sha256');
// Verifikasi Access Token JWT
const { payload: tokenPayload } = await jwtVerify(accessToken, AUTH_SERVER_PUBLIC_KEY);
// Validasi Token Binding (cnf.jkt)
if (!tokenPayload.cnf || tokenPayload.cnf.jkt !== computedJkt) {
return res.status(401).json({ error: 'unauthorized_client', error_description: 'Access token tidak terikat ke public key DPoP ini' });
}
req.dpop = dpopPayload;
req.token = tokenPayload;
req.clientJkt = computedJkt;
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_dpop_proof', error_description: err.message });
}
}Mitigasi Replay Attack Lanjutan
Signature kriptografi mencegah pembajakan isi payload. Attacker masih bisa curi DPoP proof valid lalu re-send request dalam jendela 60 detik. Dua mekanisme cegah serangan ini:
1. Cache Tracking JTI dengan Redis
Simpan jti DPoP proof di Redis. Set TTL sesuai window expiry (60 detik). Jika jti sudah ada di Redis, tolak request.
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function verifyJTIUniqueness(req, res, next) {
const jti = req.dpop.jti;
const key = `dpop:jti:${jti}`;
// Atomic operation SETNX dengan TTL 60 detik
const isSet = await redis.set(key, '1', 'EX', 60, 'NX');
if (!isSet) {
return res.status(401).json({
error: 'invalid_dpop_proof',
error_description: 'Replay attack terdeteksi: JTI sudah pernah digunakan'
});
}
next();
}2. Server-Provided Nonce Handshake (RFC 9449 Section 8)
Server paksa client pakai nonce buatan server. Alur kerja handshake nonce:
- Client kirim request tanpa nonce atau pakai nonce lama.
- Server tolak request dengan status HTTP 401. Server kirim header
DPoP-Nonce: <nonce_baru>dan erroruse_dpop_nonce. - Client buat DPoP proof baru memasukkan
noncedari server. - Client kirim ulang request. Server terima request.
async function validateServerNonce(req, res, next) {
const clientNonce = req.dpop.nonce;
const expectedNonce = await redis.get(`dpop:nonce:${req.clientJkt}`);
if (!clientNonce || clientNonce !== expectedNonce) {
const newNonce = crypto.randomBytes(16).toString('hex');
await redis.set(`dpop:nonce:${req.clientJkt}`, newNonce, 'EX', 300);
res.setHeader('DPoP-Nonce', newNonce);
return res.status(401).json({
error: 'use_dpop_nonce',
error_description: 'Server membutuhkan DPoP-Nonce terbaru'
});
}
next();
}Strategi Rotasi Kunci & Key Storage Client
1. Browser Environment
Gunakan Web Crypto API (window.crypto.subtle) untuk simpan private key. Set properti extractable: false. Simpan KeyPair di IndexedDB. Script XSS tidak bisa mengekspor private key keluar dari browser.
2. Mobile Environment (React Native / Native Node)
Simpan private key di Android Keystore atau iOS Keychain (Hardware-backed security module / Secure Enclave). Operasi penandatanganan JWT dilakukan di dalam enclave.
3. Rotasi Key Client
Client bisa ganti pasangan kunci kapan saja. Prosedur rotasi kunci:
- Client buat EC keypair baru.
- Client panggil endpoint
/oauth/tokenpakaigrant_type=refresh_token. - Client sertakan DPoP proof ditandatangani private key baru.
- Authorization Server terbitkan Access Token baru terikat
jktkunci baru. - Client hapus pasangan kunci lama.
Checklist Produksi DPoP Node.js
| Komponen | Kriteria Validasi | Tindakan Rekayasa |
|---|---|---|
| Algoritma Kunci | Gunakan ES256 (P-256) atau Ed25519 | Tolak algoritma none, HS256, atau RS256 pada header DPoP. |
| URI Normalization | Match exact scheme, host, port, path | Hapus query parameter, hash fragment, dan trailing slash tidak konsisten. |
| Clock Skew Toleransi | Maksimal 60 detik | Sinkronkan jam server pakai NTP (Network Time Protocol). |
| Reverse Proxy | Header Stripping & Forwarding | Pastikan Nginx / Cloudflare meneruskan header DPoP tanpa modifikasi. |
| Transport Security | Wajib TLS 1.3 | DPoP proteksi application layer, TLS proteksi confidentiality perantara network. |
Kesimpulan
Penggunaan Bearer Token tanpa token binding kriptografis sangat berisiko untuk API modern. RFC 9449 DPoP pada Node.js mengikat access token secara eksklusif ke kunci privat milik client. Kebocoran token akibat XSS, log server, atau interception tidak bisa dimanfaatkan oleh attacker tanpa kunci privat client. Kombinasi DPoP middleware, pengecekan cnf.jkt, Redis JTI cache, dan server-provided nonce menghasilkan arsitektur API OAuth 2.1 dengan tingkat keamanan maksimal.


