OAuth2 PKCE para SPA y aplicaciones móviles

Avanzado
Spring Security
Spring Security
Actualizado: 19/04/2026

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_token queda 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_token en 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, siempre S256.
  • No invalidar el code tras el primer canje: el server debe rechazar reutilizaciones. Spring Authorization Server lo hace por defecto.
  • Confiar en state solo para CSRF: PKCE no reemplaza al state. Ambos son necesarios.
Alan Sastre - Autor del tutorial

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.