Resposta curta. O DPoP (Demonstrating Proof of Possession, ou demonstração de prova de posse, RFC 9449, setembro de 2023) vincula um token de acesso a um par de chaves que o cliente gera. O servidor de autorização registra no token a impressão digital (thumbprint) da chave pública (cnf.jkt) e responde com token_type: DPoP. Em cada requisição, o cliente envia o token com o esquema DPoP e mais um cabeçalho DPoP: um JWT curto, assinado com a chave privada, que informa o método HTTP (htm), a URL (htu), um jti único, o horário (iat) e o hash do token de acesso (ath). O servidor de recursos verifica a prova, rejeita valores de jti reutilizados e provas antigas e confere se a chave da prova é aquela à qual o token está vinculado. Use o DPoP quando tokens de acesso ou tokens de atualização ficarem em lugares de onde possam ser copiados, como navegadores e aplicativos móveis, ou quando um perfil como o FAPI 2.0 exigir tokens restritos ao remetente.
Que problema o DPoP resolve
Um bearer token do OAuth (RFC 6750) é como dinheiro em espécie: quem o apresenta obtém acesso. Tokens vazam por logs, pelo armazenamento do navegador, por proxies, por relatórios de falha e por dependências comprometidas, e um bearer token vazado funciona de qualquer lugar até expirar. Tokens restritos ao remetente acrescentam um segundo fator, que não viaja junto com o token. O OAuth tem duas formas padronizadas de fazer isso: o TLS mútuo (RFC 8705), que vincula o token a um certificado de cliente, e o DPoP, que o vincula a uma chave usada na camada de aplicação. O DPoP dispensa certificados de cliente TLS, funciona a partir de um navegador e através de proxies que terminam o TLS e custa uma assinatura por requisição.
O que o DPoP não faz: ele não assina o corpo nem os cabeçalhos da requisição, apenas o método e a URL (§4.2), e não ajuda se um atacante conseguir executar código dentro do cliente, porque esse código pode pedir à chave do cliente que assine provas (§11.4). Para assinar requisições inteiras, veja assinaturas de mensagens HTTP.
O fluxo
- O cliente gera um par de chaves, de preferência não extraível, e o mantém durante toda a vida útil dos seus tokens.
- Requisição de token. O cliente envia a requisição de token normal com um cabeçalho
DPoPque contém uma prova paraPOSTe para a URL do endpoint de token. Se o servidor de autorização quiser um nonce, ele responde400comuse_dpop_noncee um cabeçalhoDPoP-Nonce, e o cliente tenta de novo com esse nonce na prova. - Resposta de token. O
token_typeéDPoP, e nãoBearer. Um token de acesso JWT traz"cnf": {"jkt": "<SHA-256 JWK thumbprint>"}(§6.1, RFC 7638); um token opaco expõe o mesmo valor pela introspecção (§6.2). Tokens de atualização emitidos para clientes públicos também ficam vinculados à chave (§5). - Requisição de recurso. O cliente envia
Authorization: DPoP <access token>junto com uma nova prova para este método e esta URL, que agora inclui oath, o hash SHA-256 em base64url do token de acesso. - O servidor de recursos verifica a prova e a vinculação e pode, ele próprio, exigir um nonce com
401eWWW-Authenticate: DPoP error="use_dpop_nonce"(§9).
A prova contém estes membros (§4.2):
| Onde | Membro | Significado |
|---|---|---|
| cabeçalho | typ | dpop+jwt, para que uma prova nunca seja confundida com outro JWT |
| cabeçalho | alg | somente assimétrico; nunca none nem HS* |
| cabeçalho | jwk | a chave pública do cliente; nunca uma chave privada |
| payload | jti | único por prova: pelo menos 96 bits aleatórios ou um UUID v4 |
| payload | htm, htu | método HTTP e URI de destino, sem a query string e sem o fragmento |
| payload | iat | horário de criação |
| payload | ath | hash do token de acesso, obrigatório quando um token é apresentado |
| payload | nonce | o DPoP-Nonce mais recente do servidor, quando ele tiver enviado um |
Cliente: como criar provas
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);
export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");
/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
const target = new URL(url);
return new SignJWT({
jti: randomUUID(),
htm: method,
htu: target.origin + target.pathname, // without query and fragment
...(accessToken && { ath: ath(accessToken) }),
...(nonce && { nonce }),
})
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
.setIssuedAt()
.sign(privateKey);
}
/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
for (let attempt = 0; attempt < 2; attempt++) {
const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
const res = await fetch(url, {
method,
body,
headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
});
if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
const challenge = res.headers.get("www-authenticate") ?? "";
const bodyText = res.status === 400 ? await res.clone().text() : "";
const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
if (!askedForNonce || attempt === 1) return res;
}
}
No navegador, a mesma abordagem funciona com a Web Crypto API: uma chave gerada com extractable: false pode ser guardada no IndexedDB e usada para assinar, mas nenhum script consegue ler a metade privada dela. Isso protege contra o roubo da chave, mas não contra um script malicioso na página que peça a ela uma assinatura.
Servidor de recursos: como verificar provas
A RFC 9449 §4.3 lista doze verificações, que “podem ser feitas em qualquer ordem”, e o §7 acrescenta a vinculação ao token. O verificador abaixo as implementa para uma requisição, com o número de cada passo nos comentários:
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";
const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead
export class DpopError extends Error {
constructor(error, description, headers = {}) {
super(description);
this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
this.headers = headers;
}
}
const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
return u.origin + u.pathname;
};
/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
const jwks = createRemoteJWKSet(new URL(jwksUri));
const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production
return async function verify(req) {
// §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
throw new DpopError("invalid_token", "DPoP authorization scheme required");
}
// Steps 1-2: exactly one DPoP header holding one JWT.
const proofs = req.headersDistinct.dpop ?? [];
if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
// Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
// public key (EmbeddedJWK refuses private keys), and iat within the window.
let proof;
try {
proof = await jwtVerify(proofs[0], EmbeddedJWK, {
typ: "dpop+jwt",
algorithms: PROOF_ALGS,
requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
maxTokenAge: MAX_PROOF_AGE,
clockTolerance: SKEW,
});
} catch (e) {
throw new DpopError("invalid_dpop_proof", e.message);
}
const { payload: p, protectedHeader } = proof;
// Steps 8-9: the proof was made for this method and this URI.
const target = new URL(req.url, publicOrigin);
if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
}
// Step 10: the server's current nonce, if this server hands them out.
if (requireNonce && p.nonce !== nonce) {
throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
}
// Replay: each jti once per target URI while the proof could still be accepted.
const key = `${p.htu} ${p.jti}`;
const now = Date.now();
for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
// The access token itself: issuer, audience, signature, expiry.
let token;
try {
({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
} catch (e) {
throw new DpopError("invalid_token", e.message);
}
// Step 12: the proof belongs to this token, and the token is bound to the proof's key.
if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
throw new DpopError("invalid_token", "Invalid DPoP key binding");
}
return token;
};
}
/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
const description = err.message.replaceAll('"', "'");
return {
status: 401,
headers: {
"WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
...err.headers,
},
};
}
Três detalhes importam mais do que parece:
publicOriginé configuração, não o cabeçalhoHost. Atrás de um balanceador de carga, o servidor vê uma URL interna. Reconstruir a URL a partir dos cabeçalhos da requisição ou faz todas as requisições falharem, ou confia em um cabeçalho que o cliente controla.- O registro de provas já usadas vale por URI de destino e só pelo tempo da janela da prova. Com várias instâncias atrás de um mesmo endpoint, esse registro precisa ser compartilhado, o que, como a própria RFC 9449 admite, “nem sempre é viável”; nonces de servidor de curta duração são a alternativa.
- Rejeite um token vinculado por DPoP que chegue como
Bearer(§7.2). Caso contrário, a vinculação fica opcional para quem roubou o token.
Testado com tokens emitidos pelo Keycloak
O laboratório rodou o Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, modo dev) com um cliente confidencial, orders-cli, configurado para exigir tokens de acesso vinculados por DPoP (o atributo de cliente dpop.bound.access.tokens), e um mapper do tipo Audience que adiciona orders-api ao público-alvo. Um script de teste pediu tokens com provas do dpop-client.mjs, subiu na porta 4000 um servidor de recursos baseado no dpop-verify.mjs, com nonces ativados, e depois tentou o que um atacante com um token copiado ou uma requisição copiada tentaria:
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet 401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce 200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests) 200 {"orders":[],"client":"orders-cli"}
same proof replayed 401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer 401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key 401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE 401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token 401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago 401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"
O que a execução mostra:
- A vinculação é real. O Keycloak retornou
token_type DPoPe umcnf.jktigual à impressão digital da chave do cliente. Ele também acrescenta um membro fora do padrão,kc-jkt-type, ao lado dejkt; servidores de recursos devem ignorar os membros que não conhecem, como faz o verificador. - Um token roubado não basta. Enviado como
Bearer, ele foi recusado; enviado com uma prova válida feita com a chave do próprio atacante, falhou na verificação da vinculação. Uma requisição copiada também falhou: a mesma prova não pôde ser reenviada, uma prova paraGETnão funcionou paraDELETEe uma prova criada dois minutos antes já era antiga demais. - O
athprende a prova a um único token. Uma prova válida feita para outro token de acesso do mesmo cliente, com a mesma chave, foi rejeitada. - Nonces custam uma ida e volta. A primeira requisição sem nonce recebeu
401 use_dpop_nonce; odpopFetchprecisou de 2 requisições na primeira chamada e de 1 nas seguintes.
O endpoint de token do Keycloak também verifica as provas. Um segundo script curto, que reaproveita o módulo do cliente:
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";
const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
fetch(token_endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
DPoP: proof,
},
body: "grant_type=client_credentials",
});
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
const res = await send(proof);
const body = await res.json();
console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
first use of a proof 200 DPoP
same proof again 400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI 400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}
Um desvio que convém conhecer: o §5 da RFC 9449 diz que uma prova inválida no endpoint de token recebe o código de erro invalid_dpop_proof, mas o Keycloak 26.8.1 respondeu invalid_request para todos os problemas de prova acima. Escreva clientes que só tomem decisões com base em use_dpop_nonce e trate os demais erros de uma requisição de token DPoP como falhas a relatar, e não como códigos a comparar.
Nonces: por que os servidores pedem
Sem um nonce do servidor, saber se uma prova é recente depende do iat, um valor que o cliente escolhe. Quem controla o cliente, inclusive o próprio usuário legítimo, pode assinar provas com datas no futuro e usá-las mais tarde, a partir de uma máquina que não tem a chave (§11.2, “pré-geração”). Um nonce fornecido pelo servidor, imprevisível e rotacionado por ele, faz as provas expirarem pelo relógio do servidor. Ele também tira a defasagem de relógio da equação: o servidor pode codificar no nonce o próprio horário em vez de confiar no iat (§11.1).
Regras do §8 e do §9:
- Servidores de autorização sinalizam com
400e"error": "use_dpop_nonce"; servidores de recursos, com401eWWW-Authenticate: DPoP error="use_dpop_nonce". Ambos enviam o valor emDPoP-Nonce. - Depois que um servidor envia um nonce, ele não deve aceitar provas sem nonce (§11.3).
- Um servidor pode enviar o próximo nonce em uma resposta bem-sucedida, o que economiza a ida e volta extra; convém que essas respostas não possam ser armazenadas em cache.
- Clientes no navegador só enxergam
DPoP-NonceeWWW-Authenticatese o servidor listar esses cabeçalhos emAccess-Control-Expose-Headers. Sem isso, uma aplicação de página única não consegue ler o nonce que o servidor pede que ela use.
O servidor de autorização da Okta, por exemplo, responde à primeira requisição de token DPoP com “Authorization server requires nonce in DPoP proof” (guia da Okta), então os clientes precisam da nova tentativa desde o primeiro dia.
Vinculação do código de autorização: dpop_jkt
O DPoP vincula tokens no endpoint de token, mas o código de autorização é emitido antes, por meio do navegador. Com dpop_jkt na requisição de autorização (§10), o cliente anuncia a impressão digital da chave que vai usar, e o servidor de autorização rejeita uma requisição de token cuja prova use outra chave. Junto com o PKCE, isso protege o fluxo de ponta a ponta, desde que o cliente use uma chave nova a cada requisição de autorização. O FAPI 2.0 exige que servidores de autorização que usam DPoP ofereçam suporte a essa vinculação, mas deixa o uso dela opcional para os clientes (FAPI 2.0 Security Profile §5.3.2.1 e §5.3.3.1).
Quando usar o DPoP
| Situação | DPoP? |
|---|---|
| Aplicação de página única ou aplicativo móvel que guarda tokens de atualização | Sim: tokens de atualização emitidos para clientes públicos ficam vinculados à chave, e é aí que o DPoP mais ajuda |
| FAPI 2.0 (open banking e similares) | Obrigatório na prática: o FAPI 2.0 exige tokens restritos ao remetente via mTLS ou DPoP |
| Servidor a servidor com TLS mútuo já implantado | A vinculação por mTLS (RFC 8705) é a opção mais simples |
| Aplicação web confidencial que nunca expõe tokens ao navegador | Opcional; os tokens ficam no lado do servidor, e os tokens de atualização dela já estão vinculados à autenticação do cliente |
Servidores de recursos que você não controla e que só aceitam Bearer | Limitado: ali o token só pode ser usado como bearer token, sem a proteção (§7.2) |
O Keycloak (testado acima), a Okta e a Auth0 (documentação) documentam o DPoP nos seus endpoints de token, e os servidores anunciam os algoritmos de prova aceitos como dpop_signing_alg_values_supported nos seus metadados (§5.1). A lacuna costuma estar do lado do servidor de recursos, e é por isso que o verificador acima importa.
Erros comuns
- Verificar a prova, mas não a vinculação. Uma prova assinada por qualquer chave é válida por si só; só o
cnf.jkta liga ao token. - Aceitar o token também como
Bearer. Aí o atacante simplesmente usa o esquema Bearer. - Comparar o
htucom a query string ou com uma URL interna. Ohtuexclui a query string e o fragmento e precisa corresponder à URL pública após a normalização. - Nenhum registro de provas já usadas e nenhum nonce. Aí uma prova copiada volta a funcionar durante toda a janela do
iat. - Uma chave nova a cada requisição. A chave precisa continuar a mesma enquanto os tokens vinculados a ela estiverem em uso.
- Um
algsimétrico ou uma chave privada nojwk. Ambos são proibidos pelo §4.2; oEmbeddedJWKdo jose recusa o segundo caso.
Fontes
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), do §4 ao §11
- RFC 7638: JSON Web Key (JWK) Thumbprint
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 6750: Bearer Token Usage
- FAPI 2.0 Security Profile, §5.3
- Okta: configurar o DPoP do OAuth 2.0 e Auth0: DPoP
- jose:
EmbeddedJWK,calculateJwkThumbprint