🔐Ciberseguridad

OAuth 2.0, OpenID Connect y PKCE: guía práctica para SPA

Felipe••16 min de lectura
Entiende OAuth 2.0, OpenID Connect y PKCE en una SPA: tokens, scopes, state y nonce, con un diagrama del flujo y un laboratorio en TypeScript.

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

PiezaProblema que resuelveEn nuestra aplicación
OAuth 2.0Delegar acceso limitado a recursosObtener permiso para consultar la API de documentos
OpenID Connect, u OIDCComunicar al cliente una identidad autenticadaSaber qué cuenta ha iniciado sesión
PKCEVincular el canje de un código a quien inició la operaciónImpedir 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

ElementoDestinatarioUso
Authorization codeEndpoint de tokens del proveedorCanjearlo una sola vez por tokens
Access tokenAPI para la que fue emitidoPresentar la autorización concedida al cliente
ID tokenCliente OIDC, aquí la SPAValidar la identidad autenticada
Refresh token, si se emiteEndpoint de tokensSolicitar 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_TOKEN

ACCESS_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 editor en 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:

ValorDónde viajaDónde se comprueba
code_challengeSolicitud de autorizaciónEl proveedor lo vincula al código
code_verifierSolicitud al endpoint de tokensEl proveedor comprueba su relación con el desafío
stateSolicitud de autorización y callbackEl cliente lo relaciona con una operación pendiente de ese navegador
nonceSolicitud OIDC y claim del ID tokenEl 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.

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

ErrorQué revisar
Enviar el ID token a la APISeparar las credenciales y sus destinatarios
Dar permisos al decodificar un JWTValidar el token y luego autorizar el recurso
Reutilizar verifier, state o nonceCrear una transacción nueva para cada intento
Usar plain o enviar el verifier en la autorizaciónExigir PKCE S256 y conservar el verifier hasta el canje
Aceptar un callback sin transacción válidaFallar sin iniciar sesión ni usar los tokens
Introducir un secreto en el frontendRegistrar correctamente el cliente público
Interpretar un scope como acceso globalComprobar 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

#OAuth 2.0#OpenID Connect#PKCE#SPA#autenticación#autorización#TypeScript#ciberseguridad

Artículos relacionados