El flujo Authorization Code Grant ha sido durante años el estándar para aplicaciones web confidenciales. Sin embargo, en SPAs (Angular, React, Vue) y aplicaciones móviles no hay forma de proteger un client_secret: cualquier usuario puede inspeccionar el código JavaScript o decompilar el APK.
PKCE (Proof Key for Code Exchange, RFC 7636) resuelve este problema. Spring Security 7.x lo soporta de forma nativa tanto en el lado Authorization Server como en el Client.
Qué problema resuelve PKCE
En un Authorization Code Flow clásico, el cliente intercambia el code por un access_token enviando su client_secret. Si el secret está embebido en una SPA, un atacante que intercepte el code (por ejemplo, con un esquema URL malicioso en móvil) puede canjearlo por tokens.
PKCE introduce un secret efímero por sesión que solo el cliente legítimo conoce.
sequenceDiagram
participant App as App movil / SPA
participant Auth as Authorization Server
App->>App: Generar code_verifier aleatorio
App->>App: code_challenge = SHA256(code_verifier)
App->>Auth: GET /authorize?code_challenge=...&method=S256
Auth->>App: Login + consent
Auth->>App: Redirect con ?code=abc
App->>Auth: POST /token con code + code_verifier
Auth->>Auth: SHA256(code_verifier) == code_challenge?
Auth->>App: access_token + refresh_token
Sin el code_verifier, el code interceptado es inútil.
Generación del code_verifier y code_challenge
El code_verifier es una cadena aleatoria de 43 a 128 caracteres del set [A-Za-z0-9-._~]. El code_challenge es su SHA-256 codificado en Base64URL sin padding.
JavaScript (frontend):
async function generatePkce() {
const verifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64URLEncode(new Uint8Array(hash));
return { verifier, challenge };
}
function base64URLEncode(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
El verifier se guarda en sessionStorage. El challenge se envía en el primer paso del flujo.
Lado cliente Spring: Spring Authorization Server
Spring Authorization Server (a partir de la versión 1.x) acepta PKCE de forma transparente. Basta con registrar el cliente como público (sin secret) y exigir PKCE.
@Bean
RegisteredClientRepository clientRepository() {
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("spa-client")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://app.demo.local/callback")
.scope(OidcScopes.OPENID)
.scope("tareas:read")
.clientSettings(ClientSettings.builder()
.requireProofKey(true) // exige PKCE
.requireAuthorizationConsent(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(spa);
}
requireProofKey(true) rechaza cualquier petición sin code_challenge. Es la configuración recomendada incluso para clientes confidenciales: PKCE como defensa en profundidad no penaliza a clientes con secret.
Authorization endpoint con PKCE
El cliente redirige al usuario a /oauth2/authorize con los parámetros estándar más PKCE:
GET /oauth2/authorize?
response_type=code&
client_id=spa-client&
redirect_uri=https://app.demo.local/callback&
scope=openid+tareas:read&
state=xyz123&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
Los dos últimos parámetros son los novedosos. El servidor los almacena junto al code emitido.
Token endpoint con PKCE
Cuando el cliente canjea el code, envía el code_verifier:
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=eyJraWQ...&
redirect_uri=https://app.demo.local/callback&
client_id=spa-client&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
El servidor recalcula SHA256(code_verifier) y compara con el code_challenge guardado. Si coinciden, emite los tokens.
Cliente Spring: oauth2Client con PKCE
Si tu cliente es otra app Spring (por ejemplo, un BFF que media entre la SPA y el backend), Spring Security añade PKCE automáticamente a los flujos authorization_code cuando el cliente está marcado como público.
spring:
security:
oauth2:
client:
registration:
empresa-bff:
provider: empresa-auth
client-id: spa-client
client-authentication-method: none
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, tareas:read
provider:
empresa-auth:
issuer-uri: "https://auth.demo.local"
client-authentication-method: none desencadena el modo público con PKCE.
Storage del refresh_token en SPA
Almacenar el refresh_token en localStorage es vulnerable a XSS. Las opciones modernas son:
- 1. BFF (Backend For Frontend): el
refresh_tokenqueda en el servidor; la SPA solo maneja una session cookie httpOnly. Patrón recomendado. - 2. Service Worker: el SW intercepta las peticiones y añade el
access_token. El refresh queda en una IndexedDB que el documento principal no puede leer. - 3. Refresh token rotation: cada uso del refresh emite uno nuevo y el anterior se invalida. Mitiga el robo, no lo evita.
En 2026, la OAuth 2.1 BCP for Browser Apps desaconseja explícitamente guardar
refresh_tokenen SPA. El patrón BFF es la opción por defecto para producción.
App móvil con custom scheme
En móvil, el redirect_uri usa un esquema custom registrado por la app (com.empresa.app://callback). El sistema operativo entrega la respuesta a la app instalada.
- iOS: declarar el esquema en
Info.plist. - Android: declarar
<intent-filter>con el esquema en el manifest.
Riesgo: una app maliciosa puede registrar el mismo esquema. Para evitarlo, en Android se usa App Links (verificados por Google) y en iOS Universal Links.
spring:
security:
oauth2:
authorizationserver:
client:
movil:
registration:
client-id: movil-app
client-authentication-methods: none
authorization-grant-types: authorization_code, refresh_token
redirect-uris: "com.empresa.app://callback"
scopes: openid, tareas:read, tareas:write
require-proof-key: true
Tokens DPoP: el siguiente paso
DPoP (Demonstration of Proof-of-Possession, RFC 9449) ata el access_token a una clave criptográfica del cliente. Aunque el token sea robado, no sirve sin la clave.
Spring Authorization Server añadió soporte experimental en 2024. La adopción aún es baja, pero se espera como sucesor de PKCE para escenarios de máxima sensibilidad.
POST /api/tareas
Authorization: DPoP eyJ...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2In0...
El header DPoP es un JWT firmado con la clave del cliente que prueba que la petición proviene del legítimo poseedor del token.
Errores frecuentes
- Reusar
code_verifier: cada flujo debe generar uno nuevo. Reusarlo permite a un atacante predecir futuros challenges. - Usar
code_challenge_method=plain: el RFC permite enviar el challenge sin hashing. Nunca lo uses, siempreS256. - No invalidar el
codetras el primer canje: el server debe rechazar reutilizaciones. Spring Authorization Server lo hace por defecto. - Confiar en
statesolo para CSRF: PKCE no reemplaza alstate. Ambos son necesarios.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Security
Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Entender el flujo Authorization Code con PKCE, configurar Spring Authorization Server para aceptar clientes públicos y validar code_challenge generados por una SPA o app móvil.