Saltar al contenido
ecdsa.com
← Todos los artículos

DPoP: cómo vincular tokens OAuth a una clave del cliente

Un token de portador sirve a quien lo tenga. Un token vinculado con DPoP solo funciona junto con una firma reciente hecha con la clave privada del cliente, así que un token copiado no sirve de nada por sí solo. Aquí tienes el protocolo, el código del cliente y del servidor, y lo que rechazó un servidor de recursos cuando se robaron, repitieron y reutilizaron tokens y pruebas emitidos por Keycloak.

Respuesta breve. DPoP (Demonstrating Proof of Possession, es decir, demostración de prueba de posesión; RFC 9449, septiembre de 2023) vincula un token de acceso a un par de claves que genera el cliente. El servidor de autorización registra en el token la huella digital de la clave pública (cnf.jkt) y responde con token_type: DPoP. En cada solicitud, el cliente envía el token con el esquema DPoP y, además, un encabezado DPoP: un JWT breve, firmado con la clave privada, que indica el método HTTP (htm), la URL (htu), un jti único, la hora (iat) y el hash del token de acceso (ath). El servidor de recursos verifica la prueba, rechaza los valores de jti reutilizados y las pruebas antiguas, y comprueba que la clave de la prueba sea aquella a la que está vinculado el token. Úsalo cuando los tokens de acceso o los tokens de actualización se guarden en un lugar del que puedan copiarse, como los navegadores y las aplicaciones móviles, o cuando un perfil como FAPI 2.0 exija tokens vinculados al remitente.

Qué problema resuelve DPoP

Un token de portador de OAuth (RFC 6750) es como el dinero en efectivo: quien lo presenta obtiene acceso. Los tokens se filtran a través de registros, el almacenamiento del navegador, proxies, informes de fallos y dependencias comprometidas, y un token de portador filtrado funciona desde cualquier lugar hasta que caduca. Los tokens vinculados al remitente añaden un segundo factor que no viaja con el token. OAuth tiene dos formas estándar de hacerlo: TLS mutuo (RFC 8705), que vincula el token a un certificado de cliente, y DPoP, que lo vincula a una clave que se usa en la capa de aplicación. DPoP no necesita certificados de cliente TLS, funciona desde un navegador y a través de proxies que terminan TLS, y cuesta una firma por solicitud.

Lo que DPoP no hace: no firma el cuerpo ni los encabezados de la solicitud, solo el método y la URL (§4.2), y no ayuda si un atacante puede ejecutar código dentro del cliente, porque ese código puede pedir a la clave del cliente que firme pruebas (§11.4). Para firmar solicitudes completas, consulta firmas de mensajes HTTP.

El flujo

  1. El cliente genera un par de claves, a ser posible no extraíble, y lo conserva durante toda la vida de sus tokens.
  2. Solicitud de token. El cliente envía su solicitud de token habitual con un encabezado DPoP que contiene una prueba para POST y la URL del endpoint de token. Si el servidor de autorización quiere un nonce, responde 400 con use_dpop_nonce y un encabezado DPoP-Nonce, y el cliente reintenta con ese nonce en la prueba.
  3. Respuesta de token. token_type es DPoP, no Bearer. Un token de acceso JWT lleva "cnf": {"jkt": "<SHA-256 JWK thumbprint>"} (§6.1, RFC 7638); un token opaco expone el mismo valor mediante introspección (§6.2). Los tokens de actualización emitidos a clientes públicos también quedan vinculados a la clave (§5).
  4. Solicitud al recurso. Authorization: DPoP <access token> más una prueba nueva para este método y esta URL, que ahora incluye ath, el hash SHA-256 en base64url del token de acceso.
  5. El servidor de recursos comprueba la prueba y la vinculación, y puede exigir por su cuenta un nonce con 401 y WWW-Authenticate: DPoP error="use_dpop_nonce" (§9).

La prueba lleva estos miembros (§4.2):

