DPoP a Fondo: La Guía Completa para Hacer Inútiles los Tokens OAuth Robados
Tus access tokens son bearer tokens. Eso significa que cualquiera que tenga el string del token — ya sea que lo robó de un log, de un CDN comprometido, de una vulnerabilidad XSS o de un ataque man-in-the-middle — puede usarlo exactamente como si fuera tu usuario legítimo. El token no sabe quién lo tiene. No le importa.
Esta es la debilidad de seguridad fundamental de los despliegues OAuth modernos, y ha sido un secreto a voces por más de una década. Cada auditoría de seguridad lo marca. Cada modelo de amenazas lo reconoce. Y hasta hace poco, las mitigaciones prácticas eran o demasiado complejas (¿mTLS para clientes de navegador?) o demasiado limitadas (los tiempos de vida cortos solo reducen el radio de explosión, no previenen la explosión).
DPoP (Demonstrating Proof-of-Possession) cambia la ecuación. Definido en RFC 9449 y ahora uno de los dos mecanismos aprobados (junto con mTLS) para los sender-constrained tokens obligatorios en FAPI 2.0, DPoP vincula criptográficamente los tokens a una clave privada que tiene el cliente. Poseer el token solo ya no es suficiente. Cada request a la API debe incluir una prueba criptográfica fresca de que el caller tiene la clave privada original. Los tokens robados se convierten en strings inertes.
Esta guía cubre todo: por qué los bearer tokens están fundamentalmente rotos, cómo funciona la criptografía de DPoP, implementaciones completas en TypeScript para cliente y servidor, manejo de nonces para protección contra replay, estrategias de almacenamiento de claves entre plataformas, y un camino práctico de migración de bearer a sender-constrained tokens.
El Problema del Bearer Token
Los bearer tokens funcionan como efectivo. Quien tiene el billete, lo gasta. No hay chequeo de identidad, no hay PIN, no hay biométrico. Esto fue una decisión de diseño deliberada: RFC 6750 define el bearer token como uno donde "cualquier parte en posesión de un bearer token puede usarlo para acceder a los recursos asociados (sin demostrar posesión de una clave criptográfica)."
Esa simplicidad hizo que la adopción de OAuth 2.0 fuera rápida. También hizo que el robo de tokens fuera devastadoramente efectivo.
Cómo Se Roban los Tokens
La superficie de ataque es amplia:
Vectores de filtración de tokens:
1. XSS → lectura de document.cookie o localStorage
2. Agregación de logs → tokens en params de URL o headers aparecen en logs
3. CDN/Proxy → servicios intermedios cachean o loguean headers Authorization
4. Extensiones de navegador → extensiones maliciosas leen headers de request
5. Man-in-the-middle → terminación TLS comprometida
6. Supply chain → paquete npm comprometido exfiltra tokens
El informe Verizon DBIR 2025 reporta que el robo de tokens representa el 31% de las técnicas de bypass de MFA en entornos empresariales, mientras que las credenciales robadas están presentes en el 22% de todas las brechas. Los tokens de vida corta reducen la ventana, pero un access token de 15 minutos sigue siendo 15 minutos de acceso total a la API para un atacante. La rotación de refresh tokens ayuda, pero si el refresh token se roba antes de la rotación, el atacante tiene acceso a largo plazo.
Por Qué las Mitigaciones Existentes Se Quedan Cortas
| Mitigación | Limitación |
|---|---|
| Tokens de vida corta | Reduce la ventana pero no previene el robo. Tokens de 5 minutos siguen dando 5 minutos de acceso. |
| Rotación de refresh tokens | Race condition: el atacante usa el token antes de la rotación. Falla si el refresh token se roba al emitirse. |
| Token binding (RFC 8471) | Nunca logró soporte amplio en navegadores. Efectivamente muerto. |
| mTLS (RFC 8705) | Seguridad excelente pero impracticable para clientes de navegador. El overhead de gestión de certificados es enorme. |
| Cookies HttpOnly | Protege contra XSS pero introduce riesgo de CSRF y no funciona para APIs cross-origin. |
DPoP ocupa el lugar justo: seguridad a nivel de aplicación que funciona en navegadores, apps móviles y flujos server-to-server sin requerir certificados TLS de cliente.
Cómo Funciona DPoP: La Criptografía
DPoP es conceptualmente simple: el cliente genera un par de claves, demuestra que tiene la clave privada con cada request, y el servidor vincula el token a esa clave:
Flujo DPoP:
1. El cliente genera un par de claves asimétricas (ej: EC P-256)
2. El cliente solicita token al Authorization Server
→ incluye DPoP proof JWT firmado con clave privada
→ el proof contiene: método HTTP, URL destino, ID único, timestamp
3. El Authorization Server valida el proof, emite token
→ el token contiene claim 'cnf' con thumbprint de clave pública
4. El cliente llama al Resource Server
→ envía token + NUEVO DPoP proof (firmado, fresco, con hash del token)
5. El Resource Server valida:
→ la firma del proof corresponde a la clave vinculada al token
→ método HTTP y URL coinciden con el request real
→ el proof es fresco (timestamp + nonce opcional)
→ el hash del token coincide con el token presentado
El punto clave: incluso si un atacante roba el access token, no puede generar DPoP proofs válidos sin la clave privada. El token es criptográficamente inútil para cualquiera excepto el cliente original.
La Estructura del DPoP Proof JWT
Cada request incluye un DPoP proof — un JWT firmado con una estructura específica:
Estructura del DPoP Proof JWT:
HEADER:
{
"typ": "dpop+jwt", // DEBE ser exactamente esto
"alg": "ES256", // Algoritmo asimétrico (ES256, RS256, etc.)
"jwk": { // Clave pública (para requests de token)
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
PAYLOAD:
{
"htm": "POST", // Método HTTP del request
"htu": "https://auth.example.com/token", // URL destino
"iat": 1712400000, // Emitido en (timestamp Unix)
"jti": "unique-id-abc123", // ID único (previene replay)
"ath": "fUHyO2r2Z3DZ..." // Hash del access token (para resource server)
"nonce": "server-nonce" // Nonce provisto por el servidor (si requerido)
}
Dos claims críticos diferencian DPoP de los JWTs regulares:
-
ath(Access Token Hash): Presente solo al llamar a resource servers. Es el hash SHA-256 del access token codificado en base64url. Vincula el proof a un token específico, previniendo la reutilización con otros tokens. -
nonce: Un valor opaco provisto por el servidor en el header de respuestaDPoP-Nonce. Habilita protección contra replay del lado del servidor más allá del chequeo de unicidad deljti.
Implementación Completa: Lado del Cliente
Construyamos un cliente DPoP completo en TypeScript. Usamos la Web Crypto API para generación de claves y la librería jose para operaciones JWT.
Generación del Par de Claves
// dpop-client.ts import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'; import { v4 as uuidv4 } from 'uuid'; interface DPoPKeyPair { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey; thumbprint: string; } async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { // Generar par de claves EC P-256 no-extraíble const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // no-extraíble: la clave privada no se puede exportar ['sign', 'verify'] ); const publicJwk = await exportJWK(keyPair.publicKey); const thumbprint = await calculateJwkThumbprint( publicJwk as Parameters<typeof calculateJwkThumbprint>[0], 'sha256' ); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, publicJwk, thumbprint, }; }
El parámetro false en generateKey es clave: marca la clave privada como no-extraíble. Ni siquiera código JavaScript corriendo en el mismo contexto puede leer el material crudo de la clave. La clave existe solo dentro del motor criptográfico del navegador.
Creación de DPoP Proofs
interface DPoPProofOptions { keyPair: DPoPKeyPair; method: string; url: string; accessToken?: string; // Requerido para requests al resource server nonce?: string; // Nonce provisto por el servidor } async function createDPoPProof(options: DPoPProofOptions): Promise<string> { const { keyPair, method, url, accessToken, nonce } = options; const payload: Record<string, unknown> = { htm: method.toUpperCase(), htu: url, jti: uuidv4(), iat: Math.floor(Date.now() / 1000), }; // Agregar hash del access token para requests al resource server if (accessToken) { const encoder = new TextEncoder(); const tokenBytes = encoder.encode(accessToken); const hashBuffer = await crypto.subtle.digest('SHA-256', tokenBytes); const hashArray = new Uint8Array(hashBuffer); payload.ath = base64urlEncode(hashArray); } // Agregar nonce provisto por el servidor si está disponible if (nonce) { payload.nonce = nonce; } const proof = await new SignJWT(payload) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: keyPair.publicJwk, }) .sign(keyPair.privateKey); return proof; } function base64urlEncode(buffer: Uint8Array): string { const binary = String.fromCharCode(...buffer); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }
El Cliente HTTP con DPoP
Acá está la pieza crucial — un wrapper de cliente HTTP que maneja el ciclo de vida completo de DPoP incluyendo reintentos automáticos de nonce:
class DPoPClient { private keyPair: DPoPKeyPair | null = null; private nonces: Map<string, string> = new Map(); // origin → nonce async initialize(): Promise<void> { this.keyPair = await generateDPoPKeyPair(); } async fetch( url: string, options: RequestInit & { accessToken?: string } = {} ): Promise<Response> { if (!this.keyPair) throw new Error('Cliente DPoP no inicializado'); const method = options.method || 'GET'; const origin = new URL(url).origin; const proof = await createDPoPProof({ keyPair: this.keyPair, method, url, accessToken: options.accessToken, nonce: this.nonces.get(origin), }); const headers = new Headers(options.headers); headers.set('DPoP', proof); if (options.accessToken) { headers.set('Authorization', `DPoP ${options.accessToken}`); } const response = await fetch(url, { ...options, headers }); // Manejar challenges de nonce const newNonce = response.headers.get('DPoP-Nonce'); if (newNonce) { this.nonces.set(origin, newNonce); if (response.status === 401 || response.status === 400) { const wwwAuth = response.headers.get('WWW-Authenticate') || ''; if (wwwAuth.includes('use_dpop_nonce')) { return this.fetch(url, options); // Reintentar con nonce almacenado } } } return response; } }
Request de Token con DPoP
async function requestToken( client: DPoPClient, tokenEndpoint: string, authCode: string, codeVerifier: string ): Promise<{ access_token: string; refresh_token: string }> { const response = await client.fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, code_verifier: codeVerifier, client_id: 'my-spa-client', redirect_uri: 'https://app.example.com/callback', }).toString(), }); if (!response.ok) { throw new Error(`Fallo en request de token: ${response.status}`); } return response.json(); }
Notá que el scheme de Authorization cambia de Bearer a DPoP — esto le señala al resource server que debe esperar y validar un DPoP proof.
Implementación Completa: Lado del Servidor
El lado del servidor tiene dos responsabilidades: vincular tokens a claves durante la emisión, y validar proofs durante el acceso a recursos.
Emisión de Tokens (Authorization Server)
import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose'; interface DPoPProofPayload { htm: string; htu: string; jti: string; iat: number; ath?: string; nonce?: string; } async function validateDPoPProof( proofJwt: string, expectedMethod: string, expectedUrl: string, expectedNonce?: string, accessToken?: string ): Promise<{ thumbprint: string }> { // 1. Decodificar header SIN verificar todavía (para extraer la clave pública) const [headerB64] = proofJwt.split('.'); const header = JSON.parse(atob(headerB64)); // 2. Validar header if (header.typ !== 'dpop+jwt') { throw new Error('typ inválido: debe ser dpop+jwt'); } if (!header.jwk) { throw new Error('Falta jwk en header'); } if (header.alg === 'none' || header.alg.startsWith('HS')) { throw new Error('Algoritmos simétricos no permitidos para DPoP'); } // 3. Importar clave pública y verificar firma const publicKey = await importJWK(header.jwk, header.alg); const { payload } = await jwtVerify(proofJwt, publicKey, { typ: 'dpop+jwt', maxTokenAge: '60s', }); const claims = payload as unknown as DPoPProofPayload; // 4. Validar método HTTP y URL if (claims.htm !== expectedMethod) { throw new Error(`htm mismatch: esperado ${expectedMethod}, recibido ${claims.htm}`); } if (claims.htu !== expectedUrl) { throw new Error(`htu mismatch: esperado ${expectedUrl}, recibido ${claims.htu}`); } // 5. Validar unicidad de jti (verificar contra caché de replay) const isReplay = await checkAndStoreJti(claims.jti, 300); if (isReplay) { throw new Error('Replay de DPoP proof detectado'); } // 6. Validar nonce si es requerido if (expectedNonce && claims.nonce !== expectedNonce) { throw new Error('Nonce DPoP inválido o ausente'); } // 7. Validar hash del access token (para requests al resource server) if (accessToken) { const expectedAth = await computeAth(accessToken); if (claims.ath !== expectedAth) { throw new Error('Hash del access token no coincide'); } } // 8. Calcular thumbprint del JWK para token binding const thumbprint = await calculateJwkThumbprint(header.jwk, 'sha256'); return { thumbprint }; } async function computeAth(accessToken: string): Promise<string> { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( 'SHA-256', encoder.encode(accessToken) ); return base64urlEncode(new Uint8Array(hashBuffer)); } // Caché de replay JTI basada en Redis async function checkAndStoreJti( jti: string, windowSeconds: number ): Promise<boolean> { const key = `dpop:jti:${jti}`; const exists = await redis.exists(key); if (exists) return true; await redis.setex(key, windowSeconds, '1'); return false; }
Token con Claim de Confirmación
Cuando el authorization server emite un token vinculado a DPoP, incluye un claim cnf (confirmation) con el thumbprint del JWK:
async function issueToken( userId: string, dpopThumbprint: string, scopes: string[] ): Promise<string> { const token = await new SignJWT({ sub: userId, scope: scopes.join(' '), cnf: { jkt: dpopThumbprint, // Confirmación de JWK Thumbprint }, token_type: 'DPoP', }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime('15m') .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .sign(serverPrivateKey); return token; }
Validación en el Resource Server
// middleware/dpop-validator.ts import { jwtVerify, decodeJwt } from 'jose'; async function validateDPoPRequest(req: Request): Promise<void> { // 1. Extraer el DPoP proof de los headers const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { throw new DPoPError(401, 'Falta header de DPoP proof'); } // 2. Extraer el access token const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('DPoP ')) { throw new DPoPError(401, 'Scheme de autorización inválido, se esperaba DPoP'); } const accessToken = authHeader.slice(5); // 3. Decodificar el token para obtener el thumbprint vinculado const tokenClaims = decodeJwt(accessToken); const boundThumbprint = (tokenClaims.cnf as { jkt: string })?.jkt; if (!boundThumbprint) { throw new DPoPError(401, 'Token no vinculado a DPoP (falta cnf.jkt)'); } // 4. Validar el DPoP proof const requestUrl = new URL(req.url).origin + new URL(req.url).pathname; const { thumbprint } = await validateDPoPProof( dpopProof, req.method, requestUrl, getCurrentNonce(), accessToken ); // 5. Verificar que el proof fue firmado con la clave vinculada al token if (thumbprint !== boundThumbprint) { throw new DPoPError(401, 'Clave del DPoP proof no coincide con el binding del token'); } } class DPoPError extends Error { constructor( public status: number, message: string ) { super(message); } }
Gestión de Nonces para Protección contra Replay
El claim jti provee unicidad del lado del cliente, pero un atacante con acceso al proof en tránsito podría replayarlo dentro de la ventana de tiempo. Los nonces provistos por el servidor agregan una segunda capa:
Cómo Funcionan los Nonces
Flujo de Nonce Challenge:
Cliente → Resource Server: DPoP proof (sin nonce)
Resource Server → Cliente: 401 + DPoP-Nonce: "abc123"
Cliente → Resource Server: DPoP proof (nonce: "abc123")
Resource Server → Cliente: 200 OK + DPoP-Nonce: "def456"
Cliente → Resource Server: DPoP proof (nonce: "def456")
...continúa, el nonce rota con cada respuesta...
Implementación de Nonces del Lado del Servidor
class NonceManager { private currentNonce: string; private previousNonce: string | null = null; private rotationInterval: NodeJS.Timeout; constructor(rotationSeconds: number = 30) { this.currentNonce = this.generate(); this.rotationInterval = setInterval(() => { this.previousNonce = this.currentNonce; this.currentNonce = this.generate(); }, rotationSeconds * 1000); } private generate(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64urlEncode(bytes); } getCurrent(): string { return this.currentNonce; } isValid(nonce: string): boolean { // Aceptar nonce actual o anterior (período de gracia durante rotación) return nonce === this.currentNonce || nonce === this.previousNonce; } } // Middleware Express function dpopNonceMiddleware(nonceManager: NonceManager) { return (req: Request, res: Response, next: NextFunction) => { // Siempre incluir el nonce actual en las respuestas res.setHeader('DPoP-Nonce', nonceManager.getCurrent()); next(); }; }
Reglas Importantes sobre Nonces
-
Los nonces son por servidor. Los nonces del authorization server y los del resource server son completamente separados. Nunca reutilices un nonce de un servidor al hablar con otro.
-
Los nonces son opacos. Los clientes no deben parsear, decodificar ni interpretar los valores de nonce. Tratalos como strings opacos.
-
Aceptá el nonce anterior. Durante la rotación, aceptá tanto el nonce actual como el inmediatamente anterior para no romper requests en vuelo.
-
Siempre enviá el header de nonce. Incluí
DPoP-Nonceen cada respuesta, incluso las exitosas. Así los clientes pueden pre-cargar el nonce para el siguiente request sin un roundtrip fallido.
Estrategias de Almacenamiento de Claves
La seguridad de DPoP depende completamente de que la clave privada se mantenga no-extraíble. Diferentes plataformas requieren diferentes estrategias:
Navegador (SPA)
// Usar IndexedDB para almacenamiento persistente de claves async function persistKeyPair(keyPair: CryptoKeyPair): Promise<void> { const db = await openDB('dpop-keys', 1, { upgrade(db) { db.createObjectStore('keys'); }, }); // Los objetos CryptoKey se pueden almacenar directamente en IndexedDB // Mantienen su propiedad non-extractable incluso al persistirse await db.put('keys', keyPair, 'dpop-keypair'); } async function loadKeyPair(): Promise<CryptoKeyPair | null> { const db = await openDB('dpop-keys', 1); return db.get('keys', 'dpop-keypair'); }
Propiedad clave: los objetos CryptoKey almacenados en IndexedDB retienen su propiedad extractable: false. El material crudo de la clave nunca se expone a JavaScript, ni siquiera entre recargas de página.
Móvil (React Native / Nativo)
| Plataforma | Almacenamiento | Nivel de Seguridad |
|---|---|---|
| iOS | Secure Enclave (Keychain) | Respaldado por hardware, a prueba de manipulación |
| Android | Android Keystore (StrongBox si disponible) | Respaldado por hardware en dispositivos soportados |
| React Native | react-native-keychain + backing específico de plataforma | Depende de la plataforma subyacente |
Server-to-Server
Para servicios backend, el par de claves puede cargarse desde variables de entorno o un servicio de gestión de claves (KMS):
// Usar variable de entorno o KMS const privateKey = await importPKCS8( process.env.DPOP_PRIVATE_KEY!, 'ES256' );
Migración: De Bearer a DPoP
No podés apretar un switch y requerir DPoP de todos los clientes de un día para el otro. Acá va el enfoque por fases:
Fase 1: Soporte Dual
Aceptar tanto tokens bearer como DPoP. Los clientes nuevos usan DPoP; los existentes siguen con bearer.
async function validateRequest(req: Request): Promise<TokenClaims> { const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('DPoP ')) { // Flujo DPoP: validar proof + token binding await validateDPoPRequest(req); return decodeAndVerifyToken(authHeader.slice(5)); } if (authHeader?.startsWith('Bearer ')) { // Flujo bearer legacy: todavía aceptado metrics.increment('auth.bearer.used'); // Trackear para deprecación return decodeAndVerifyToken(authHeader.slice(7)); } throw new Error('Header Authorization faltante o inválido'); }
Fase 2: DPoP Preferido
Emitir tokens vinculados a DPoP por defecto. Loguear uso de bearer tokens para monitoreo.
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (dpopProof) { // Cliente soporta DPoP: emitir token sender-constrained const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); } else { // Cliente legacy: emitir bearer token con aviso de deprecación const token = await issueBearerToken(userId, scopes); res.setHeader('Deprecation', 'true'); res.json({ access_token: token, token_type: 'bearer' }); } });
Fase 3: DPoP Obligatorio
Después de que el monitoreo confirme bajo uso de bearer, imponer DPoP para todos los clientes.
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { return res.status(400).json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof requerido. Los bearer tokens ya no se aceptan.', }); } const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); });
Métricas de Migración
| Métrica | Objetivo | Qué Te Dice |
|---|---|---|
| Tasa de adopción DPoP | >90% antes de Fase 3 | Progreso general de migración |
| Uso de bearer tokens | Declinando a <5% | Si los clientes legacy se están actualizando |
| Tasa de retry de nonce | <10% de requests | Si la rotación de nonces es muy agresiva |
| Fallos de validación de proof | <0.1% | Clock skew o bugs de implementación |
| Eventos de rotación de claves | Trackeados por cliente | Salud del ciclo de vida de claves |
DPoP vs. Otros Enfoques de Token Binding
| Característica | Bearer Token | DPoP (RFC 9449) | mTLS (RFC 8705) | Token Binding (RFC 8471) |
|---|---|---|---|---|
| Protección contra robo de token | ❌ Ninguna | ✅ Binding criptográfico | ✅ Binding TLS | ✅ Binding TLS |
| Soporte en navegadores | ✅ Universal | ✅ Web Crypto API | ❌ Sin UI de cert de cliente | ❌ Abandonado por navegadores |
| Soporte móvil | ✅ Universal | ✅ Crypto de plataforma | ⚠️ Gestión compleja de certs | ❌ No implementado |
| Complejidad de implementación | ⭐ Simple | ⭐⭐ Moderada | ⭐⭐⭐ Alta | N/A (Muerto) |
| Overhead de rendimiento | Ninguno | ~2ms por request (firma) | Costo de TLS handshake | N/A |
| Compatible con CDN/proxy | ✅ Sí | ✅ Sí (capa de aplicación) | ⚠️ Problemas de terminación TLS | ❌ La terminación TLS lo rompe |
| Madurez del estándar | RFC 6750 (2012) | RFC 9449 (2023) | RFC 8705 (2020) | Abandonado |
DPoP gana para web y móvil porque opera en la capa de aplicación. No se necesita configuración especial de TLS, no se necesitan certificados de cliente, no hay cambios en la UI del navegador. Solo criptografía en JavaScript.
Checklist de Seguridad para Producción
Lado del Cliente
-
Usá claves no-extraíbles. Siempre usá
extractable: falseencrypto.subtle.generateKey(). Esto previene que cualquier código JavaScript — incluyendo payloads XSS inyectados — pueda leer la clave privada cruda. -
Rotá las claves periódicamente. Generá un nuevo par de claves cuando los usuarios se reautentiquen o las sesiones empiecen. Los bindings de tokens viejos se invalidan automáticamente.
-
Usá IndexedDB, no localStorage. Los objetos
CryptoKeysolo se pueden almacenar en IndexedDB. localStorage solo almacena strings, lo que requeriría extraer la clave — anulando el propósito. -
Manejá el clock skew. El claim
iatdebe estar dentro de la ventana de tolerancia del servidor. La validación server-side debería aceptar ±60 segundos de desfase.
Lado del Servidor
-
Mantené un caché de replay de JTI. Usá Redis o similar con TTL que coincida con tu ventana de aceptación de proofs. Sin esto, los proofs se pueden replayar dentro de la ventana de tiempo.
-
Validá todo. Chequeá
typ,alg(rechazá algoritmos simétricos),htm,htu,iat,jti,ath, ynonce. Si te saltás algún paso de validación se crea un bypass. -
Usá nonces en contextos de alta seguridad. Para APIs financieras o cumplimiento FAPI 2.0, los nonces provistos por el servidor son mandatorios. Para APIs generales, agregan seguridad pero aumentan la latencia en un roundtrip en el primer request.
-
Devolvé nonces en cada respuesta. No obligues a los clientes a fallar antes de aprender el nonce. Incluí el header
DPoP-Nonceen todas las respuestas para que los clientes puedan pre-cargarlo. -
Rechazá
alg: noney algoritmos simétricos. El DPoP proof DEBE usar un algoritmo asimétrico. AceptarHS256anula el propósito completo — significaría que el servidor y el cliente comparten un secreto, que es exactamente lo que DPoP busca evitar.
Modelo de Rendimiento y Costo
DPoP agrega operaciones criptográficas a cada request. Acá está el impacto real:
| Operación | Tiempo (P50) | Tiempo (P99) | Notas |
|---|---|---|---|
| Generación de claves (EC P-256) | 0.5ms | 2ms | Una vez por sesión |
| Firma del proof (ECDSA) | 0.8ms | 2.5ms | Cada request |
| Verificación del proof (servidor) | 0.3ms | 1ms | Cada request |
| Lookup de caché JTI (Redis) | 0.1ms | 0.5ms | Cada request |
| Cálculo de thumbprint | 0.1ms | 0.3ms | Al emitir token |
Overhead total por request: ~1.3ms en el cliente, ~0.5ms en el servidor. Para contexto, una query típica a la base de datos toma 5-50ms. El overhead de DPoP es nivel ruido.
El trade-off es claro: ~2ms de latencia adicional por request eliminan toda la clase de ataques de robo de tokens.
La Realidad de 2026
DPoP no es un estándar futuro, es un requisito presente. RFC 9449 se publicó en septiembre de 2023 y se convirtió en uno de los dos mecanismos de sender-constraint requeridos para cumplimiento FAPI 2.0 en servicios financieros. Auth0 y Okta ofrecen soporte de primera clase para DPoP, y Microsoft Entra ID ofrece su propio binding de Proof-of-Possession. La WebCrypto API está disponible en cada navegador principal.
La barrera real es la inercia. Los equipos que construyeron sistemas de autenticación enteros alrededor de bearer tokens dudan en agregar la complejidad de proofs criptográficos. Pero la implementación no es compleja — es un par de claves, un JWT por request, y middleware de validación. La librería jose maneja el trabajo pesado. El wrapper de fetch con DPoP que vimos arriba tiene menos de 50 líneas de código.
Los bearer tokens fueron diseñados para la simplicidad en una era donde XSS era menos prevalente, los ataques de supply chain eran raros, y las arquitecturas de API eran más simples. Esa era terminó. Cada token que emitís como bearer simple es un token que puede ser robado y replayado con cero fricción.
Empezá por tus endpoints de API de mayor valor — autenticación, facturación, operaciones de admin. Agregá soporte DPoP junto con bearer tokens. Monitoreá qué clientes se actualizan. Cuando la cobertura sea suficiente, deprecá bearer tokens por completo.
Tus tokens deberían demostrar quién los tiene. DPoP lo hace posible con unos cientos de líneas de código y latencia despreciable. El único costo de no hacerlo es esperar la brecha.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit