Tu aplicación necesita iniciar sesión y consultar una API. Encuentras una integración con OAuth, recibes varios tokens y surge la duda: ¿cuál demuestra quién es el usuario y cuál permite leer sus datos?
Vamos a resolverlo con una aplicación ficticia de documentos. La persona inicia sesión, la aplicación obtiene permiso para consultar documentos y la API decide cuáles puede entregar. Seguiremos el recorrido completo, dibujaremos el intercambio y ejecutaremos un laboratorio pequeño para entender PKCE.
Trabajaremos con una SPA, Single-Page Application: una aplicación cuya interfaz y lógica de cliente se ejecutan en el navegador. El ejemplo supone un proveedor de identidad de confianza y una API propia; no requiere usar cuentas ni credenciales reales.
Tres piezas con responsabilidades diferentes
| Pieza | Problema que resuelve | En nuestra aplicación |
|---|---|---|
| OAuth 2.0 | Delegar acceso limitado a recursos | Obtener permiso para consultar la API de documentos |
| OpenID Connect, u OIDC | Comunicar al cliente una identidad autenticada | Saber qué cuenta ha iniciado sesión |
| PKCE | Vincular el canje de un código a quien inició la operación | Impedir que alguien canjee un código robado sin conocer el verificador |
OAuth 2.0 es un marco de autorización. Su cliente es una aplicación, no necesariamente una persona. En este caso participan la persona que permite el acceso, la SPA, el servidor de autorización que emite tokens y el servidor de recursos que aloja la API. RFC 6749, sección 1.1.
Autenticar responde a quién está actuando; autorizar, a qué puede hacer en un contexto concreto. Que una persona haya iniciado sesión no basta para permitirle descargar cualquier documento. Los permisos deben comprobarse en el servidor para cada petición. OWASP Authorization Cheat Sheet.
OIDC añade una capa de identidad sobre OAuth. La solicitud incluye el scope openid y el cliente recibe un ID token con afirmaciones sobre la autenticación. El proveedor que realiza esa función se denomina OpenID Provider. OpenID Connect Core, introducción.
PKCE significa Proof Key for Code Exchange. Protege el intercambio del código; no autentica por sí solo al usuario ni decide sus permisos. RFC 7636, introducción.
Access token, ID token y código: no son intercambiables
| Elemento | Destinatario | Uso |
|---|---|---|
| Authorization code | Endpoint de tokens del proveedor | Canjearlo una sola vez por tokens |
| Access token | API para la que fue emitido | Presentar la autorización concedida al cliente |
| ID token | Cliente OIDC, aquí la SPA | Validar la identidad autenticada |
| Refresh token, si se emite | Endpoint de tokens | Solicitar nuevos access tokens conforme a la política del proveedor |
Un access token puede ser opaco o tener una estructura como JWT: OAuth no exige que sea JWT. La SPA no debe inferir su validez simplemente porque puede leerlo. RFC 6749, sección 1.4.
La API necesita un mecanismo de validación acordado con el emisor. Por ejemplo, puede consultar el estado de un token mediante introspección, un endpoint protegido que devuelve si está activo y el contexto de autorización asociado. Esa respuesta no sustituye las reglas de acceso de la aplicación. RFC 7662, sección 2.
Si el contrato usa access tokens JWT, la API debe verificar su protección criptográfica y sus claims esperados. El artículo JWT: decodificar no es validar desarrolla esa diferencia.
No envíes el ID token como credencial de tu API. Su audiencia incluye el client_id de la aplicación OIDC; la credencial destinada al recurso es el access token. Validar un ID token exige comprobar el emisor esperado, audiencia, caducidad y las demás reglas del flujo. En esta guía elegimos verificar también la firma con las claves del proveedor y algoritmos permitidos. OpenID Connect Core, sección 3.1.3.7.
Para un access token de tipo Bearer, la petición habitual es:
GET /documents HTTP/1.1
Host: api.example
Authorization: Bearer ACCESS_TOKENACCESS_TOKEN representa aquí un valor ficticio. Un bearer token puede usarse por quien lo posea: hay que protegerlo durante el transporte y almacenamiento. No lo pongas en enlaces, capturas ni registros. Usa HTTPS y el encabezado Authorization. PKCE no convierte automáticamente ese token en una credencial ligada a una clave del navegador. RFC 6750, secciones 1.2 y 2.1.
Scopes y roles responden preguntas distintas
Un scope expresa un alcance solicitado o concedido al cliente. Para este ejemplo inventamos documents:read: no es un nombre definido por OAuth, sino parte del contrato de nuestra API. Pedirlo no garantiza recibirlo. RFC 6749, sección 3.3.
Un rol, como editor, pertenece al modelo de permisos de la aplicación. No hay que tratarlo como sinónimo de scope ni suponer que todo proveedor lo enviará con el mismo nombre o significado.
Imagina este caso:
- Elena tiene el rol
editoren su organización. - La aplicación recibió únicamente
documents:read. - Elena intenta modificar un documento desde esa aplicación.
Nuestra política debe rechazar la operación: el rol no amplía el acceso delegado al cliente. Y un scope de lectura tampoco permite leer documentos de otra organización. La API comprueba acción, recurso, organización y relación del usuario con ese recurso. Esta es una decisión de diseño del ejemplo, coherente con el principio de mínimo privilegio. OWASP Authorization Cheat Sheet.
Una SPA es un cliente público
El JavaScript que entregas al navegador puede inspeccionarse. Un client_secret dentro del bundle, incluso si procede de una variable de entorno usada al compilar, no funciona como secreto de cliente. El client_id, en cambio, es un identificador público. RFC 6749, secciones 2.1 y 2.2.
Para este escenario utilizaremos Authorization Code Flow con PKCE y S256. RFC 9700 actualiza las recomendaciones de seguridad de OAuth: exige PKCE a los clientes públicos que usan este flujo. Para clientes confidenciales lo recomienda. También desaconseja emitir access tokens en la respuesta de autorización mediante el flujo implícito y prohíbe el grant de contraseña. Son reglas del protocolo en ese alcance, no obligaciones legales generales. RFC 9700, secciones 2.1.1, 2.1.2 y 2.4.
Que RFC 6749 describa un flujo no basta para elegirlo hoy: hay que leerlo junto con sus actualizaciones de seguridad.
PKCE: un verificador privado y un desafío público
Antes de redirigir al proveedor, el cliente genera un code_verifier aleatorio para esa operación. Debe tener entre 43 y 128 caracteres permitidos. Una forma recomendada de obtenerlo es generar 32 bytes aleatorios y codificarlos en Base64url sin relleno.
Con S256, calcula:
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))En la solicitud de autorización envía el code_challenge y code_challenge_method=S256. Conserva el verificador hasta el canje y lo envía entonces al endpoint de tokens mediante HTTPS. El servidor recalcula el desafío y lo compara con el que vinculó al código. RFC 7636, secciones 4.1 a 4.6.
Si alguien intercepta solo el código, no tiene el verificador necesario para completar ese canje. Si también roba el verificador o controla el JavaScript de la SPA, esta protección deja de cubrir ese escenario. OWASP OAuth2 Cheat Sheet: PKCE.
El verificador es temporal: no es la contraseña del usuario ni un secreto compartido permanente de la aplicación. Se genera de nuevo para cada intento.
State y nonce: qué relaciona cada uno
Los tres mecanismos se asocian a una operación concreta, pero se comprueban en lugares diferentes:
| Valor | Dónde viaja | Dónde se comprueba |
|---|---|---|
code_challenge | Solicitud de autorización | El proveedor lo vincula al código |
code_verifier | Solicitud al endpoint de tokens | El proveedor comprueba su relación con el desafío |
state | Solicitud de autorización y callback | El cliente lo relaciona con una operación pendiente de ese navegador |
nonce | Solicitud OIDC y claim del ID token | El cliente lo contrasta durante la validación del ID token |
state permite correlacionar la respuesta con el intento iniciado. Para mitigar CSRF —peticiones inducidas que no corresponden a la operación legítima— debe ser impredecible, de un solo uso y estar vinculado al navegador. No basta con aceptar cualquier valor que llegue al callback. OWASP también advierte contra redirecciones abiertas: no conviertas state en una URL arbitraria a la que navegar después. OWASP OAuth2 Cheat Sheet: controles básicos.
nonce vincula la solicitud OIDC con el ID token y ayuda a detectar que se intente reutilizar un token de otra operación. En Authorization Code Flow, OIDC Core lo define como opcional; si se envía, debe estar presente y coincidir al validar el ID token. Aquí lo incluimos como elección de implementación. En OAuth sin OIDC no hay ID token sobre el que hacer esa comprobación. OpenID Connect Core, solicitud de autenticación.
Hay un matiz importante: RFC 9700 permite apoyarse en PKCE contra CSRF cuando el cliente ha confirmado que el servidor lo soporta y la transacción está correctamente vinculada. Si no se usa esa protección ni la de nonce en OIDC, exige tokens CSRF mediante state. Por tanto, los tres no son obligatorios universalmente. Para nuestro diseño usaremos PKCE, state y nonce, con responsabilidades explícitas. RFC 9700, secciones 2.1 y 4.7.1.
Authorization Code Flow con PKCE, paso a paso
Este diagrama representa la SPA y la API de documentos. La interacción de la persona con la pantalla del proveedor se resume en el paso de autenticación y consentimiento; este último puede estar concedido previamente.
No se pudo mostrar el diagrama. Puedes consultar su definición.
sequenceDiagram
accTitle: Inicio de sesión OIDC con PKCE en una SPA
accDescr: La SPA obtiene un código, lo canjea con su verificador, valida la identidad y usa un access token para consultar la API.
participant S as SPA
participant P as Proveedor OIDC
participant A as API
S->>S: Generar verifier, challenge, state y nonce
S->>P: Navegar a autorización con challenge, state y nonce
Note over P: Autenticar a la persona<br/>y resolver consentimiento
P-->>S: Redirigir al callback con code y state
S->>S: Validar transacción y state
S->>P: Canjear code con code_verifier
P->>P: Validar código y comprobar PKCE
P-->>S: Access token e ID token
S->>S: Validar ID token y nonce
S->>A: Solicitar documentos con access token
A->>A: Validar token y permisos del recurso
A-->>S: Entregar los documentos permitidos
El dibujo es una guía de lectura del intercambio descrito por OAuth Authorization Code Grant, la extensión PKCE y el flujo de código de OIDC. En una SPA que canjea directamente el código, los tokens llegan al navegador: el flujo evita devolverlos en la URL de autorización, pero no los oculta al JavaScript del cliente.
1. Configurar un proveedor de confianza
Registra la SPA como cliente público, habilita Code Flow con PKCE S256 y registra una URI exacta de retorno, por ejemplo https://app.example/callback. La configuración de CORS del endpoint de tokens debe permitir el origen de la SPA para este canje directo; confírmalo en la documentación de tu proveedor.
Parte de un issuer configurado por la aplicación. El documento de descubrimiento OIDC permite obtener los endpoints y jwks_uri, la dirección de las claves públicas. Comprueba que el issuer de los metadatos coincide con el esperado; no elijas endpoints a partir de parámetros arbitrarios del callback. OpenID Connect Discovery, secciones 3 y 4.
2. Preparar la operación y redirigir
Conserva una transacción pendiente con su verificador, state, nonce, emisor y caducidad. Debe sobrevivir a la navegación y estar vinculada a ese navegador. Un SDK de OIDC para SPA debe gestionar esa correlación y los intentos simultáneos.
La solicitud tiene esta forma esquemática; los saltos de línea y valores entre llaves son explicativos, no una URL para pegar:
https://identity.example/authorize
?response_type=code
&client_id=documents-spa
&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback
&scope=openid%20documents%3Aread
&code_challenge={desafio_de_esta_operacion}
&code_challenge_method=S256
&state={state_de_esta_operacion}
&nonce={nonce_de_esta_operacion}identity.example, app.example y api.example son dominios de documentación. En una integración real, obtén las direcciones del proveedor configurado y construye los parámetros con una API de URL, no concatenando entradas del usuario.
3. Procesar el callback y canjear el código
El cliente busca la transacción correspondiente, verifica state y procesa el resultado, incluido un posible error de autorización. Si falta la transacción, ha caducado o no coincide, termina el intento. No canjea el código ni intenta continuar con valores vacíos.
El cuerpo del canje usa application/x-www-form-urlencoded y contiene:
grant_type=authorization_code
code={codigo_recibido}
client_id=documents-spa
redirect_uri=https://app.example/callback
code_verifier={verificador_original}Son campos ilustrativos: al serializarlos se unen con & y se codifican sus valores. No hay client_secret en esta SPA. El proveedor valida el código, cliente, URI de retorno y PKCE; después el cliente valida la respuesta OIDC. La transacción se consume una sola vez. RFC 6749, sección 4.1.3, RFC 7636, sección 4.6.
Si admites varios proveedores, añade protección contra mix-up: confundir quién emitió una respuesta y enviar el código al endpoint equivocado. RFC 9207 define un parámetro iss que se contrasta con el emisor de la operación antes de continuar. No se trata de decodificar cualquier ID token y confiar en el emisor que declare. RFC 9207, sección 2.4.
4. Establecer la identidad y llamar a la API
El SDK valida el ID token, incluido el nonce esperado cuando se envió. Solo después la interfaz establece la identidad autenticada. Las peticiones de documentos llevan el access token; la API valida esa credencial y aplica su propia política de acceso.
Para implementar el recorrido completo, mi recomendación es usar una biblioteca OIDC mantenida o el SDK oficial del proveedor. Este flujo sirve para revisar su configuración y sus resultados, no para sustituir todas sus validaciones por unas llamadas manuales a fetch.
Laboratorio TypeScript: comprobar la relación de PKCE
Este ejercicio local demuestra la generación y transformación de PKCE. No implementa un servidor OAuth, un inicio de sesión ni la validación de tokens. No contacta servicios externos.
Usa Node.js 24 o superior. Guarda el archivo como pkce.mts y ejecuta node pkce.mts. Node puede ejecutar estas anotaciones de TypeScript mediante eliminación de tipos; eso no equivale a comprobarlos estáticamente. Documentación de TypeScript en Node.js.
import assert from 'node:assert/strict';
import { createHash, randomBytes } from 'node:crypto';
type ParPKCE = {
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: 'S256';
};
function calcularDesafio(verificador: string): string {
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verificador)) {
throw new Error('Formato de code_verifier inválido');
}
return createHash('sha256')
.update(verificador, 'ascii')
.digest('base64url');
}
function crearPKCE(): ParPKCE {
const codeVerifier = randomBytes(32).toString('base64url');
return {
codeVerifier,
codeChallenge: calcularDesafio(codeVerifier),
codeChallengeMethod: 'S256',
};
}
// Vector público de prueba del apéndice B de RFC 7636.
// Nunca reutilices este verificador en una autenticación real.
const verificadorDePrueba =
'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const desafioEsperado =
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
assert.equal(calcularDesafio(verificadorDePrueba), desafioEsperado);
assert.notEqual(calcularDesafio('a'.repeat(43)), desafioEsperado);
assert.throws(() => calcularDesafio('demasiado-corto'));
const operacion = crearPKCE();
assert.equal(operacion.codeVerifier.length, 43);
assert.equal(operacion.codeChallenge.length, 43);
assert.equal(operacion.codeChallengeMethod, 'S256');
assert.equal(
calcularDesafio(operacion.codeVerifier),
operacion.codeChallenge,
);
console.log('PKCE: vector conocido y comprobaciones correctos');randomBytes() genera los bytes aleatorios y createHash() calcula SHA-256. base64url produce una representación compatible con URL sin los caracteres de relleno de Base64. No imprimas el verificador de operaciones reales en los logs. Node.js: crypto, Node.js: codificaciones de Buffer.
El vector publicado permite comprobar un resultado conocido, además de la consistencia entre las funciones. El caso con otro verificador y el formato inválido ejercitan fallos distintos. RFC 7636, apéndice B.
El resultado esperado es PKCE: vector conocido y comprobaciones correctos. Puedes borrar pkce.mts al terminar. El ejercicio no almacena tokens ni crea cuentas. En el navegador no se importa node:crypto: para una SPA real, utiliza la implementación PKCE de la biblioteca elegida.
SHA-256 tiene sentido aquí porque transforma un valor aleatorio de alta entropía. Eso no contradice que un hash rápido, por sí solo, sea inadecuado para almacenar contraseñas elegidas por personas: son problemas diferentes.
Errores que conviene detectar antes de integrar
| Error | Qué revisar |
|---|---|
| Enviar el ID token a la API | Separar las credenciales y sus destinatarios |
| Dar permisos al decodificar un JWT | Validar el token y luego autorizar el recurso |
| Reutilizar verifier, state o nonce | Crear una transacción nueva para cada intento |
Usar plain o enviar el verifier en la autorización | Exigir PKCE S256 y conservar el verifier hasta el canje |
| Aceptar un callback sin transacción válida | Fallar sin iniciar sesión ni usar los tokens |
| Introducir un secreto en el frontend | Registrar correctamente el cliente público |
| Interpretar un scope como acceso global | Comprobar organización, propietario y acción en la API |
Además, prueba cancelación del usuario, código repetido, state incorrecto, nonce distinto, ID token caducado y audiencia equivocada en un entorno autorizado. Esos casos son una lista de comprobaciones para tu integración; el laboratorio anterior solo verifica PKCE.
PKCE no corrige XSS, la ejecución de JavaScript inyectado en tu origen. sessionStorage y localStorage siguen siendo accesibles al JavaScript de ese origen; cambiar de uno a otro no crea un almacén secreto. Mantener tokens en memoria reduce persistencia, pero tampoco elimina el riesgo de un script malicioso activo. OWASP HTML5 Security Cheat Sheet: almacenamiento local.
Si el riesgo justifica un backend intermediario, puede evaluarse una arquitectura BFF, Backend for Frontend, que mantenga los tokens en el servidor y entregue al navegador una sesión con cookies protegidas. Es una alternativa de arquitectura, no un requisito universal de OAuth, y exige gestionar su propia protección CSRF y la seguridad de la sesión.
Si el proveedor emite refresh tokens a un cliente público, RFC 9700 exige rotación o vinculación criptográfica al cliente que presenta el token (sender-constraining). Renovar tokens no consiste en guardar indefinidamente una cadena en el navegador. RFC 9700, sección 2.2.2.
Diseñar la integración desde sus límites de confianza
En nuestra aplicación de documentos, el proveedor establece la identidad, el cliente mantiene la transacción y la API decide qué datos puede entregar. Cada parte necesita validar lo que recibe antes de usarlo.
Al revisar una integración, sigue ese recorrido: qué intentó hacer la persona, qué operación quedó pendiente, quién emitió la respuesta, para quién sirve cada token y qué permiso exige el documento solicitado. El diagrama y las pruebas negativas ayudan a convertir esas preguntas en comprobaciones concretas.
Referencias
- RFC 6749: The OAuth 2.0 Authorization Framework.
- RFC 7636: Proof Key for Code Exchange.
- RFC 9700: Best Current Practice for OAuth 2.0 Security.
- OpenID Connect Core 1.0, errata set 2.
- OpenID Connect Discovery 1.0, errata set 2.
- RFC 6750: Bearer Token Usage.
- RFC 7662: OAuth 2.0 Token Introspection.
- RFC 9207: Authorization Server Issuer Identification.
- OWASP OAuth2 Cheat Sheet.
- OWASP Authorization Cheat Sheet.
- OWASP HTML5 Security Cheat Sheet.
- Node.js: crypto, Buffer y TypeScript.