DóndeMiembroSignificado
encabezadotypdpop+jwt, para que una prueba nunca pueda confundirse con otro JWT
encabezadoalgsolo asimétrico; nunca none ni HS*
encabezadojwkla clave pública del cliente; nunca una clave privada
carga útiljtiúnico por prueba: al menos 96 bits aleatorios o un UUID v4
carga útilhtm, htumétodo HTTP y URI de destino sin la consulta ni el fragmento
carga útiliatmomento de creación
carga útilathhash del token de acceso, obligatorio cuando se presenta uno
carga útilnonceel último DPoP-Nonce del servidor, si envió uno

Cliente: generación de pruebas

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;
  }
}

En un navegador, el mismo enfoque funciona con la Web Crypto API: una clave generada con extractable: false puede guardarse en IndexedDB y usarse para firmar, pero ningún script puede leer su parte privada. Eso protege contra el robo de la clave, no contra un script malicioso en la página que le pida firmar.

Servidor de recursos: comprobación de pruebas

El RFC 9449 §4.3 enumera doce comprobaciones, que «pueden realizarse en cualquier orden», y el §7 añade la vinculación del token. El verificador siguiente las implementa para una solicitud, con los números de paso en los comentarios:

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,
    },
  };
}

Tres detalles importan más de lo que parece:

  • publicOrigin es configuración, no el encabezado Host. Detrás de un balanceador de carga, el servidor ve una URL interna. Reconstruir la URL a partir de los encabezados de la solicitud o bien hace fallar todas las solicitudes, o bien confía en un encabezado que controla el cliente.
  • El registro de pruebas usadas es por URI de destino y guarda cada entrada solo mientras dura la ventana de la prueba. Con varias instancias detrás de un mismo endpoint, ese registro tiene que ser compartido, algo que, como admite el RFC 9449, «puede no ser siempre factible»; la alternativa son los nonces del servidor de corta duración.
  • Rechaza un token vinculado con DPoP que llegue como Bearer (§7.2). Si no, la vinculación es opcional para quien haya robado el token.

Probado con tokens emitidos por Keycloak

El laboratorio ejecutó Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, modo de desarrollo) con un cliente confidencial, orders-cli, configurado para exigir tokens de acceso vinculados con DPoP (el atributo de cliente dpop.bound.access.tokens), y un mapper de audiencia que añade orders-api. Un script de control pidió tokens con pruebas de dpop-client.mjs, levantó en el puerto 4000 un servidor de recursos basado en dpop-verify.mjs con los nonces activados y después probó lo que probaría un atacante con un token copiado o una solicitud copiada:

Salida: Keycloak 26.8.1 y el servidor de recursos anterior (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"

Lo que muestra la ejecución:

  • La vinculación es real. Keycloak devolvió token_type DPoP y un cnf.jkt igual a la huella digital de la clave del cliente. También añade un miembro no estándar, kc-jkt-type, junto a jkt; los servidores de recursos deben ignorar los miembros que no conocen, como hace el verificador.
  • Un token robado no basta. Enviado como Bearer, se rechazó; enviado con una prueba válida hecha con la clave del propio atacante, no superó la comprobación de la vinculación. Una solicitud copiada también falló: la misma prueba no pudo repetirse, una prueba para GET no sirvió para DELETE y una prueba creada dos minutos antes era demasiado antigua.
  • ath ata una prueba a un solo token. Se rechazó una prueba válida hecha para otro token de acceso del mismo cliente y con la misma clave.
  • Los nonces cuestan un viaje de ida y vuelta. La primera solicitud sin nonce recibió 401 use_dpop_nonce; dpopFetch necesitó 2 solicitudes en su primera llamada y 1 en las siguientes.

El endpoint de token de Keycloak también valida las pruebas. Un segundo script corto, que reutiliza el módulo del 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()}`);
Salida
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"}

Una desviación que conviene conocer: el RFC 9449 §5 dice que una prueba no válida en el endpoint de token recibe el código de error invalid_dpop_proof, mientras que Keycloak 26.8.1 respondió invalid_request en todos los problemas de prueba anteriores. Escribe clientes que solo traten de forma específica use_dpop_nonce y que consideren los demás errores de una solicitud de token DPoP como fallos que hay que notificar, no como códigos con los que comparar.

Nonces: por qué los piden los servidores

Sin un nonce del servidor, la frescura de una prueba depende de iat, un valor que elige el cliente. Cualquiera que controle el cliente, incluido su usuario legítimo, puede firmar pruebas con fecha futura y usarlas más tarde desde un equipo que no tiene la clave (§11.2, generación anticipada o «pre-generation»). Un nonce que proporciona el servidor, impredecible y rotado por el propio servidor, hace que las pruebas caduquen según el reloj del servidor. Además, elimina el problema del desfase de reloj: el servidor puede codificar su propia hora en el nonce en lugar de fiarse de iat (§11.1).

Reglas de los §8 y §9:

  • Los servidores de autorización lo indican con 400 y "error": "use_dpop_nonce"; los servidores de recursos, con 401 y WWW-Authenticate: DPoP error="use_dpop_nonce". Ambos envían el valor en DPoP-Nonce.
  • Después de enviar un nonce, un servidor no debe aceptar pruebas sin nonce (§11.3).
  • Un servidor puede enviar el siguiente nonce en una respuesta correcta, lo que ahorra el viaje de ida y vuelta adicional; esas respuestas no deberían poder almacenarse en caché.
  • Los clientes de navegador solo ven DPoP-Nonce y WWW-Authenticate si el servidor los incluye en Access-Control-Expose-Headers. Sin eso, una aplicación de página única no puede leer el nonce que se le pide usar.

El servidor de autorización de Okta, por ejemplo, responde a la primera solicitud de token DPoP con «Authorization server requires nonce in DPoP proof» (guía de Okta), así que los clientes necesitan el reintento desde el primer día.

Vinculación del código de autorización: dpop_jkt

DPoP vincula los tokens en el endpoint de token, pero el código de autorización se emite antes, a través del navegador. Con dpop_jkt en la solicitud de autorización (§10), el cliente anuncia la huella digital de la clave que va a usar, y el servidor de autorización rechaza una solicitud de token cuya prueba use otra clave. Junto con PKCE, esto protege el flujo de extremo a extremo, siempre que el cliente use una clave nueva en cada solicitud de autorización. FAPI 2.0 exige que los servidores de autorización que usan DPoP admitan esta vinculación, pero deja su uso como opcional para los clientes (perfil de seguridad de FAPI 2.0, §5.3.2.1 y §5.3.3.1).

Cuándo usar DPoP

Situación¿DPoP?
Aplicación de página única o aplicación móvil que guarda tokens de actualizaciónSí: los tokens de actualización emitidos a clientes públicos quedan vinculados a la clave, y ahí es donde más ayuda DPoP
FAPI 2.0 (banca abierta y similares)Obligatorio en la práctica: FAPI 2.0 exige tokens vinculados al remitente mediante mTLS o DPoP
De servidor a servidor con TLS mutuo ya implantadoLa vinculación con mTLS (RFC 8705) es la opción más sencilla
Aplicación web confidencial que nunca expone los tokens al navegadorOpcional; los tokens se quedan en el servidor y sus tokens de actualización ya están vinculados a la autenticación del cliente
Servidores de recursos que no controlas y que solo aceptan BearerLimitado: ahí el token solo puede usarse como token de portador, sin la protección (§7.2)

Keycloak (probado más arriba), Okta y Auth0 (documentación) documentan DPoP para sus endpoints de token, y los servidores anuncian los algoritmos de prueba aceptados como dpop_signing_alg_values_supported en sus metadatos (§5.1). Lo que suele faltar está en el lado del servidor de recursos, y por eso importa el verificador anterior.

Errores frecuentes

  1. Comprobar la prueba, pero no la vinculación. Una prueba firmada con cualquier clave es válida por sí sola; solo cnf.jkt la ata al token.
  2. Aceptar el token también como Bearer. Entonces el atacante simplemente usa el esquema Bearer.
  3. Comparar htu con una URL que incluye la cadena de consulta o con una URL interna. htu excluye la consulta y el fragmento, y debe coincidir con la URL pública después de la normalización.
  4. Ni registro de pruebas usadas ni nonce. Entonces una prueba copiada vuelve a funcionar durante toda la ventana de iat.
  5. Una clave nueva por solicitud. La clave debe seguir siendo la misma mientras se usen los tokens vinculados a ella.
  6. Un alg simétrico o una clave privada en jwk. El §4.2 prohíbe ambas cosas; el EmbeddedJWK de jose rechaza la segunda.

Fuentes