Pular para o conteúdo
ecdsa.com
← Todos os artigos

DPoP explicado: tokens OAuth vinculados à chave do cliente

Um bearer token funciona para quem estiver com ele. Um token vinculado por DPoP só funciona junto com uma assinatura nova feita com a chave privada do cliente, então um token copiado, sozinho, não serve para nada. Veja o protocolo, o código do cliente e do servidor e o que um servidor de recursos rejeitou quando tokens emitidos pelo Keycloak e as respectivas provas foram roubados, reenviados e reutilizados.

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

  1. 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.
  2. Requisição de token. O cliente envia a requisição de token normal com um cabeçalho DPoP que contém uma prova para POST e para a URL do endpoint de token. Se o servidor de autorização quiser um nonce, ele responde 400 com use_dpop_nonce e um cabeçalho DPoP-Nonce, e o cliente tenta de novo com esse nonce na prova.
  3. Resposta de token. O token_type é DPoP, e não Bearer. 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).
  4. 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 o ath, o hash SHA-256 em base64url do token de acesso.
  5. O servidor de recursos verifica a prova e a vinculação e pode, ele próprio, exigir um nonce com 401 e WWW-Authenticate: DPoP error="use_dpop_nonce" (§9).

A prova contém estes membros (§4.2):

OndeMembroSignificado
cabeçalhotypdpop+jwt, para que uma prova nunca seja confundida com outro JWT
cabeçalhoalgsomente assimétrico; nunca none nem HS*
cabeçalhojwka chave pública do cliente; nunca uma chave privada
payloadjtiúnico por prova: pelo menos 96 bits aleatórios ou um UUID v4
payloadhtm, htumétodo HTTP e URI de destino, sem a query string e sem o fragmento
payloadiathorário de criação
payloadathhash do token de acesso, obrigatório quando um token é apresentado
payloadnonceo DPoP-Nonce mais recente do servidor, quando ele tiver enviado um

Cliente: como criar provas

dpop-client.mjs (jose 6.2.13, Node.js 24.22)
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:

dpop-verify.mjs (jose 6.2.13)
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çalho Host. 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:

Saída: Keycloak 26.8.1 e o servidor de recursos acima (node:24.22.0-bookworm-slim)
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 DPoP e um cnf.jkt igual à impressão digital da chave do cliente. Ele também acrescenta um membro fora do padrão, kc-jkt-type, ao lado de jkt; 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 para GET não funcionou para DELETE e uma prova criada dois minutos antes já era antiga demais.
  • O ath prende 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; o dpopFetch precisou 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:

as-replay.mjs
// 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()}`);
Saída
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 400 e "error": "use_dpop_nonce"; servidores de recursos, com 401 e WWW-Authenticate: DPoP error="use_dpop_nonce". Ambos enviam o valor em DPoP-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-Nonce e WWW-Authenticate se o servidor listar esses cabeçalhos em Access-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çãoDPoP?
Aplicação de página única ou aplicativo móvel que guarda tokens de atualizaçãoSim: 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á implantadoA vinculação por mTLS (RFC 8705) é a opção mais simples
Aplicação web confidencial que nunca expõe tokens ao navegadorOpcional; 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 BearerLimitado: 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

  1. Verificar a prova, mas não a vinculação. Uma prova assinada por qualquer chave é válida por si só; só o cnf.jkt a liga ao token.
  2. Aceitar o token também como Bearer. Aí o atacante simplesmente usa o esquema Bearer.
  3. Comparar o htu com a query string ou com uma URL interna. O htu exclui a query string e o fragmento e precisa corresponder à URL pública após a normalização.
  4. Nenhum registro de provas já usadas e nenhum nonce. Aí uma prova copiada volta a funcionar durante toda a janela do iat.
  5. Uma chave nova a cada requisição. A chave precisa continuar a mesma enquanto os tokens vinculados a ela estiverem em uso.
  6. Um alg simétrico ou uma chave privada no jwk. Ambos são proibidos pelo §4.2; o EmbeddedJWK do jose recusa o segundo caso.

Fontes