DPoP na Prática: O Guia Completo pra Tornar Tokens OAuth Roubados Inúteis
Seus access tokens são bearer tokens. Isso significa que qualquer pessoa que tiver a string do token — seja roubando de um log, de um CDN comprometido, de uma vulnerabilidade XSS ou de um ataque man-in-the-middle — pode usar como se fosse seu usuário legítimo. O token não sabe quem tá segurando ele. Não se importa.
Essa é a fraqueza fundamental de segurança dos deployments OAuth modernos, e é um segredo aberto já faz mais de uma década. Toda auditoria de segurança aponta. Todo modelo de ameaças reconhece. E até recentemente, as mitigações práticas eram complexas demais (mTLS pra clientes de navegador?) ou limitadas demais (tempos de vida curtos só reduzem o raio de explosão, não previnem a explosão).
DPoP (Demonstrating Proof-of-Possession) muda o jogo. Definido na RFC 9449 e agora um dos dois mecanismos aprovados (junto com mTLS) pros sender-constrained tokens obrigatórios no FAPI 2.0, DPoP vincula criptograficamente os tokens a uma chave privada que o cliente tem. Só ter o token já não basta. Cada request pra API precisa incluir uma prova criptográfica fresca de que quem tá chamando tem a chave privada original. Tokens roubados viram strings sem utilidade.
Esse guia cobre tudo: por que bearer tokens são fundamentalmente quebrados, como a criptografia do DPoP funciona, implementações completas em TypeScript pra cliente e servidor, tratamento de nonces pra proteção contra replay, estratégias de armazenamento de chaves entre plataformas, e um caminho prático de migração de bearer pra sender-constrained tokens.
O Problema do Bearer Token
Bearer tokens funcionam como dinheiro vivo. Quem segura a nota, gasta. Não tem verificação de identidade, não tem PIN, não tem biometria. Isso foi uma decisão de design deliberada: a RFC 6750 define o bearer token como um onde "qualquer parte em posse de um bearer token pode usá-lo pra acessar os recursos associados (sem demonstrar posse de uma chave criptográfica)."
Essa simplicidade fez a adoção do OAuth 2.0 ser rápida. Também fez o roubo de tokens ser devastadoramente efetivo.
Como Tokens São Roubados
A superfície de ataque é ampla:
Vetores de vazamento de tokens:
1. XSS → leitura de document.cookie ou localStorage
2. Agregação de logs → tokens em params de URL ou headers aparecem nos logs
3. CDN/Proxy → serviços intermediários cacheiam ou logam headers Authorization
4. Extensões de navegador → extensões maliciosas leem headers de request
5. Man-in-the-middle → terminação TLS comprometida
6. Supply chain → pacote npm comprometido exfiltra tokens
O relatório Verizon DBIR 2025 reporta que roubo de tokens representa 31% das técnicas de bypass de MFA em ambientes empresariais, enquanto credenciais roubadas estão presentes em 22% de todas as brechas. Tokens de vida curta reduzem a janela, mas um access token de 15 minutos ainda são 15 minutos de acesso total à API pro atacante. Rotação de refresh tokens ajuda, mas se o refresh token for roubado antes da rotação, o atacante tem acesso de longo prazo.
Por Que as Mitigações Existentes São Insuficientes
| Mitigação | Limitação |
|---|---|
| Tokens de vida curta | Reduz a janela mas não previne o roubo. Tokens de 5 minutos ainda dão 5 minutos de acesso. |
| Rotação de refresh tokens | Race condition: atacante usa o token antes da rotação. Falha se o refresh token for roubado na emissão. |
| Token binding (RFC 8471) | Nunca teve suporte amplo em navegadores. Efetivamente morto. |
| mTLS (RFC 8705) | Segurança excelente mas impraticável pra clientes de navegador. Overhead de gestão de certificados é enorme. |
| Cookies HttpOnly | Protege contra XSS mas introduz risco de CSRF e não funciona pra APIs cross-origin. |
DPoP ocupa o ponto ideal: segurança na camada de aplicação que funciona em navegadores, apps mobile e fluxos server-to-server sem exigir certificados TLS de cliente.
Como o DPoP Funciona: A Criptografia
DPoP é conceitualmente simples: o cliente gera um par de chaves, prova que tem a chave privada a cada request, e o servidor vincula o token a essa chave:
Fluxo DPoP:
1. Cliente gera par de chaves assimétrico (ex: EC P-256)
2. Cliente solicita token ao Authorization Server
→ inclui DPoP proof JWT assinado com chave privada
→ o proof contém: método HTTP, URL destino, ID único, timestamp
3. Authorization Server valida o proof, emite token
→ token contém claim 'cnf' com thumbprint da chave pública
4. Cliente chama o Resource Server
→ envia token + NOVO DPoP proof (assinado, fresco, com hash do token)
5. Resource Server valida:
→ assinatura do proof corresponde à chave vinculada ao token
→ método HTTP e URL batem com o request real
→ o proof é fresco (timestamp + nonce opcional)
→ o hash do token bate com o token apresentado
O ponto chave: mesmo que um atacante roube o access token, ele não consegue gerar DPoP proofs válidos sem a chave privada. O token é criptograficamente inútil pra qualquer um exceto o cliente original.
A Estrutura do DPoP Proof JWT
Cada request inclui um DPoP proof — um JWT assinado com uma estrutura específica:
Estrutura do DPoP Proof JWT:
HEADER:
{
"typ": "dpop+jwt", // DEVE ser exatamente isso
"alg": "ES256", // Algoritmo assimétrico (ES256, RS256, etc.)
"jwk": { // Chave pública (pra requests de token)
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
PAYLOAD:
{
"htm": "POST", // Método HTTP do request
"htu": "https://auth.example.com/token", // URL destino
"iat": 1712400000, // Emitido em (timestamp Unix)
"jti": "unique-id-abc123", // ID único (previne replay)
"ath": "fUHyO2r2Z3DZ..." // Hash do access token (pra resource server)
"nonce": "server-nonce" // Nonce fornecido pelo servidor (se requerido)
}
Dois claims críticos diferenciam DPoP de JWTs regulares:
-
ath(Access Token Hash): Presente só ao chamar resource servers. É o hash SHA-256 do access token codificado em base64url. Vincula o proof a um token específico, prevenindo reuso com outros tokens. -
nonce: Um valor opaco fornecido pelo servidor no header de respostaDPoP-Nonce. Habilita proteção contra replay do lado do servidor além do check de unicidade dojti.
Implementação Completa: Lado do Cliente
Bora construir um cliente DPoP completo em TypeScript. Usamos a Web Crypto API pra geração de chaves e a biblioteca jose pra operações JWT.
Geração do Par de Chaves
// 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> { // Gerar par de chaves EC P-256 não-extraível const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // não-extraível: chave privada não pode ser exportada ['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, }; }
O parâmetro false no generateKey é crucial: marca a chave privada como não-extraível. Nem código JavaScript rodando no mesmo contexto consegue ler o material bruto da chave. A chave existe só dentro do motor criptográfico do navegador.
Criação de DPoP Proofs
interface DPoPProofOptions { keyPair: DPoPKeyPair; method: string; url: string; accessToken?: string; // Obrigatório pra requests ao resource server nonce?: string; // Nonce fornecido pelo 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), }; // Adicionar hash do access token pra requests ao 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); } // Adicionar nonce fornecido pelo servidor se disponível 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(/=+$/, ''); }
O Cliente HTTP com DPoP
Aqui tá a peça crucial — um wrapper de cliente HTTP que cuida do ciclo de vida completo do DPoP incluindo retries 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 não 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 }); // Tratar 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); // Retry com nonce armazenado } } } return response; } }
Request de Token com 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(`Falha no request de token: ${response.status}`); } return response.json(); }
Note que o scheme de Authorization muda de Bearer pra DPoP — isso sinaliza pro resource server que ele deve esperar e validar um DPoP proof.
Implementação Completa: Lado do Servidor
O lado do servidor tem duas responsabilidades: vincular tokens a chaves durante a emissão, e validar proofs durante o acesso a recursos.
Emissão 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 SEM verificar ainda (pra extrair a chave 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: deve ser dpop+jwt'); } if (!header.jwk) { throw new Error('jwk ausente no header'); } if (header.alg === 'none' || header.alg.startsWith('HS')) { throw new Error('Algoritmos simétricos não são permitidos pra DPoP'); } // 3. Importar chave pública e verificar assinatura 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 e URL if (claims.htm !== expectedMethod) { throw new Error(`htm mismatch: esperado ${expectedMethod}, recebido ${claims.htm}`); } if (claims.htu !== expectedUrl) { throw new Error(`htu mismatch: esperado ${expectedUrl}, recebido ${claims.htu}`); } // 5. Validar unicidade do jti (verificar contra cache de replay) const isReplay = await checkAndStoreJti(claims.jti, 300); if (isReplay) { throw new Error('Replay de DPoP proof detectado'); } // 6. Validar nonce se requerido if (expectedNonce && claims.nonce !== expectedNonce) { throw new Error('Nonce DPoP inválido ou ausente'); } // 7. Validar hash do access token (pra requests ao resource server) if (accessToken) { const expectedAth = await computeAth(accessToken); if (claims.ath !== expectedAth) { throw new Error('Hash do access token não confere'); } } // 8. Calcular thumbprint do JWK pro 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)); } // Cache de replay JTI baseado em 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 com Claim de Confirmação
Quando o authorization server emite um token vinculado a DPoP, inclui um claim cnf (confirmation) com o thumbprint do 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, // Confirmação 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; }
Validação no Resource Server
// middleware/dpop-validator.ts import { jwtVerify, decodeJwt } from 'jose'; async function validateDPoPRequest(req: Request): Promise<void> { // 1. Extrair o DPoP proof dos headers const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { throw new DPoPError(401, 'Header de DPoP proof ausente'); } // 2. Extrair o access token const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('DPoP ')) { throw new DPoPError(401, 'Scheme de autorização inválido, esperado DPoP'); } const accessToken = authHeader.slice(5); // 3. Decodificar o token pra pegar o thumbprint vinculado const tokenClaims = decodeJwt(accessToken); const boundThumbprint = (tokenClaims.cnf as { jkt: string })?.jkt; if (!boundThumbprint) { throw new DPoPError(401, 'Token não vinculado a DPoP (cnf.jkt ausente)'); } // 4. Validar o 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 o proof foi assinado com a chave vinculada ao token if (thumbprint !== boundThumbprint) { throw new DPoPError(401, 'Chave do DPoP proof não confere com o binding do token'); } } class DPoPError extends Error { constructor( public status: number, message: string ) { super(message); } }
Gestão de Nonces pra Proteção contra Replay
O claim jti provê unicidade do lado do cliente, mas um atacante com acesso ao proof em trânsito poderia fazer replay dentro da janela de tempo. Nonces fornecidos pelo servidor adicionam uma segunda camada:
Como os Nonces Funcionam
Fluxo de Nonce Challenge:
Cliente → Resource Server: DPoP proof (sem 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")
...continua, nonce rotaciona com cada resposta...
Implementação de Nonces no 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 { // Aceitar nonce atual ou anterior (período de graça durante rotação) return nonce === this.currentNonce || nonce === this.previousNonce; } } // Middleware Express function dpopNonceMiddleware(nonceManager: NonceManager) { return (req: Request, res: Response, next: NextFunction) => { // Sempre incluir o nonce atual nas respostas res.setHeader('DPoP-Nonce', nonceManager.getCurrent()); next(); }; }
Regras Importantes sobre Nonces
-
Nonces são por servidor. Nonces do authorization server e do resource server são completamente separados. Nunca reutilize um nonce de um servidor ao falar com outro.
-
Nonces são opacos. Clientes não devem parsear, decodificar nem interpretar valores de nonce. Trate como strings opacas.
-
Aceite o nonce anterior. Durante a rotação, aceite tanto o nonce atual quanto o imediatamente anterior pra não quebrar requests em voo.
-
Sempre envie o header de nonce. Inclua
DPoP-Nonceem toda resposta, mesmo as com sucesso. Assim clientes podem pré-carregar o nonce pro próximo request sem um roundtrip com falha.
Estratégias de Armazenamento de Chaves
A segurança do DPoP depende completamente da chave privada permanecer não-extraível. Plataformas diferentes precisam de estratégias diferentes:
Navegador (SPA)
// Usar IndexedDB pra armazenamento persistente de chaves async function persistKeyPair(keyPair: CryptoKeyPair): Promise<void> { const db = await openDB('dpop-keys', 1, { upgrade(db) { db.createObjectStore('keys'); }, }); // Objetos CryptoKey podem ser armazenados direto no IndexedDB // Mantêm a propriedade non-extractable mesmo quando persistidos 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'); }
Ponto chave: objetos CryptoKey armazenados no IndexedDB mantêm a propriedade extractable: false. O material bruto da chave nunca é exposto ao JavaScript, nem entre recargas de página.
Mobile (React Native / Nativo)
| Plataforma | Armazenamento | Nível de Segurança |
|---|---|---|
| iOS | Secure Enclave (Keychain) | Baseado em hardware, à prova de violação |
| Android | Android Keystore (StrongBox se disponível) | Baseado em hardware em dispositivos suportados |
| React Native | react-native-keychain + backing específico de plataforma | Depende da plataforma subjacente |
Server-to-Server
Pra serviços backend, o par de chaves pode ser carregado de variáveis de ambiente ou um serviço de gerenciamento de chaves (KMS):
// Usar variável de ambiente ou KMS const privateKey = await importPKCS8( process.env.DPOP_PRIVATE_KEY!, 'ES256' );
Migração: De Bearer pra DPoP
Não dá pra apertar um botão e exigir DPoP de todos os clientes da noite pro dia. Bora por fases:
Fase 1: Suporte Dual
Aceitar tanto tokens bearer quanto DPoP. Clientes novos usam DPoP; os existentes continuam com bearer.
async function validateRequest(req: Request): Promise<TokenClaims> { const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('DPoP ')) { // Fluxo DPoP: validar proof + token binding await validateDPoPRequest(req); return decodeAndVerifyToken(authHeader.slice(5)); } if (authHeader?.startsWith('Bearer ')) { // Fluxo bearer legado: ainda aceito metrics.increment('auth.bearer.used'); // Trackear pra deprecação return decodeAndVerifyToken(authHeader.slice(7)); } throw new Error('Header Authorization ausente ou inválido'); }
Fase 2: DPoP Preferido
Emitir tokens vinculados a DPoP por padrão. Logar uso de bearer tokens pra monitoramento.
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (dpopProof) { // Cliente suporta 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 legado: emitir bearer token com aviso de deprecação const token = await issueBearerToken(userId, scopes); res.setHeader('Deprecation', 'true'); res.json({ access_token: token, token_type: 'bearer' }); } });
Fase 3: DPoP Obrigatório
Após monitoramento confirmar baixo uso de bearer, enforçar DPoP pra todos os 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 obrigatório. Bearer tokens não são mais aceitos.', }); } 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 Migração
| Métrica | Objetivo | O Que Diz |
|---|---|---|
| Taxa de adoção DPoP | >90% antes da Fase 3 | Progresso geral da migração |
| Uso de bearer tokens | Caindo pra <5% | Se clientes legados tão atualizando |
| Taxa de retry de nonce | <10% dos requests | Se a rotação de nonces tá agressiva demais |
| Falhas de validação de proof | <0.1% | Clock skew ou bugs de implementação |
| Eventos de rotação de chaves | Trackeados por cliente | Saúde do ciclo de vida de chaves |
DPoP vs. Outras Abordagens de Token Binding
| Característica | Bearer Token | DPoP (RFC 9449) | mTLS (RFC 8705) | Token Binding (RFC 8471) |
|---|---|---|---|---|
| Proteção contra roubo de token | ❌ Nenhuma | ✅ Binding criptográfico | ✅ Binding TLS | ✅ Binding TLS |
| Suporte em navegadores | ✅ Universal | ✅ Web Crypto API | ❌ Sem UI de cert de cliente | ❌ Abandonado por navegadores |
| Suporte mobile | ✅ Universal | ✅ Crypto de plataforma | ⚠️ Gestão complexa de certs | ❌ Não implementado |
| Complexidade de implementação | ⭐ Simples | ⭐⭐ Moderada | ⭐⭐⭐ Alta | N/A (Morto) |
| Overhead de performance | Nenhum | ~2ms por request (assinatura) | Custo de TLS handshake | N/A |
| Compatível com CDN/proxy | ✅ Sim | ✅ Sim (camada de aplicação) | ⚠️ Problemas de terminação TLS | ❌ Terminação TLS quebra |
| Maturidade do padrão | RFC 6750 (2012) | RFC 9449 (2023) | RFC 8705 (2020) | Abandonado |
DPoP ganha pra web e mobile porque opera na camada de aplicação. Não precisa de configuração especial de TLS, não precisa de certificados de cliente, não tem mudanças na UI do navegador. Só criptografia em JavaScript.
Checklist de Segurança pra Produção
Lado do Cliente
-
Use chaves não-extraíveis. Sempre use
extractable: falsenocrypto.subtle.generateKey(). Isso previne que qualquer código JavaScript — incluindo payloads XSS injetados — consiga ler a chave privada bruta. -
Rotacione chaves periodicamente. Gere um novo par de chaves quando usuários reautenticarem ou sessões começarem. Bindings de tokens antigos são invalidados automaticamente.
-
Use IndexedDB, não localStorage. Objetos
CryptoKeysó podem ser armazenados no IndexedDB. localStorage só armazena strings, o que exigiria extrair a chave — anulando o propósito. -
Trate clock skew. O claim
iatdeve estar dentro da janela de tolerância do servidor. Validação server-side deve aceitar ±60 segundos de desfasagem.
Lado do Servidor
-
Mantenha um cache de replay de JTI. Use Redis ou similar com TTL correspondente à sua janela de aceitação de proofs. Sem isso, proofs podem ser replicados dentro da janela de tempo.
-
Valide tudo. Cheque
typ,alg(rejeite algoritmos simétricos),htm,htu,iat,jti,athenonce. Pular qualquer passo de validação cria um bypass. -
Use nonces em contextos de alta segurança. Pra APIs financeiras ou compliance FAPI 2.0, nonces fornecidos pelo servidor são mandatórios. Pra APIs gerais, adicionam segurança mas aumentam latência em um roundtrip no primeiro request.
-
Retorne nonces em toda resposta. Não obrigue clientes a falharem antes de aprender o nonce. Inclua o header
DPoP-Nonceem todas as respostas pra que clientes possam pré-carregar. -
Rejeite
alg: nonee algoritmos simétricos. O DPoP proof DEVE usar algoritmo assimétrico. AceitarHS256anula o propósito inteiro — significaria que servidor e cliente compartilham um segredo, que é exatamente o que DPoP foi feito pra evitar.
Modelo de Performance e Custo
DPoP adiciona operações criptográficas a cada request. O impacto real é esse:
| Operação | Tempo (P50) | Tempo (P99) | Notas |
|---|---|---|---|
| Geração de chaves (EC P-256) | 0.5ms | 2ms | Uma vez por sessão |
| Assinatura do proof (ECDSA) | 0.8ms | 2.5ms | Cada request |
| Verificação do proof (servidor) | 0.3ms | 1ms | Cada request |
| Lookup de cache JTI (Redis) | 0.1ms | 0.5ms | Cada request |
| Cálculo de thumbprint | 0.1ms | 0.3ms | Na emissão do token |
Overhead total por request: ~1.3ms no cliente, ~0.5ms no servidor. Pra comparação, uma query típica ao banco leva 5-50ms. O overhead do DPoP é nível ruído.
O trade-off é claro: ~2ms de latência adicional por request eliminam toda a classe de ataques de roubo de tokens.
A Realidade de 2026
DPoP não é um padrão futuro, é uma exigência presente. A RFC 9449 foi publicada em setembro de 2023 e se tornou um dos dois mecanismos de sender-constraint requeridos pra compliance FAPI 2.0 em serviços financeiros. Auth0 e Okta oferecem suporte de primeira classe pra DPoP, e Microsoft Entra ID oferece seu próprio binding de Proof-of-Possession. A WebCrypto API tá disponível em todo navegador principal.
A barreira real é a inércia. Times que construíram sistemas de autenticação inteiros em cima de bearer tokens hesitam em adicionar a complexidade de proofs criptográficos. Mas a implementação não é complexa — é um par de chaves, um JWT por request e middleware de validação. A biblioteca jose cuida do trabalho pesado. O wrapper de fetch com DPoP que vimos lá em cima tem menos de 50 linhas de código.
Bearer tokens foram projetados pra simplicidade numa era quando XSS era menos prevalente, ataques de supply chain eram raros, e arquiteturas de API eram mais simples. Essa era acabou. Cada token que você emite como bearer simples é um token que pode ser roubado e replicado com fricção zero.
Comece pelos seus endpoints de API de maior valor — autenticação, billing, operações de admin. Adicione suporte DPoP junto com bearer tokens. Monitore quais clientes atualizam. Quando a cobertura for alta o suficiente, deprece bearer tokens por completo.
Seus tokens devem provar quem tá segurando eles. DPoP torna isso possível com algumas centenas de linhas de código e latência desprezível. O único custo de não fazer é esperar pela brecha.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit