Plan de estudio Java 2026
Módulo 10 muy preguntado Día 24 ≈ 5 h de estudio activo

Seguridad de aplicaciones Java y Spring

La seguridad no es una capa que se añade al final: es una propiedad del diseño. Este módulo recorre los principios (CIA, defensa en profundidad, mínimo privilegio, confianza cero), la criptografía que de verdad vas a usar, la arquitectura completa de Spring Security 6, la autenticación con tokens, OAuth 2 y OpenID Connect, el OWASP Top 10 con código vulnerable y su corrección, la cadena de suministro, la privacidad y el proceso. Siempre con el por qué, porque una regla de seguridad que no entiendes es una regla que acabarás desactivando “solo en desarrollo”.

Progreso de este módulo0 / 0
Cómo leer este módulo: las secciones 1 y 2 son los cimientos conceptuales (no las saltes: sin ellas, Spring Security parece magia arbitraria). Las secciones 3, 4 y 5 son el cómo en Spring Boot 3. La sección 6 (OWASP) es la que más se pregunta en entrevistas y la que más errores te va a evitar. Las 7 a 10 son las que separan a un desarrollador de un ingeniero responsable de un sistema en producción.
Aviso ético y legal: aquí se explica cómo se rompen las cosas para saber protegerlas, sin exploits listos para usar. Practica siempre contra tus propios entornos o contra laboratorios diseñados para ello (PortSwigger Academy, OWASP Juice Shop, WebGoat). Atacar sistemas ajenos sin autorización escrita es un delito, en España tipificado en el Código Penal (artículos 197 bis y 264).

1. Fundamentos: principios y modelado de amenazas

1.1 La tríada CIA: qué proteges exactamente

Todo requisito de seguridad se reduce a tres propiedades. Nombrarlas te obliga a ser concreto y evita frases vacías del tipo “la aplicación tiene que ser segura”.

PropiedadQué garantizaEjemplo de fallo en una API de pedidosControles típicos
Confidencialidad Solo quien debe, ve el dato. GET /pedidos/4711 devuelve el pedido de otro cliente, con su dirección y su teléfono. Autorización, cifrado en tránsito y en reposo, minimización de campos en las respuestas.
Integridad El dato no se altera sin autorización y, si se altera, se detecta. El cliente envía un total de 0,01 € y el servidor lo acepta en lugar de recalcularlo. Validación en el servidor, firmas, hashes, transacciones, auditoría, control de versiones optimista.
Disponibilidad El servicio responde cuando se le necesita. Un endpoint sin paginación ni límite de tamaño tumba el servicio con una sola petición. Rate limiting, timeouts, cuotas, límites de payload, autoescalado, WAF y anti-DDoS.

A la tríada clásica se le añaden dos propiedades imprescindibles en sistemas de negocio: autenticidad (el mensaje viene de quien dice venir) y no repudio (quien hizo algo no puede negarlo después, gracias a la auditoría).

1.2 Los siete principios que debes poder recitar

PrincipioQué significaTraducción a código o configuración
Superficie de ataque mínima Cada puerto, endpoint, parámetro, dependencia y usuario es una puerta más que vigilar. Borra endpoints muertos, cierra Actuator, elimina dependencias sin usar, imagen base distroless.
Defensa en profundidad Ninguna medida es infalible: se apilan capas independientes para que el fallo de una no sea catastrófico. WAF + autorización por URL + autorización por método + comprobación de propiedad en la consulta.
Mínimo privilegio Cada identidad tiene exactamente los permisos que necesita, y durante el tiempo que los necesita. Usuario de base de datos sin DROP, un rol de IAM por servicio, contenedor sin root, token con un solo scope.
Confianza cero (zero trust) La red no otorga confianza. Cada petición se autentica y se autoriza, venga de dentro o de fuera. mTLS entre microservicios, validación del token en cada salto, nunca “si viene de la VPN, pasa”.
Fallar de forma segura Si algo va mal, el resultado por defecto es denegar, no permitir. anyRequest().authenticated() al final; si el servicio de permisos no responde, se deniega.
Seguridad por diseño y por defecto La opción segura es la que sale de fábrica; la insegura requiere un acto explícito y justificado. TLS obligatorio, contraseñas hasheadas por el framework, CSRF activado, cookies HttpOnly.
Shift-left El coste de arreglar un fallo crece un orden de magnitud en cada fase, así que se busca pronto. Modelado de amenazas en diseño, SAST y SCA en cada pull request, DAST en preproducción.
Analogía del castillo (defensa en profundidad): un castillo medieval no confía en la muralla. Tiene foso, puente levadizo, muralla exterior, patio de armas, muralla interior, torre del homenaje y, dentro, una cámara acorazada. Cada capa ralentiza al atacante y da tiempo a detectarlo. Tu aplicación, igual: el WAF frena lo genérico, la autenticación frena a los anónimos, la autorización frena a los curiosos, la comprobación de propiedad frena al cliente legítimo que trastea con identificadores, y los logs te avisan de que alguien está trasteando.
El antipatrón contrario: seguridad de cáscara de huevo (duro por fuera, blando por dentro). “Estamos detrás del firewall corporativo, dentro nos fiamos.” Cualquier atacante que consiga un solo punto de apoyo —un SSRF, una dependencia comprometida, un portátil robado— se mueve libremente por toda la red interna. Es la razón exacta por la que existe la confianza cero.

1.3 Modelado de amenazas con STRIDE, aplicado de verdad

Modelar amenazas es una reunión de una hora con un diagrama, no un documento de ochenta páginas. El procedimiento honesto son las cuatro preguntas de Adam Shostack: ¿en qué estamos trabajando? (diagrama), ¿qué puede salir mal? (STRIDE), ¿qué vamos a hacer? (mitigaciones con dueño y fecha) y ¿lo hemos hecho bien? (revisión).

DIAGRAMA DE FLUJO DE DATOS DE LA API DE PEDIDOS (lo que dibujas en la pizarra)

   ┌─────────────┐   1. HTTPS      ┌───────────────┐   3. HTTPS/mTLS   ┌──────────────┐
   │  Navegador  │────────────────►│               │──────────────────►│  Servicio    │
   │    (SPA)    │◄────────────────│  API pedidos  │◄──────────────────│   de pagos   │
   └─────────────┘                 │ (Spring Boot) │                   └──────────────┘
         │                         └───┬───────┬───┘
         │ 2. Authorization Code        │       │  4. JDBC sobre TLS
         │    + PKCE                    │       ▼
         ▼                              │   ┌──────────────┐
   ┌─────────────┐  valida el JWT       │   │  PostgreSQL  │
   │  Keycloak   │◄─── con el JWKS ─────┘   │  (cifrado    │
   │  (AS/OIDC)  │                          │   en reposo) │
   └─────────────┘                          └──────────────┘

 ════ LÍMITES DE CONFIANZA (trust boundaries) ════
  A) Navegador ↔ API      : todo dato de entrada es hostil. NADA de lógica en el cliente.
  B) API ↔ Keycloak       : confío en la FIRMA del token, no en la red.
  C) API ↔ pagos          : otro equipo, otro despliegue → identidad propia y autorización.
  D) API ↔ base de datos  : credenciales, mínimo privilegio y cifrado del canal.

 Cada FLECHA que cruza un límite de confianza es donde se aplica STRIDE.
STRIDEAmenaza concreta en la API de pedidosPropiedad violadaMitigación
Spoofing
suplantación
Alguien llama a POST /pedidos con un JWT que ha fabricado él, o reutiliza el token robado de otro usuario. Autenticidad Validar la firma con el JWKS del emisor, comprobar iss, aud y exp, vida corta del token, MFA en el login, mTLS entre servicios.
Tampering
manipulación
El cliente envía un precio unitario de 0,01 € o cambia el estado del pedido a PAGADO en el JSON. Integridad DTO de entrada con solo los campos que el cliente puede fijar; precios y estados siempre recalculados en el servidor; @Version optimista.
Repudiation
repudio
Un operador cancela 300 pedidos y luego niega haberlo hecho; no hay rastro de quién fue. No repudio Log de auditoría con actor, acción, recurso, IP, traceId y marca de tiempo, en almacenamiento de solo añadido y con retención definida.
Information disclosure
divulgación
GET /pedidos/4711 devuelve el pedido de otro cliente (IDOR). El stack trace revela versiones y rutas internas. Confidencialidad Comprobación de propiedad en la consulta, no solo en un if; errores genéricos con traceId; TLS; sin datos personales en los logs.
Denial of service
denegación de servicio
GET /pedidos?size=1000000, o 50.000 intentos de login por minuto, o un JSON de 500 MB. Disponibilidad Paginación con tope duro, spring.servlet.multipart.max-file-size, rate limiting por IP y por usuario, timeouts, cuotas y WAF.
Elevation of privilege
escalada
Un usuario normal llama a DELETE /admin/pedidos/4711, que solo estaba oculto en la interfaz. Autorización Deny by default, autorización en el servicio con @PreAuthorize y no solo por URL, roles que nunca vienen del cliente.
Truco para que el modelado no se convierta en burocracia: limítalo a los flujos que cruzan un límite de confianza y exige que cada mitigación acabe en un ticket con dueño y en un test automático. Un modelo de amenazas sin tests es un PDF que nadie volverá a abrir.

1.4 Autenticación, autorización y auditoría no son lo mismo

ConceptoPregunta que respondeCuándo ocurreEn Spring SecurityCódigo HTTP si falla
Autenticación (AuthN) ¿Quién eres? ¿Puedes demostrarlo? Una vez, al principio de la sesión o de la petición. AuthenticationManager, AuthenticationProvider, filtros de autenticación. 401 Unauthorized (mal nombrado: significa “no autenticado”), con WWW-Authenticate.
Autorización (AuthZ) ¿Puedes hacer esto, sobre este recurso? En cada operación, tantas veces como haga falta. AuthorizationManager, AuthorizationFilter, @PreAuthorize. 403 Forbidden, o 404 si el propio hecho de que el recurso exista es información sensible.
Auditoría (accounting) ¿Quién hizo qué, cuándo y desde dónde? Después, de forma inmutable. Eventos AuthenticationSuccess/FailureEvent, AOP, @EntityListeners. No falla: si no puedes auditar una operación crítica, considera abortarla.
Error de diseño clásico: confundir “estar autenticado” con “tener permiso”. La mayoría de las vulnerabilidades graves que aparecen en auditorías reales son de autorización, no de autenticación: el atacante es un usuario legítimo de la plataforma que consulta datos que no son suyos. Por eso A01 (control de acceso roto) es la primera categoría del OWASP Top 10.

Checklist — fundamentos

2. Criptografía aplicada: lo justo y sin errores

Regla número uno de la criptografía: no la implementes tú. No inventes un cifrado, no “mejores” AES con una vuelta extra, no construyas tu propio esquema de firma. Usa primitivas estándar de la JCA o de librerías auditadas (Tink, Bouncy Castle, libsodium) con los modos y parámetros recomendados. El trabajo de un desarrollador de aplicaciones es elegir bien y gestionar las claves, no diseñar algoritmos.

2.1 Hash, cifrado y firma: tres cosas distintas

Primitiva¿Reversible?¿Clave?Qué garantizaEjemplo de uso correcto
Hash (SHA-256, SHA-3) No: función de un solo sentido No Integridad: mismo dato, mismo resumen. Huella de un fichero, deduplicación, árboles de Merkle, ETag.
Hash de contraseñas (BCrypt, Argon2id) No, y además deliberadamente lento No, pero con salt Que un volcado de la base de datos no revele las contraseñas. La columna del hash en la tabla de usuarios.
MAC / HMAC (HMAC-SHA256) No Sí, simétrica Integridad y autenticidad entre quienes comparten la clave. Firma de webhooks, JWT con HS256, cookies firmadas.
Cifrado simétrico (AES-GCM, ChaCha20-Poly1305) Sí, con la misma clave Sí, una compartida Confidencialidad, e integridad si es AEAD. Cifrado de un campo sensible en base de datos, ficheros, el interior de TLS.
Cifrado asimétrico (RSA-OAEP, ECIES) Sí: cifra con la pública, descifra con la privada Sí, par de claves Confidencialidad sin compartir un secreto previo. Intercambiar una clave de sesión; cifrar un secreto pequeño para un destinatario concreto.
Firma digital (RSA-PSS, ECDSA P-256, Ed25519) No Sí: firma con la privada, verifica con la pública Autenticidad, integridad y no repudio. JWT con RS256 o ES256, certificados TLS, firma de artefactos.
Analogía: el hash es la trituradora de papel (no puedes recomponer el documento, pero dos documentos iguales dan la misma montaña de tiras). El cifrado es la caja fuerte (metes y sacas con la llave). La firma es el notario: no oculta nada, pero certifica quién lo escribió y que no se ha tocado desde entonces. Confundirlas es el error conceptual más habitual: “ciframos las contraseñas” es, casi siempre, la confesión de un diseño equivocado.

2.2 Hashing de contraseñas: el caso que sí te va a tocar

MD5, SHA-1, SHA-256 y SHA-512 no sirven para contraseñas. No porque estén “rotos” en el sentido de las colisiones (SHA-256 está perfectamente bien como hash), sino porque están diseñados para ser rapidísimos: exactamente lo contrario de lo que necesitas aquí. Una GPU moderna calcula del orden de miles de millones de SHA-256 por segundo, así que un diccionario con los miles de millones de contraseñas filtradas históricas se prueba en minutos.

Un algoritmo de contraseñas necesita tres propiedades que SHA no tiene:

  1. Salt aleatorio y único por usuario. Impide precomputar tablas (rainbow tables) y hace que dos usuarios con la misma contraseña tengan hashes distintos. El salt no es secreto: se guarda junto al hash.
  2. Coste configurable (factor de trabajo). El hash tarda deliberadamente 100–500 ms. Para ti es imperceptible; para quien prueba millones de candidatos es un muro. Y se puede subir con los años sin cambiar de algoritmo.
  3. Coste de memoria en los algoritmos modernos. Argon2 y SCrypt exigen decenas de MiB por cálculo, lo que arruina la ventaja de las GPU y los ASIC, que tienen mucha unidad aritmética pero poca memoria por núcleo.
// ❌ INCORRECTO — todas estas variantes se han visto en producción y todas son inaceptables
usuario.setPassword(password);                                    // texto plano
usuario.setPassword(DigestUtils.md5Hex(password));                // MD5: se rompe en segundos
usuario.setPassword(sha256(password));                            // rápido → fuerza bruta trivial
usuario.setPassword(sha256(SAL_GLOBAL + password));               // salt único global: inútil
usuario.setPassword(Base64.getEncoder().encodeToString(bytes));   // Base64 NO es cifrado
usuario.setPassword(cifrarAes(password, CLAVE));                  // reversible: roban la clave,
                                                                  // roban todas las contraseñas

// ✅ CORRECTO — delega en Spring Security y no toques el algoritmo a mano
@Bean
PasswordEncoder passwordEncoder() {
    // Devuelve un DelegatingPasswordEncoder: codifica con el algoritmo por defecto (bcrypt)
    // y sabe VERIFICAR cualquiera de los formatos históricos gracias al prefijo {id}.
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// Alta de usuario
String hash = passwordEncoder.encode(passwordEnClaro);
// hash → "{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye/Ci1Xh0zqQ8pEeS6PfLZKuHNP7BvJ2u"
//          └──┬───┘└┬┘└┬┘└──────── salt (22 caracteres) ──┘└────── hash ──────┘
//             │     │  └ coste = 2^10 = 1024 iteraciones internas
//             │     └── versión del algoritmo bcrypt
//             └──────── identificador que permite migrar de algoritmo sin romper nada

// Verificación: NUNCA compares hashes tú mismo, ni con equals ni con ==
boolean ok = passwordEncoder.matches(passwordRecibido, usuario.getPasswordHash());

// Elección explícita del algoritmo cuando quieres Argon2id (recomendación de OWASP)
@Bean
PasswordEncoder passwordEncoderArgon2() {
    String idPorDefecto = "argon2";
    Map<String, PasswordEncoder> codificadores = Map.of(
            "argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(),
            "bcrypt", new BCryptPasswordEncoder(12),
            "scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8(),
            "pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    var delegante = new DelegatingPasswordEncoder(idPorDefecto, codificadores);
    // Cualquier hash antiguo SIN prefijo se asume bcrypt (soporte de migración):
    delegante.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
    return delegante;
}
AlgoritmoTipoParámetros recomendadosCuándo usarlo
Argon2idMemoria dura; ganador del Password Hashing CompetitionDesde 19 MiB de memoria, 2 iteraciones y paralelismo 1 (perfil de OWASP). Spring usa 16 MiB por defecto.Primera opción en proyectos nuevos.
BCryptCoste en CPU, basado en BlowfishCoste 10–12: mídelo y apunta a unos 250 ms en tu hardware.Estándar de facto y por defecto en Spring. La opción segura y aburrida.
SCryptMemoria dura, anterior a Argon2N = 2^16, r = 8, p = 1.Alternativa válida si ya lo usas.
PBKDF2-HMAC-SHA256Solo iteracionesDesde 600.000 iteraciones y salt de 16 bytes.Solo cuando lo exige una certificación (FIPS) que no admite los anteriores.
Dos detalles de BCrypt que se preguntan en entrevistas: (1) ignora todo lo que pase de 72 bytes de contraseña, así que si “pre-hasheas” con SHA-512 en hexadecimal estás metiendo 128 caracteres y perdiendo entropía —si necesitas pre-hashear, usa la salida en Base64, o directamente Argon2id—; (2) el salt va dentro de la cadena resultante, no en una columna aparte: por eso matches() funciona sin que tú guardes nada más.

Migrar de algoritmo sin obligar a nadie a cambiar su contraseña

// El prefijo {id} de DelegatingPasswordEncoder permite convivencia y migración perezosa:
// en el momento del login (el ÚNICO momento en que tienes la contraseña en claro) rehasheas.
@Service
public class ServicioAutenticacion {

    private final PasswordEncoder encoder;
    private final RepositorioUsuarios repositorio;

    @Transactional
    public Usuario autenticar(String email, String password) {
        Usuario u = repositorio.porEmail(email)
                .orElseThrow(() -> new BadCredentialsException("Credenciales no válidas"));

        if (!encoder.matches(password, u.getPasswordHash())) {
            throw new BadCredentialsException("Credenciales no válidas");   // mensaje IDÉNTICO
        }

        // Migración transparente: si el hash usa un algoritmo o un coste antiguos, se regraba
        if (encoder.upgradeEncoding(u.getPasswordHash())) {
            u.setPasswordHash(encoder.encode(password));
            repositorio.guardar(u);
        }
        return u;
    }
}
Por qué el mensaje de error debe ser idéntico para “usuario inexistente” y “contraseña incorrecta”: si son distintos, tienes una enumeración de usuarios (A07 del OWASP Top 10). Un atacante puede averiguar qué correos están registrados y usar esa lista para credential stuffing o para phishing dirigido. Cuidado también con el canal lateral del tiempo: si cuando el usuario no existe respondes en 2 ms, pero cuando existe tardas 250 ms hasheando, el atacante también lo sabe. Solución: ejecutar un hash “de compensación” contra un hash constante cuando el usuario no exista.

2.3 Cifrado simétrico: AES-GCM bien hecho

Si necesitas guardar un dato y luego recuperarlo (un IBAN, el token de un proveedor externo, un número de historia clínica), usas cifrado simétrico autenticado (AEAD). En la JCA eso es AES/GCM/NoPadding. Tres reglas no negociables:

  1. Nunca ECB. AES/ECB cifra cada bloque de 16 bytes de forma independiente, así que bloques de texto claro iguales producen bloques cifrados iguales y el patrón del original se ve a través del cifrado (el famoso “pingüino ECB”). Y ojo: Cipher.getInstance("AES") significa ECB en la implementación por defecto de la JDK. Escribe siempre el modo de forma explícita.
  2. IV (nonce) aleatorio y único por mensaje. Reutilizar el par (clave, IV) en GCM es catastrófico: no solo se filtra información del texto claro, sino que permite recuperar la clave de autenticación y falsificar mensajes. El IV no es secreto: se guarda junto al criptograma.
  3. Verifica la etiqueta. GCM ya lo hace: si el criptograma se ha manipulado, doFinal() lanza AEADBadTagException. No captures esa excepción para “seguir con lo que se pueda”.
// ❌ INCORRECTO — el catálogo de errores de cifrado en Java
Cipher c = Cipher.getInstance("AES");                       // = AES/ECB: patrón visible
Cipher d = Cipher.getInstance("AES/CBC/PKCS5Padding");      // sin MAC → maleable, padding oracle
byte[] iv = new byte[12];                                   // IV de ceros
byte[] iv2 = "1234567890ab".getBytes();                     // IV constante: rotura total en GCM
SecretKey k = new SecretKeySpec("claveSuperSecreta1234567890123456".getBytes(), "AES"); // en el código
Cipher e = Cipher.getInstance("DES/CBC/PKCS5Padding");      // DES, 3DES, RC4, Blowfish: obsoletos

// ✅ CORRECTO — AES-256-GCM con IV aleatorio por mensaje y datos asociados
public final class CifradoCampo {

    private static final String TRANSFORMACION = "AES/GCM/NoPadding";
    private static final int LONGITUD_IV = 12;        // 96 bits: el tamaño recomendado por NIST
    private static final int LONGITUD_TAG_BITS = 128; // etiqueta de autenticación completa
    private static final SecureRandom ALEATORIO = new SecureRandom();

    private final SecretKey clave;   // inyectada desde un KMS o desde un secreto de la plataforma

    public CifradoCampo(SecretKey clave) { this.clave = clave; }

    /**
     * Devuelve  iv || criptograma+etiqueta  en Base64 URL-safe.
     * @param datosAsociados contexto NO cifrado pero SÍ autenticado (p. ej. "usuario:4711:iban").
     *                       Impide mover un criptograma válido de una fila a otra.
     */
    public String cifrar(byte[] claro, byte[] datosAsociados) throws GeneralSecurityException {
        byte[] iv = new byte[LONGITUD_IV];
        ALEATORIO.nextBytes(iv);                               // ← nuevo IV en CADA llamada

        Cipher cipher = Cipher.getInstance(TRANSFORMACION);
        cipher.init(Cipher.ENCRYPT_MODE, clave, new GCMParameterSpec(LONGITUD_TAG_BITS, iv));
        cipher.updateAAD(datosAsociados);
        byte[] criptograma = cipher.doFinal(claro);            // incluye la etiqueta al final

        byte[] salida = new byte[iv.length + criptograma.length];
        System.arraycopy(iv, 0, salida, 0, iv.length);
        System.arraycopy(criptograma, 0, salida, iv.length, criptograma.length);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(salida);
    }

    public byte[] descifrar(String base64, byte[] datosAsociados) throws GeneralSecurityException {
        byte[] todo = Base64.getUrlDecoder().decode(base64);
        if (todo.length <= LONGITUD_IV) throw new IllegalArgumentException("criptograma truncado");

        GCMParameterSpec spec = new GCMParameterSpec(LONGITUD_TAG_BITS, todo, 0, LONGITUD_IV);
        Cipher cipher = Cipher.getInstance(TRANSFORMACION);
        cipher.init(Cipher.DECRYPT_MODE, clave, spec);
        cipher.updateAAD(datosAsociados);
        // Si alguien tocó un solo bit → AEADBadTagException. Déjala subir: el dato NO es de fiar.
        return cipher.doFinal(todo, LONGITUD_IV, todo.length - LONGITUD_IV);
    }
}

// Derivar una clave AES a partir de una contraseña (solo si NO tienes KMS)
SecretKeyFactory fabrica = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, 600_000, 256);
SecretKey clave = new SecretKeySpec(fabrica.generateSecret(spec).getEncoded(), "AES");
Límite poco conocido de AES-GCM: con IV aleatorios de 96 bits, NIST recomienda no cifrar más de unos 2³² mensajes con la misma clave, porque la probabilidad de repetir un IV deja de ser despreciable. En un sistema que cifra millones de campos al día, eso significa rotar la clave. Si necesitas volúmenes muy grandes, usa una librería que gestione esto por ti (Google Tink) o AES-GCM-SIV.

2.4 Asimétrico, firmas y certificados

El cifrado asimétrico resuelve el problema del huevo y la gallina: cómo acordar un secreto con alguien con quien nunca has hablado, por un canal que alguien está escuchando. Es lento (miles de veces más que AES), así que no se usa para cifrar datos, sino para tres cosas:

FamiliaTamaño mínimo hoyFirmaNotas prácticas
RSA2048 bits (3072 para datos de larga vida)RSA-PSS, mejor que PKCS#1 v1.5Ubicuo y bien soportado. Claves y firmas grandes, operaciones lentas.
Curva elíptica (ECDSA/ECDH)P-256, equivalente a unos 3072 bits de RSAES256 en JWTClaves y firmas pequeñas, y rápido. Es lo que usa casi todo TLS moderno.
Ed25519 / EdDSA256 bitsEd25519 (JDK 15+)Rápido, determinista y sin parámetros que puedas equivocar. La mejor opción para firmar hoy.
// Firmar y verificar: el patrón es siempre el mismo
KeyPairGenerator gen = KeyPairGenerator.getInstance("Ed25519");
KeyPair par = gen.generateKeyPair();

Signature firmador = Signature.getInstance("Ed25519");
firmador.initSign(par.getPrivate());
firmador.update(mensaje);
byte[] firma = firmador.sign();                        // solo el dueño de la privada puede producirla

Signature verificador = Signature.getInstance("Ed25519");
verificador.initVerify(par.getPublic());               // la pública se puede publicar sin miedo
verificador.update(mensaje);
boolean valida = verificador.verify(firma);            // ← ¿compruebas el RESULTADO? Es obligatorio

if (!valida) throw new SecurityException("firma no válida");

// ⚠️ El fallo real más común con firmas no es criptográfico: es IGNORAR el booleano de verify()
//    o capturar la excepción y continuar. Ese "if" salva sistemas enteros.

Certificados y cadena de confianza

Una clave pública por sí sola no dice nada: ¿de quién es? Un certificado X.509 es una clave pública, más una identidad (el subject, hoy siempre en la extensión SAN), más un periodo de validez, todo firmado por una autoridad de certificación (CA). Tu navegador confía en unas ciento cincuenta CA raíz preinstaladas; a partir de ahí, la confianza se hereda por la cadena.

CADENA DE CONFIANZA

 ┌──────────────────────────┐
 │  CA raíz (autofirmada)   │  Está en el truststore del sistema o de la JVM (cacerts).
 │  clave privada offline   │  Si esta se compromete, se cae todo el sistema.
 └────────────┬─────────────┘
              │ firma
 ┌────────────▼─────────────┐
 │  CA intermedia           │  Firma el día a día. El servidor DEBE enviarla también
 └────────────┬─────────────┘  (olvidarla = "certificado no válido" solo en algunos clientes).
              │ firma
 ┌────────────▼─────────────┐
 │ Certificado del servidor │  SAN = api.miempresa.com · validez 90 días (ACME/Let's Encrypt)
 └──────────────────────────┘

VALIDACIÓN QUE HACE EL CLIENTE (todas, no solo la primera):
 1. La firma de cada eslabón es correcta y llega a una raíz de confianza.
 2. Ninguno está caducado ni es todavía futuro (¡reloj del contenedor sincronizado!).
 3. El nombre solicitado coincide con una SAN del certificado.
 4. El certificado no está revocado (OCSP stapling o CRL).
 5. Los usos de clave y las restricciones básicas son coherentes.

❌ Desactivar el paso 1 o el 3 (un "TrustManager que acepta todo", un "HostnameVerifier que
   devuelve true") convierte TLS en cifrado SIN autenticación: cualquiera puede ponerse en
   medio. Es la vulnerabilidad más frecuente en clientes móviles y en integraciones internas
   "provisionales".

2.5 TLS: el handshake, mTLS y cómo se configura

HANDSHAKE DE TLS 1.3 (1 RTT: la mitad que en TLS 1.2)

    CLIENTE                                                       SERVIDOR
       │                                                              │
       │──── ClientHello ────────────────────────────────────────────►│
       │      · versiones soportadas (1.3)                            │
       │      · cipher suites (TLS_AES_128_GCM_SHA256…)                │
       │      · key_share: mi parte pública de ECDHE (X25519)          │
       │      · SNI: api.miempresa.com   · ALPN: h2                    │
       │                                                              │
       │◄─── ServerHello ─────────────────────────────────────────────│
       │      · cipher suite elegida                                  │
       │      · key_share: su parte pública de ECDHE                   │
       │      ══ desde aquí TODO va cifrado ══                         │
       │      · {EncryptedExtensions}                                  │
       │      · {Certificate}        ← cadena X.509                    │
       │      · {CertificateVerify}  ← firma que demuestra que tiene   │
       │                               la clave privada del cert.     │
       │      · {Finished}                                             │
       │                                                              │
       │   [el cliente valida la cadena, el nombre y las fechas]      │
       │                                                              │
       │──── {Finished} ─────────────────────────────────────────────►│
       │──── {datos de aplicación: GET /pedidos} ────────────────────►│

CLAVE DEL DISEÑO: la clave de sesión sale de ECDHE, es EFÍMERA y nunca viaja por la red.
Aunque un atacante grabe todo el tráfico y años después robe la clave privada del servidor,
NO puede descifrar lo grabado → forward secrecy, obligatoria en TLS 1.3.

TLS 1.3 eliminó: RSA como transporte de clave, Diffie-Hellman estático, la renegociación,
la compresión (CRIME), MD5 y SHA-1, RC4, los modos CBC y todos los cipher suites no AEAD.
→ "usa TLS 1.3, y 1.2 solo como respaldo" es una configuración correcta y suficiente.

mTLS (TLS mutuo) añade un paso: el servidor pide también un certificado al cliente y verifica su cadena. Deja de haber “clientes anónimos con token”; hay identidades criptográficas a nivel de conexión. Es el mecanismo estándar para servicio a servicio y la base de las mallas de servicio (Istio, Linkerd), que lo aplican de forma transparente. Véase el módulo 08.

# application.yml — TLS en Spring Boot 3.x con SSL bundles (3.1+)
server:
  port: 8443
  ssl:
    enabled: true
    bundle: servidor
    client-auth: none          # need = mTLS obligatorio · want = opcional
spring:
  ssl:
    bundle:
      jks:
        servidor:
          key:
            alias: api
          keystore:
            location: file:/etc/tls/keystore.p12
            password: ${KEYSTORE_PASSWORD}      # NUNCA literal en el fichero
            type: PKCS12
          truststore:                            # solo necesario para mTLS
            location: file:/etc/tls/truststore.p12
            password: ${TRUSTSTORE_PASSWORD}
            type: PKCS12
      # Un bundle PEM es más cómodo con cert-manager en Kubernetes:
      pem:
        interno:
          keystore:
            certificate: file:/etc/tls/tls.crt
            private-key: file:/etc/tls/tls.key
          truststore:
            certificate: file:/etc/tls/ca.crt
# Generar material de prueba (SOLO para desarrollo local)
keytool -genkeypair -alias api -keyalg EC -groupname secp256r1 \
        -storetype PKCS12 -keystore keystore.p12 \
        -validity 365 -dname "CN=localhost" \
        -ext "SAN=dns:localhost,ip:127.0.0.1"

# Inspeccionar lo que sirve un servidor de verdad
openssl s_client -connect api.miempresa.com:443 -servername api.miempresa.com -tls1_3 < /dev/null
openssl s_client -connect api.miempresa.com:443 -showcerts 2>/dev/null \
  | openssl x509 -noout -dates -text

# Ver qué versiones y suites acepta (auditoría rápida)
nmap --script ssl-enum-ciphers -p 443 api.miempresa.com
# En producción, la referencia pública es SSL Labs (ssllabs.com/ssltest). Objetivo: A o A+.
Por qué normalmente NO configuras TLS en Spring Boot en producción: en una arquitectura con Kubernetes o con un balanceador gestionado, el TLS termina en el ingress o en el balanceador. Allí están el certificado, su renovación automática (cert-manager, ACM), HSTS, HTTP/2 y HTTP/3, y el WAF. La aplicación escucha HTTP en claro dentro de la red del clúster. Eso es correcto si el tráfico interno también está protegido (malla de servicio con mTLS) o el modelo de amenazas lo acepta. Lo que tienes que configurar en la aplicación es esto:
server:
  # Detrás de un proxy, para que Spring sepa el esquema y la IP originales y genere URLs https
  forward-headers-strategy: framework      # confía en X-Forwarded-* (el proxy DEBE sanearlos)
  tomcat:
    remoteip:
      remote-ip-header: x-forwarded-for
      protocol-header: x-forwarded-proto
  error:
    include-stacktrace: never              # jamás un stack trace al cliente
    include-message: never
    include-binding-errors: never
  servlet:
    session:
      cookie:
        http-only: true                    # el JavaScript no puede leer la cookie
        secure: true                       # solo se envía por HTTPS
        same-site: lax                     # mitiga CSRF; strict si no necesitas navegación entrante
// Redirigir HTTP a HTTPS y forzar HSTS lo hace normalmente el ingress; en la aplicación:
http.requiresChannel(canal -> canal.anyRequest().requiresSecure())
    .headers(h -> h.httpStrictTransportSecurity(hsts -> hsts
            .includeSubDomains(true)
            .preload(true)
            .maxAgeInSeconds(31_536_000)));   // 1 año

// ⚠️ HSTS es una decisión IRREVERSIBLE durante max-age para los navegadores que ya lo vieron.
//    No lo actives con includeSubDomains sobre un dominio del que algún subdominio siga en HTTP.

2.6 Aleatoriedad, comparaciones y gestión de claves

// ❌ Random NO es seguro: es un generador congruencial de 48 bits, predecible con dos salidas.
String token = Long.toHexString(new Random().nextLong());            // adivinable
String otro  = UUID.nameUUIDFromBytes(email.getBytes()).toString();  // UUID v3: DETERMINISTA
int codigo   = (int) (Math.random() * 1_000_000);                    // OTP predecible

// ✅ SecureRandom para todo lo que deba ser impredecible
private static final SecureRandom RNG = new SecureRandom();   // reutilízalo: es thread-safe

public static String tokenOpaco() {
    byte[] bytes = new byte[32];                              // 256 bits de entropía
    RNG.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
String idPublico = UUID.randomUUID().toString();              // UUID v4: usa SecureRandom por dentro

// getInstanceStrong() para material de clave de larga vida
SecureRandom fuerte = SecureRandom.getInstanceStrong();

// ✅ Comparación en tiempo constante: obligatoria para tokens, MAC y códigos OTP.
// equals() sale en el primer byte distinto → filtra información por el TIEMPO de respuesta.
boolean malo  = tokenEsperado.equals(tokenRecibido);                       // ❌ canal lateral
boolean bueno = MessageDigest.isEqual(tokenEsperado.getBytes(UTF_8),
                                      tokenRecibido.getBytes(UTF_8));      // ✅ tiempo constante

// Y no guardes tokens de sesión ni API keys en claro: guarda su SHA-256 (son valores de alta
// entropía, no necesitan bcrypt) y compara hashes. Si roban la tabla, no roban tokens usables.
Aspecto de la gestión de clavesMalBien
Dónde vive la claveUna constante en el código, versionada en el repositorio.KMS gestionado (AWS KMS, Azure Key Vault, Google Cloud KMS), Vault o HSM. La clave maestra nunca sale del KMS.
Cómo cifras muchos datosUna sola clave para todo, para siempre.Envelope encryption: el KMS protege la clave maestra y cada dato se cifra con una DEK única que se guarda cifrada junto al dato.
RotaciónNunca, porque “funciona”.Programada (anual) y bajo demanda tras un incidente. Versiona las claves con un keyId para poder descifrar lo antiguo mientras cifras con lo nuevo.
SeparaciónLa misma clave en desarrollo y en producción.Clave distinta por entorno y por propósito (cifrado ≠ firma ≠ sesiones). Desarrollo jamás puede descifrar producción.
AuditoríaSin registro de uso.Cada operación del KMS queda en el log de auditoría, con alertas por uso anómalo.
Qué NO hacer, resumido en cinco frases: no implementes criptografía; no uses ECB ni un IV repetido; no cifres lo que deberías hashear (contraseñas) ni hashees lo que deberías cifrar (datos que necesitas recuperar); no pongas claves en el código, en el application.yml del repositorio ni en una imagen Docker; y no desactives la validación de certificados “temporalmente”, porque eso siempre acaba en producción.

Checklist — criptografía

3. Spring Security 6: arquitectura y configuración moderna

3.1 La cadena de filtros: dónde ocurre todo

Spring Security es, en esencia, un solo filtro de servlet registrado en el contenedor (springSecurityFilterChain, envuelto en DelegatingFilterProxy) que delega en una o varias SecurityFilterChain. Cada cadena es una lista ordenada de filtros, y ese orden lo explica casi todo: por qué el CORS debe ir antes que la autorización, por qué el CSRF bloquea tu POST antes de llegar al controlador, o por qué un 403 aparece “sin que se ejecute tu código”.

PETICIÓN HTTP
     │
     ▼
FilterChainProxy   ── elige la PRIMERA SecurityFilterChain cuyo securityMatcher encaje
     │
     ▼   (orden real, simplificado a los filtros que de verdad te afectan)
┌────────────────────────────────────────────────────────────────────────────────────┐
│ 1  DisableEncodeUrlFilter          evita el jsessionid en la URL (fuga de sesión)  │
│ 2  WebAsyncManagerIntegrationFilter propaga el SecurityContext a peticiones async  │
│ 3  SecurityContextHolderFilter     CARGA el SecurityContext (de la sesión) y lo    │
│                                    limpia al final. En SS6 ya NO guarda solo;      │
│                                    el guardado es explícito (repository.saveContext)│
│ 4  HeaderWriterFilter              escribe CSP, X-Frame-Options, HSTS, nosniff…    │
│ 5  CorsFilter                      preflight OPTIONS y cabeceras Access-Control-*  │
│ 6  CsrfFilter                      valida el token CSRF en POST/PUT/PATCH/DELETE   │
│ 7  LogoutFilter                    /logout: invalida sesión y limpia contexto      │
│ 8  OAuth2AuthorizationRequestRedirectFilter   inicia el flujo Authorization Code   │
│ 9  UsernamePasswordAuthenticationFilter       procesa el POST del formulario login │
│10  DefaultLoginPageGeneratingFilter           la página de login "gratis"          │
│11  ConcurrentSessionFilter         control de sesiones simultáneas                 │
│12  BearerTokenAuthenticationFilter LEE "Authorization: Bearer …" (resource server)  │
│13  BasicAuthenticationFilter       HTTP Basic                                      │
│14  RequestCacheAwareFilter         reenvía a la URL que pedías antes del login     │
│15  SecurityContextHolderAwareRequestFilter   integra con la API de Servlet         │
│16  RememberMeAuthenticationFilter  cookie de "recuérdame"                          │
│17  AnonymousAuthenticationFilter   si NADIE autenticó, crea un Authentication      │
│                                    "anonymousUser" → nunca hay null a partir de aquí│
│18  SessionManagementFilter         protección de fijación de sesión                │
│19  ExceptionTranslationFilter      ⇦ CAPTURA las excepciones de los filtros de     │
│                                       abajo: AuthenticationException → 401 vía     │
│                                       AuthenticationEntryPoint                     │
│                                       AccessDeniedException  → 403 vía             │
│                                       AccessDeniedHandler                          │
│20  AuthorizationFilter             ⇦ APLICA authorizeHttpRequests (en SS5 era      │
│                                       FilterSecurityInterceptor). Lanza            │
│                                       AccessDeniedException, que sube al 19.       │
└────────────────────────────────────────────────────────────────────────────────────┘
     │  si nadie ha cortado la petición
     ▼
DispatcherServlet ──► @RestController ──► @Service con @PreAuthorize (segunda barrera)

CLAVE: el 19 envuelve al 20. Por eso un fallo de autorización se traduce en 403 "solo",
sin llegar a tu @ControllerAdvice. Y por eso el orden 5 antes que 20 permite que el
preflight OPTIONS del navegador funcione aunque el endpoint requiera autenticación.

3.2 Los objetos que tienes que conocer

ComponenteResponsabilidadLo que debes recordar
SecurityContextHolder Guarda el SecurityContext de la petición en curso. Usa un ThreadLocal por defecto. No se propaga a hilos que crees tú: con @Async, ExecutorService o hilos virtuales necesitas DelegatingSecurityContext(Executor|Runnable). Y siempre se limpia al final de la petición (si no, el siguiente usuario del hilo del pool heredaría la identidad).
Authentication El “quién”: getPrincipal(), getCredentials(), getAuthorities(), isAuthenticated(). Antes de autenticar es una petición de autenticación (con la contraseña); después es el resultado (sin credenciales, con authorities). Nunca es null tras el filtro anónimo.
AuthenticationManager Punto de entrada de la autenticación. La implementación normal es ProviderManager. Recorre sus AuthenticationProvider y usa el primero que soporte el tipo de Authentication.
AuthenticationProvider Sabe validar un mecanismo (usuario/contraseña, LDAP, OIDC, mTLS, API key…). DaoAuthenticationProvider = UserDetailsService + PasswordEncoder.
UserDetailsService loadUserByUsername(String): recupera el usuario y su hash. Debe lanzar UsernameNotFoundException; Spring la traduce a BadCredentialsException para no filtrar si el usuario existe.
GrantedAuthority Un permiso, como cadena. Nada más. hasRole("ADMIN") busca literalmente la authority ROLE_ADMIN; hasAuthority("X") busca X exacto. El prefijo ROLE_ es pura convención, pero obligatoria si usas hasRole.
AuthorizationManager<T> Decide si un Authentication puede acceder a un objeto seguro. En Spring Security 6 sustituye a AccessDecisionManager y AccessDecisionVoter, que fueron eliminados. Es la extensión natural para reglas de dominio.
// Obtener el usuario actual: tres formas, de mejor a peor
// ✅ 1. Inyección en el controlador: testeable y explícito
@GetMapping("/perfil")
public PerfilDto perfil(@AuthenticationPrincipal Jwt jwt) {
    return servicio.perfilDe(jwt.getSubject());
}

// ✅ 2. En un servicio, cuando de verdad hace falta el contexto
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String usuario = auth.getName();
boolean esAdmin = auth.getAuthorities().stream()
        .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));

// ❌ 3. Pasar el "userId" como parámetro que envía el cliente
@GetMapping("/pedidos")                                    // el cliente decide QUIÉN es → suplantación
public List<PedidoDto> mios(@RequestParam String usuarioId) { … }

// Propagar la identidad a otro hilo (necesario con @Async y con hilos virtuales propios)
Executor seguro = new DelegatingSecurityContextExecutor(Executors.newVirtualThreadPerTaskExecutor());
seguro.execute(() -> log.info("Aquí sí veo a {}",
        SecurityContextHolder.getContext().getAuthentication().getName()));

3.3 Configuración moderna: componentes y lambda DSL

WebSecurityConfigurerAdapter ya no existe. Fue marcado como obsoleto en Spring Security 5.7 y eliminado en 6.0. Si encuentras un tutorial con extends WebSecurityConfigurerAdapter, configure(HttpSecurity), authorizeRequests(), antMatchers() o and() encadenado, es material de antes de 2022: no compilará. El modelo actual es declarar beans (SecurityFilterChain, UserDetailsService, PasswordEncoder, AuthenticationManager) y configurar con lambdas.
// ❌ ANTIGUO (Spring Security 5.x, ya eliminado) — solo para que lo reconozcas
@Configuration
public class SeguridadVieja extends WebSecurityConfigurerAdapter {
    @Override protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and().formLogin()
            .and().csrf().disable();
    }
}

// ✅ MODERNO (Spring Boot 3.x / Spring Security 6.x)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity            // activa @PreAuthorize/@PostAuthorize (prePost está en true por defecto)
public class ConfiguracionSeguridad {

    @Bean
    SecurityFilterChain cadenaWeb(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // El ORDEN IMPORTA: se evalúa de arriba abajo y gana la PRIMERA que encaja.
                .requestMatchers("/", "/css/**", "/js/**", "/webjars/**").permitAll()
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")           // busca ROLE_ADMIN
                .requestMatchers(HttpMethod.POST, "/pedidos").hasAuthority("pedidos:crear")
                .requestMatchers("/interno/**").hasIpAddress("10.0.0.0/8")
                .anyRequest().authenticated())                            // ← DENY BY DEFAULT: siempre al final
            .formLogin(login -> login
                .loginPage("/login").permitAll()
                .defaultSuccessUrl("/panel", false)
                .failureHandler(this::fallosDeLogin))                     // audita y no filtra el motivo
            .logout(logout -> logout
                .logoutUrl("/logout")                                     // por defecto exige POST con CSRF
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID")
                .logoutSuccessUrl("/?salida"))
            .sessionManagement(sesion -> sesion
                .sessionFixation(SessionFixationConfigurer::changeSessionId)  // por defecto ya
                .maximumSessions(3).maxSessionsPreventsLogin(false))
            .headers(h -> h
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                        "default-src 'self'; object-src 'none'; base-uri 'self'; " +
                        "frame-ancestors 'none'; form-action 'self'"))
                .frameOptions(FrameOptionsConfig::deny)
                .referrerPolicy(r -> r.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
                .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)
                                                         .maxAgeInSeconds(31_536_000))
                .permissionsPolicy(p -> p.policy("geolocation=(), camera=(), microphone=()")))
            .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    UserDetailsService usuarios(RepositorioUsuarios repositorio) {
        return email -> repositorio.porEmail(email)
                .map(u -> User.withUsername(u.getEmail())
                        .password(u.getPasswordHash())        // ya viene con {bcrypt}…
                        .authorities(u.getAuthorities())
                        .accountLocked(u.estaBloqueado())
                        .credentialsExpired(u.debeCambiarPassword())
                        .build())
                .orElseThrow(() -> new UsernameNotFoundException("no encontrado"));
    }

    private void fallosDeLogin(HttpServletRequest req, HttpServletResponse res,
                               AuthenticationException ex) throws IOException {
        log.warn("login_fallido usuario={} ip={} motivo={}",
                 req.getParameter("username"), req.getRemoteAddr(), ex.getClass().getSimpleName());
        res.sendRedirect("/login?error");     // al usuario, un mensaje genérico
    }
}
El error de configuración más peligroso: poner una regla permisiva antes de una restrictiva. requestMatchers("/**").permitAll() seguido de requestMatchers("/admin/**").hasRole("ADMIN") deja el panel de administración abierto a todo el mundo, porque la primera regla ya encajó. Igual de grave: requestMatchers("/api/**").permitAll() “mientras desarrollamos”. Regla mnemotécnica: de lo más específico a lo más general, y anyRequest().authenticated() siempre al final. Y escribe un test que lo demuestre.

3.4 Varias cadenas de filtros con securityMatcher

Es el caso real de casi cualquier aplicación: una API stateless con JWT y una parte web con formulario de login, cada una con reglas incompatibles (CSRF sí/no, sesión sí/no). No se resuelve con if: se declaran dos beans ordenados.

@Configuration
@EnableWebSecurity
public class ConfiguracionMultiple {

    @Bean
    @Order(1)                                    // se evalúa PRIMERO
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")          // esta cadena solo atiende /api/**
            .csrf(AbstractHttpConfigurer::disable)          // API con Bearer: no aplica (ver 3.7)
            .cors(cors -> cors.configurationSource(fuenteCors()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()   // preflight
                .requestMatchers("/api/publico/**").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())   // 401 + WWW-Authenticate
                .accessDeniedHandler(new BearerTokenAccessDeniedHandler()))            // 403 correcto
            .build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain actuadorInterno(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())      // requiere spring-boot-actuator
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                .anyRequest().hasRole("OPERADOR"))
            .httpBasic(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    @Order(3)                                    // cajón de sastre: TODO lo demás
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/registro", "/css/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .build();                            // CSRF y sesión ACTIVADOS: es lo correcto aquí
    }
}
Recursos estáticos y errores: no uses WebSecurityCustomizer con ignoring() salvo que sepas exactamente lo que haces: esas rutas quedan completamente fuera de Spring Security, sin cabeceras de seguridad y sin posibilidad de aplicar nada después. Es más seguro un permitAll() dentro de la cadena.

3.5 Autorización por método: la barrera que de verdad protege

La autorización por URL es necesaria pero insuficiente. Es frágil (una ruta nueva, un @RequestMapping refactorizado, una barra final, un alias) y no protege los caminos que no pasan por HTTP: un consumidor de Kafka, una tarea programada, un resolver de GraphQL o una llamada interna entre servicios. La autorización en la capa de servicio viaja con el código, no con la ruta.

@Service
@Transactional
public class ServicioPedidos {

    // Roles y authorities
    @PreAuthorize("hasRole('ADMIN')")
    public void cancelarTodos(LocalDate dia) { … }

    @PreAuthorize("hasAnyAuthority('pedidos:leer', 'pedidos:administrar')")
    public List<PedidoDto> listar(Pageable pagina) { … }

    // SpEL con los ARGUMENTOS del método (#nombre requiere -parameters al compilar,
    // que Spring Boot ya activa; si no, usa #p0 / @P("id"))
    @PreAuthorize("#nif == authentication.name or hasRole('SOPORTE')")
    public ClienteDto porNif(String nif) { … }

    // Con un bean propio: la forma limpia de expresar reglas de dominio
    @PreAuthorize("@guardiaPedidos.puedeVer(#id, authentication)")
    public PedidoDto ver(Long id) { … }

    // POSTautorización: la decisión depende del OBJETO DEVUELTO.
    // ⚠️ El método YA SE EJECUTÓ: si tenía efectos secundarios, ya ocurrieron.
    //    Con @Transactional se hace rollback, pero un email enviado no vuelve.
    @PostAuthorize("returnObject.clienteNif == authentication.name or hasRole('SOPORTE')")
    public PedidoDto detalle(Long id) { … }

    // Filtrar colecciones
    @PostFilter("filterObject.clienteNif == authentication.name")   // filtra lo DEVUELTO
    public List<PedidoDto> misPedidos() { … }

    @PreFilter("filterObject.importe <= 1000 or hasRole('ADMIN')")  // filtra el ARGUMENTO
    public void importar(List<LineaImportacion> lineas) { … }
}

// El bean de reglas: lógica de negocio testeable, no SpEL kilométrico
@Component("guardiaPedidos")
public class GuardiaPedidos {

    private final RepositorioPedidos repositorio;

    public boolean puedeVer(Long pedidoId, Authentication auth) {
        if (tieneRol(auth, "ROLE_SOPORTE")) return true;
        return repositorio.existePorIdYClienteNif(pedidoId, auth.getName());  // 1 consulta, sin cargar todo
    }

    private static boolean tieneRol(Authentication auth, String rol) {
        return auth.getAuthorities().stream().anyMatch(a -> rol.equals(a.getAuthority()));
    }
}
Trampa de los proxies (la misma del módulo 01): @PreAuthorize se aplica con un proxy AOP. Por tanto no funciona en métodos private, final o static, ni en llamadas internas (this.otroMetodo()), porque no pasan por el proxy. Si necesitas asegurar un método interno, extráelo a otro bean. Con @EnableMethodSecurity puedes activar @AuthorizeReturnObject y proxies AspectJ, pero lo pragmático es diseñar los servicios para que la frontera pública sea la que se protege.

3.6 Autorización de dominio: “¿puede ver ESTE pedido?”

Esta es la pregunta que distingue una aplicación segura de una vulnerable, y la que Spring Security no puede responder por ti: solo tú sabes qué significa “ser dueño” en tu negocio. Y hay una forma correcta y una incorrecta de implementarla.

// ❌ VULNERABLE (IDOR): autenticado ≠ autorizado. Cambiando el id, ves pedidos ajenos.
@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id) {
    return mapper.aDto(repositorio.findById(id).orElseThrow());
}

// ⚠️ MEJOR, PERO FRÁGIL: comprueba después de cargar. Funciona, pero (a) se olvida en el
// siguiente endpoint, (b) carga datos que el usuario no puede ver, (c) el 403 confirma que existe.
@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
    Pedido p = repositorio.findById(id).orElseThrow(PedidoNoEncontrado::new);
    if (!p.getClienteNif().equals(jwt.getSubject())) throw new AccessDeniedException("no es tuyo");
    return mapper.aDto(p);
}

// ✅ CORRECTO: la propiedad forma parte de la CONSULTA. No se puede olvidar,
//    no carga datos ajenos y devuelve 404 en lugar de 403 (no confirma la existencia).
public interface RepositorioPedidos extends JpaRepository<Pedido, Long> {
    Optional<Pedido> findByIdAndClienteNif(Long id, String nif);
    Page<Pedido> findAllByClienteNif(String nif, Pageable pagina);
}

@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
    return repositorio.findByIdAndClienteNif(id, jwt.getSubject())
            .map(mapper::aDto)
            .orElseThrow(PedidoNoEncontrado::new);     // → 404, mismo resultado exista o no
}

// ✅ Y cuando las reglas son ricas (roles + propiedad + delegación + estado), un AuthorizationManager
@Component
public class PedidoAuthorizationManager
        implements AuthorizationManager<RequestAuthorizationContext> {

    private final RepositorioPedidos repositorio;

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication,
                                       RequestAuthorizationContext contexto) {
        Authentication auth = authentication.get();
        String id = contexto.getVariables().get("id");        // variable de la plantilla de ruta
        if (id == null || auth == null || !auth.isAuthenticated()) {
            return new AuthorizationDecision(false);          // fallar de forma segura
        }
        boolean permitido = repositorio.puedeAcceder(Long.valueOf(id), auth.getName())
                || auth.getAuthorities().stream()
                       .anyMatch(a -> "ROLE_SOPORTE".equals(a.getAuthority()));
        return new AuthorizationDecision(permitido);
    }
}

// Enganchado en la cadena con la variable de ruta disponible:
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/pedidos/{id}").access(pedidoAuthorizationManager)
    .anyRequest().authenticated())

Cuando los permisos son por instancia y variables en el tiempo (“Ana puede editar la carpeta 42; Luis solo leerla”), lo que necesitas es una ACL: una tabla (tipo_recurso, id_recurso, sujeto, permiso) consultada en la propia query, o el módulo spring-security-acl. Antes de adoptarlo, mide: en el 90% de los casos basta una columna de propietario y un par de roles, y una ACL mal diseñada es una fuente inagotable de consultas N+1.

3.7 CSRF: qué es y cuándo hace falta de verdad

CSRF (Cross-Site Request Forgery) explota que el navegador adjunta las cookies de un dominio automáticamente, sin importar quién originó la petición. Si tu sesión vive en una cookie, una página maliciosa puede provocar acciones en tu nombre sin leer nada de tu sitio.

ANATOMÍA DE UN ATAQUE CSRF

 1. Ana inicia sesión en banco.com  →  el navegador guarda la cookie JSESSIONID.
 2. Ana visita, en otra pestaña, un foro donde alguien ha publicado esto:

      <form action="https://banco.com/transferencia" method="POST">
        <input type="hidden" name="destino" value="ES00-atacante">
        <input type="hidden" name="importe" value="5000">
      </form>
      <script>document.forms[0].submit()</script>

 3. El navegador envía el POST a banco.com  Y ADJUNTA LA COOKIE de Ana.
 4. banco.com ve una sesión válida  →  ejecuta la transferencia.

 El atacante NUNCA lee la respuesta (la política del mismo origen se lo impide).
 No necesita leerla: le basta con que el EFECTO ocurra.

DEFENSA (token sincronizador): el servidor exige, en cada petición que cambia estado,
un valor secreto que SOLO puede conocer quien haya podido LEER una página del sitio.
El atacante puede provocar la petición, pero no puede leer nada → no tiene el token.
Cómo transporta el cliente la credencial¿Hace falta CSRF?Por qué
Cookie de sesión (aplicación web clásica o SPA con cookie)SÍ, obligatorioEl navegador la envía sola en peticiones entre sitios.
Authorization: Bearer <jwt> en una cabecera puesta por JavaScriptNOEl navegador nunca añade esa cabecera por su cuenta; el atacante no puede fabricarla desde otro origen.
JWT guardado en una cookieEs una cookie: se comporta como una cookie. El “JWT no necesita CSRF” solo aplica a la cabecera.
HTTP Basic con navegadorEl navegador reenvía las credenciales automáticamente igual que una cookie.
Cliente servidor-a-servidor (sin navegador)NONo hay un navegador que adjunte credenciales ni un usuario al que engañar.
// ✅ Web con sesión: CSRF ACTIVADO (es el valor por defecto: no lo toques).
// Thymeleaf inserta el campo _csrf automáticamente en los <form th:action>.

// ✅ SPA que se autentica con cookie y lee el token del DOM/cookie:
http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())  // cookie XSRF-TOKEN legible
        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));
// El cliente lee la cookie XSRF-TOKEN y la reenvía en la cabecera X-XSRF-TOKEN.
// ⚠️ Spring Security 6 usa por defecto XorCsrfTokenRequestAttributeHandler (protección contra
//    BREACH) y carga el token de forma diferida. Para SPAs, la documentación oficial describe
//    exactamente este SpaCsrfTokenRequestHandler; si copias una receta de SS5 verás 403 fantasma.

// ✅ API stateless con Bearer: desactivar CSRF es CORRECTO y no es una excusa.
http.csrf(AbstractHttpConfigurer::disable)
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

// ⚠️ Si desactivas CSRF en toda la aplicación "porque daba 403 en Postman" y la parte web usa
//    sesión, acabas de introducir una vulnerabilidad real. Sepáralo en dos cadenas (sección 3.4).
SameSite es defensa en profundidad, no sustituto: SameSite=Lax (el comportamiento por defecto en los navegadores actuales) impide que la cookie viaje en un POST entre sitios, lo que ya frena el ataque del ejemplo. Aun así, mantén el token CSRF: SameSite no te protege de un subdominio comprometido (para las cookies, foo.miempresa.com es “mismo sitio”), ni de clientes antiguos, ni de operaciones que cambian estado por GET (que, por cierto, no deberías tener: si un GET cambia estado, ese es el bug de fondo).

3.8 CORS: lo que casi todo el mundo configura mal

Primero, el malentendido de base: CORS no es una protección de tu servidor, es una relajación controlada de la política del mismo origen del navegador. Un curl o un script en Python ignoran CORS por completo. Abrir CORS no “abre” tu API a los atacantes (ya estaba abierta a herramientas no-navegador); lo que hace es permitir que páginas web de otros orígenes lean tus respuestas usando las credenciales del usuario.

// ✅ Configuración correcta y explícita
@Bean
CorsConfigurationSource fuenteCors() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.miempresa.com",
                                     "https://admin.miempresa.com"));   // lista cerrada
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
    config.setExposedHeaders(List.of("X-Total-Count", "Location"));      // lo que el JS puede LEER
    config.setAllowCredentials(true);                                    // solo si usas cookies
    config.setMaxAge(Duration.ofMinutes(30));                            // cachea el preflight
    var fuente = new UrlBasedCorsConfigurationSource();
    fuente.registerCorsConfiguration("/api/**", config);
    return fuente;
}
// Y engánchalo en la cadena (si no, el CorsFilter de Security no se entera):
http.cors(cors -> cors.configurationSource(fuenteCors()));
Error típico de CORSQué pasaCorrección
allowedOrigins("*") + allowCredentials(true)Combinación prohibida por la especificación: Spring lanza IllegalArgumentException al arrancar.Lista explícita de orígenes, o allowedOriginPatterns si necesitas comodines.
Reflejar el Origin recibido en la respuestaEquivale a permitir cualquier origen con credenciales: fuga total de datos del usuario a cualquier web.Comparar contra una lista blanca antes de responder.
allowedOriginPatterns("https://*.miempresa.com") sin pensarSi cualquiera puede crear un subdominio (hosting de clientes, S3, GitHub Pages corporativo), le has dado acceso.Enumerar los orígenes reales.
CORS en @CrossOrigin del controlador y también en la cadenaConfiguraciones que se contradicen y un preflight que a veces falla.Un único punto: el CorsConfigurationSource.
El preflight OPTIONS devuelve 401El navegador ni siquiera intenta la petición real; en la consola se ve como “error de CORS”.El CorsFilter va antes del AuthorizationFilter si usas http.cors(...); si no, permitAll() explícito para OPTIONS.

3.9 Cabeceras de seguridad, sesión y errores

CabeceraContra qué protegeValor recomendado
Content-Security-PolicyXSS: define de dónde puede cargarse y ejecutarse código.default-src 'self'; script-src 'self' 'nonce-…'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. Evita unsafe-inline y unsafe-eval.
X-Frame-Options / frame-ancestorsClickjacking: tu página embebida en un iframe invisible.DENY (Spring lo pone por defecto). frame-ancestors de CSP es el sustituto moderno y tiene prioridad.
X-Content-Type-OptionsMIME sniffing: que el navegador ejecute como script algo que subiste como imagen.nosniff (por defecto en Spring Security).
Referrer-PolicyFuga de URLs con tokens o identificadores en el Referer.strict-origin-when-cross-origin o no-referrer.
Strict-Transport-SecurityDegradación a HTTP y stripping de TLS.max-age=31536000; includeSubDomains (y preload si estás seguro).
Cache-ControlDatos privados cacheados en un proxy o en el disco del navegador.no-store en respuestas con datos personales (Spring Security lo pone agresivo por defecto).
Permissions-PolicyAcceso innecesario a cámara, micrófono, geolocalización.geolocation=(), camera=(), microphone=()
Sobre X-XSS-Protection: Spring Security 6 la envía como 0, es decir, desactiva el filtro anti-XSS heredado de los navegadores. Es lo correcto: ese filtro llegó a introducir vulnerabilidades propias y todos los navegadores modernos lo han retirado. La protección real hoy es CSP más escapado correcto en la plantilla.

Gestión de sesión

FIJACIÓN DE SESIÓN (session fixation) — por qué Spring cambia el id al autenticarse

 1. El atacante obtiene un JSESSIONID válido (basta con visitar el sitio) → "SID=ABC".
 2. Consigue que la víctima use ESE id (enlace con el id en la URL, cookie inyectada
    desde un subdominio, XSS…).
 3. La víctima se autentica. Si el servidor CONSERVA el id ABC, ahora ABC está
    asociado a una sesión autenticada… y el atacante también tiene ABC.

 DEFENSA: al autenticarse, se genera un id NUEVO (changeSessionId, por defecto en
 Spring Security desde la versión 4 sobre Servlet 3.1+). El id antiguo queda inútil.
 Complementos: nunca el id en la URL (DisableEncodeUrlFilter), cookie HttpOnly+Secure.
# Sesión en Redis: obligatorio si escalas horizontalmente sin sesiones "pegajosas".
# Dependencia: spring-session-data-redis
spring:
  session:
    store-type: redis
    timeout: 30m
    redis:
      namespace: pedidos:sesion
      flush-mode: on-save
  data:
    redis:
      host: ${REDIS_HOST}
      ssl:
        enabled: true
server:
  servlet:
    session:
      cookie:
        name: SESION            # renombrar oculta la tecnología (mínima ofuscación, gratis)
        http-only: true
        secure: true
        same-site: lax

Con sesión centralizada, el logout es real e inmediato (borras la entrada de Redis) y puedes invalidar todas las sesiones de un usuario tras un cambio de contraseña o un incidente. Es, de hecho, la principal ventaja de las sesiones frente al JWT.

Excepciones y respuestas de error que no filtran información

// ✅ 401 para API: sin redirección a /login, con la cabecera correcta y sin detalles
@Component
public class PuntoEntradaApi implements AuthenticationEntryPoint {

    private final ObjectMapper mapper;

    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res,
                         AuthenticationException ex) throws IOException {
        // AUDITA el detalle en el log (con traceId), NO en la respuesta
        log.warn("auth_fallida ruta={} ip={} tipo={}", req.getRequestURI(),
                 req.getRemoteAddr(), ex.getClass().getSimpleName());

        res.setStatus(HttpStatus.UNAUTHORIZED.value());
        res.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"pedidos\"");
        res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        var problema = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
                "Credenciales ausentes o no válidas");     // mensaje GENÉRICO, RFC 9457
        problema.setProperty("traceId", MDC.get("traceId"));
        mapper.writeValue(res.getOutputStream(), problema);
    }
}

// ✅ 403 con el mismo criterio
@Component
public class ManejadorDenegado implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res,
                       AccessDeniedException ex) throws IOException {
        log.warn("acceso_denegado usuario={} ruta={} ",
                 SecurityContextHolder.getContext().getAuthentication().getName(),
                 req.getRequestURI());
        res.setStatus(HttpStatus.FORBIDDEN.value());
        res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        res.getWriter().write("""
                {"type":"about:blank","title":"Forbidden","status":403,
                 "detail":"No tienes permiso para esta operación"}""");
    }
}

// ❌ Lo que NUNCA debe salir al cliente
// {"error":"org.postgresql.util.PSQLException: ERROR: relation \\"pedido\\" does not exist
//  ... at com.miempresa.PedidoRepository.java:87 ... Hibernate 6.4.1 ... PostgreSQL 15.2"}
//   → versiones, esquema, rutas internas y un mapa de tu arquitectura, gratis para el atacante.

3.10 Probar la seguridad (si no hay test, no existe)

// Dependencia: org.springframework.security:spring-security-test (scope test)
// Imports estáticos clave:
// SecurityMockMvcRequestPostProcessors.{csrf, jwt, user, httpBasic, oauth2Login}
// SecurityMockMvcResultMatchers.{authenticated, unauthenticated}

@WebMvcTest(PedidoController.class)
@Import(ConfiguracionSeguridad.class)          // sin esto, la cadena real no se aplica
class PedidoControllerSeguridadTest {

    @Autowired MockMvc mvc;
    @MockitoBean ServicioPedidos servicio;

    @Test
    void anonimo_recibe_401() throws Exception {
        mvc.perform(get("/api/pedidos/1"))
           .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "ana", roles = "USER")          // crea el Authentication en el contexto
    void usuario_sin_permiso_recibe_403() throws Exception {
        mvc.perform(delete("/api/pedidos/1").with(csrf()))   // .with(csrf()) si la cadena lo exige
           .andExpect(status().isForbidden());
    }

    @Test
    void resource_server_valida_scopes() throws Exception {
        mvc.perform(get("/api/pedidos")
                .with(jwt().jwt(j -> j.claim("scope", "pedidos.read")
                                      .subject("12345678A")
                                      .claim("roles", List.of("USER")))))
           .andExpect(status().isOk());

        mvc.perform(post("/api/pedidos")
                .with(jwt().jwt(j -> j.claim("scope", "pedidos.read")))   // le falta pedidos.write
                .contentType(MediaType.APPLICATION_JSON).content("{}"))
           .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser("ana")
    void no_puede_ver_pedidos_de_otro() throws Exception {          // el test que evita el IDOR
        given(servicio.ver(99L)).willThrow(new PedidoNoEncontrado(99L));
        mvc.perform(get("/api/pedidos/99"))
           .andExpect(status().isNotFound());                        // 404, no 403: no confirma existencia
    }

    @Test
    void cabeceras_de_seguridad_presentes() throws Exception {
        mvc.perform(get("/api/publico/ping"))
           .andExpect(header().string("X-Content-Type-Options", "nosniff"))
           .andExpect(header().exists("Content-Security-Policy"));
    }
}

// Para la capa de servicio (@PreAuthorize), test de integración con el contexto real:
@SpringBootTest
class ServicioPedidosSeguridadTest {
    @Autowired ServicioPedidos servicio;

    @Test
    @WithMockUser(roles = "USER")
    void cancelarTodos_exige_admin() {
        assertThatThrownBy(() -> servicio.cancelarTodos(LocalDate.now()))
                .isInstanceOf(AccessDeniedException.class);
    }
}
Regla que impone disciplina: por cada endpoint que añadas, dos tests obligatorios: uno de “anónimo → 401” y uno de “usuario legítimo pero ajeno al recurso → 404/403”. Cuestan cinco minutos y bloquean en CI la mitad de las vulnerabilidades de la lista de OWASP.

Checklist — Spring Security 6

4. Autenticación con tokens: sesión, JWT y refresh

4.1 Sesión con cookie vs JWT: la tabla honesta

Desmontemos el mito de entrada: un JWT no es “más seguro” que una sesión. Es una decisión de arquitectura, no de seguridad: cambias una consulta al almacén de sesiones por una verificación de firma local. Ganas independencia y escalado; pierdes la capacidad de revocar. En muchas aplicaciones —sobre todo un monolito con navegador— la sesión con cookie HttpOnly+Secure+SameSite es la opción más segura y más sencilla. Elegir JWT “porque es moderno” es el origen de la mitad de los incidentes de esta sección.
CriterioSesión con cookie (estado en servidor)JWT autocontenido (sin estado)
Dónde está el estadoEn el servidor (memoria, Redis, BD). La cookie solo lleva un identificador opaco.En el propio token, en el cliente. El servidor no guarda nada.
Revocación inmediataTrivial: borras la sesión y el usuario está fuera.Imposible por diseño. El token vale hasta su exp. Requiere infraestructura extra.
Cambio de permisosInmediato en la siguiente petición.Solo cuando se emita un token nuevo: hay una ventana en la que el usuario sigue con los permisos viejos.
Escalado horizontalNecesita almacén compartido (Redis) o sesiones pegajosas.Cualquier instancia valida el token sin estado compartido.
Coste por peticiónUna lectura de Redis (~0,3 ms) o de BD.Una verificación de firma (HMAC ~µs; RSA algo más). La clave pública se cachea.
Tamaño en cada petición~30 bytes.500–2000 bytes. Con muchos claims y roles se puede rozar el límite de cabeceras del proxy.
Vulnerable a CSRFSí (es una cookie) → token CSRF obligatorio.No, si viaja en la cabecera Authorization. Sí, si lo guardas en una cookie.
Vulnerable a XSSLa cookie HttpOnly no es legible por JavaScript. El XSS sigue siendo grave, pero no se roba el identificador.Si está en localStorage, cualquier XSS lo exfiltra.
Cross-domain / móvil / servicio a servicioIncómodo (cookies, dominios, SameSite).Natural: es una cabecera HTTP.
Cuándo elegirloMonolito o BFF con navegador; requisitos de revocación inmediata; equipo pequeño.APIs para móvil y terceros, microservicios, federación con un authorization server, OAuth2/OIDC.
La opción que eligen hoy los equipos con criterio: un patrón BFF (backend for frontend). El navegador habla con el BFF mediante cookie de sesión HttpOnly (inmune a robo por XSS, revocable al instante) y es el BFF quien guarda los tokens OAuth2 y los usa contra las APIs internas. Te quedas con lo mejor de ambos mundos y ningún token toca el JavaScript. Spring Security lo soporta de forma directa con oauth2Login() y el OAuth2AuthorizedClientManager.

4.2 Anatomía de un JWT

Un JWS compacto son TRES bloques en Base64URL separados por puntos:

  eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDEifQ      ← header
  .eyJpc3MiOiJodHRwczovL2F1dGgubWllbXByZXNhLmNvbSIsInN1YiI6…      ← payload (claims)
  .Kx7fQ9mB2rT8vN1wLpZ3sYcH0dJ4gE6uA5iO7nQ2xR8                    ← firma

┌─ HEADER ────────────────────────────────────────────────────────────────────┐
│ { "alg": "RS256",      ← algoritmo de firma                                 │
│   "typ": "JWT",                                                             │
│   "kid": "2026-01" }   ← qué clave del JWKS usar (permite rotar sin cortes)  │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ PAYLOAD ───────────────────────────────────────────────────────────────────┐
│ { "iss": "https://auth.miempresa.com",   ← emisor: ¿es MI emisor?           │
│   "sub": "12345678A",                    ← sujeto: el usuario               │
│   "aud": ["pedidos-api"],                ← audiencia: ¿este token es PARA MÍ?│
│   "exp": 1774951200,                     ← expira (epoch en SEGUNDOS)       │
│   "iat": 1774950300,                     ← emitido en                       │
│   "nbf": 1774950300,                     ← no válido antes de               │
│   "jti": "b3f1…",                        ← id único: permite revocar ESTE   │
│   "scope": "pedidos.read pedidos.write", ← permisos delegados (OAuth2)       │
│   "roles": ["USER"] }                    ← claim propio                     │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ FIRMA ─────────────────────────────────────────────────────────────────────┐
│ RSASSA-PKCS1-v1_5( SHA256( base64url(header) + "." + base64url(payload) ) ) │
└─────────────────────────────────────────────────────────────────────────────┘

⚠️ EL PAYLOAD NO ESTÁ CIFRADO. Base64 NO es cifrado: cualquiera con el token lee
   todo su contenido (pruébalo con `echo … | base64 -d`, o en jwt.io con un token
   de PRUEBA, nunca con uno real). La firma garantiza INTEGRIDAD, no confidencialidad.
   → No metas nunca datos personales innecesarios, direcciones, DNI o secretos.
   → Si de verdad necesitas confidencialidad del contenido, existe JWE (cifrado),
     pero suele ser más simple usar un token OPACO y consultar /introspect.
AlgoritmoTipoQuién puede firmarCuándo usarlo
HS256 (HMAC-SHA256)SimétricoTodo el que pueda verificar (misma clave).Solo si emisor y verificador son el mismo componente. Con varios servicios, todos podrían falsificar tokens: mal.
RS256 (RSA-SHA256)AsimétricoSolo el emisor, con su clave privada.El estándar de facto en OIDC. Los resource servers solo necesitan la pública (JWKS).
ES256 (ECDSA P-256)AsimétricoSolo el emisor.Igual que RS256 pero con tokens y claves más pequeños. Buena elección moderna.
noneNingunoCualquiera.Nunca. Existe en la especificación y es el origen de una clase entera de vulnerabilidades.

4.3 Validación correcta (aquí es donde se cometen los fallos)

“Verifico la firma” es menos de la mitad del trabajo. Estas son las comprobaciones obligatorias y los ataques que evita cada una:

ComprobaciónAtaque que evitaDetalle
Firma válida con la clave correctaToken fabricado por el atacante.Obtén la clave del jwks_uri del emisor, selecciónala por kid y cachéala con TTL.
Algoritmo esperadoalg confusion y alg: none.El clásico: el atacante cambia alg de RS256 a HS256 y firma con la clave pública (que es… pública) esperando que la librería la use como secreto HMAC. Fija la lista de algoritmos aceptados; no la leas del token.
iss exactoToken emitido por otro emisor (¡el atacante puede montar su propio Keycloak!).Comparación exacta de cadena con tu issuer configurado.
aud contiene tu APIReutilización de un token legítimo emitido para otro servicio (token passing).El fallo más común y más grave: si el servicio A acepta tokens destinados al servicio B, cualquier usuario de B puede llamar a A.
exp y nbfTokens caducados o aún no válidos.Con un margen de reloj pequeño (30–60 s). Sincroniza los relojes (NTP): un contenedor desfasado provoca 401 aleatorios.
kid conocidoKey injection: jku/jwk en el header apuntando a una clave del atacante.Nunca confíes en URLs de clave que vengan dentro del token. Solo el JWKS del emisor configurado.
Claims mínimos presentesTokens degenerados (sin sub, sin scope).Rechaza lo que no encaje con tu contrato; no asumas valores por defecto.
# ✅ La forma correcta y aburrida: que lo haga Spring Boot por ti.
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Descubre jwks_uri vía /.well-known/openid-configuration y valida iss, exp, nbf y firma
          issuer-uri: https://auth.miempresa.com/realms/pedidos
          audiences: pedidos-api           # ← Spring Boot 3.1+: valida aud. NO LO OLVIDES.
          # authorization-server-metadata (alternativa) o jwk-set-uri si no hay discovery
// Cuando necesitas control fino (validadores adicionales, claves propias, algoritmo fijo)
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.issuer}") String emisor,
                      @Value("${jwt.jwks-uri}") String jwksUri) {

    NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withJwkSetUri(jwksUri)
            .jwsAlgorithms(algs -> { algs.clear(); algs.add(SignatureAlgorithm.RS256); })  // lista blanca
            .build();

    OAuth2TokenValidator<Jwt> porDefecto = JwtValidators.createDefaultWithIssuer(emisor); // exp, nbf, iss

    OAuth2TokenValidator<Jwt> audiencia = new JwtClaimValidator<List<String>>(
            JwtClaimNames.AUD, aud -> aud != null && aud.contains("pedidos-api"));

    OAuth2TokenValidator<Jwt> noRevocado = jwt -> listaRevocacion.contiene(jwt.getId())
            ? OAuth2TokenValidatorResult.failure(
                  new OAuth2Error("invalid_token", "Token revocado", null))
            : OAuth2TokenValidatorResult.success();

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            porDefecto, audiencia, noRevocado));
    return decoder;
}

// ❌ Lo que se ve en tutoriales y NO debes copiar
// 1) Decodificar sin verificar, "solo para leer el usuario":
String sub = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]));  // ¡sin firma!
// 2) Aceptar el algoritmo que diga el token (API antigua sin fijar algoritmo).
// 3) Escribir tu propio validador de JWT "porque es fácil": no lo es. Usa Nimbus/Spring.

4.4 El problema de la revocación y sus tres soluciones

Un JWT es una afirmación firmada con fecha de caducidad. Nadie puede “desfirmarla”. Si despides a alguien, si un token se filtra o si un usuario cambia su contraseña, el token sigue siendo matemáticamente válido hasta su exp. Las soluciones reales:

  1. Access tokens de vida muy corta + refresh token. El access token vive 5–15 minutos y no se comprueba contra ningún estado; el refresh token vive días, es opaco y se valida contra la base de datos del emisor, donde sí puedes revocarlo. La ventana de exposición es igual a la vida del access token. Es la solución por defecto.
  2. Lista de revocación (denylist) por jti. Guardas en Redis los identificadores revocados con TTL igual a su exp restante. Recuperas la revocación inmediata a cambio de una consulta por petición: has reintroducido el estado que querías evitar, pero solo para los casos excepcionales (la lista es pequeña).
  3. Versión de credenciales. El token incluye cver: 7; el usuario tiene una columna credential_version. Al cambiar la contraseña o revocar accesos, incrementas la versión y todos los tokens anteriores dejan de validar. Es barato (una consulta cacheable) y muy efectivo para el caso “invalidar todas las sesiones de este usuario”.

4.5 ¿Dónde guardo el token en el cliente?

UbicaciónRiesgo XSSRiesgo CSRFVeredicto
localStorage / sessionStorage Total. Cualquier script en la página lo lee y lo puede enviar a un servidor del atacante. Y basta con una dependencia npm comprometida. Ninguno (el navegador no lo envía solo). Evítalo para tokens de sesión. Es la opción cómoda de los tutoriales y la causa de muchos incidentes reales.
Variable en memoria de JavaScript Alto, pero el token muere al recargar y no queda en disco. Ninguno. Aceptable para el access token de corta vida, con el refresh en cookie HttpOnly.
Cookie HttpOnly + Secure + SameSite No es legible por JavaScript. Un XSS puede seguir haciendo peticiones en tu nombre desde tu navegador, pero no puede exfiltrar la credencial para usarla desde otro sitio y de forma persistente. Sí → token CSRF obligatorio. La mejor opción para aplicaciones web. Es el patrón BFF.
Almacén seguro del sistema (Keychain / Keystore / EncryptedSharedPreferences) Bajo, aislado por aplicación. No aplica. Lo correcto en móvil nativo. Nunca en un fichero de preferencias en claro.
Matiz importante y honesto: HttpOnly no anula el XSS. Con un XSS activo, el atacante ejecuta peticiones autenticadas desde el navegador de la víctima. Lo que sí consigue HttpOnly es que no pueda robarse la credencial para usarla desde su máquina, durante días y sin que la víctima esté conectada. Eso reduce enormemente el impacto. La defensa real contra XSS es escapar salidas y una CSP estricta; el almacenamiento del token es defensa en profundidad.

4.6 Refresh tokens y detección de reutilización

ROTACIÓN DE REFRESH TOKENS CON DETECCIÓN DE REUSO (reuse detection)

 Cliente                                   Authorization Server
   │                                                │
   │─ POST /token (grant=refresh, RT1) ────────────►│  RT1 válido → lo MARCA COMO USADO
   │◄─ { access_token: AT2, refresh_token: RT2 } ───│  y emite el par (AT2, RT2)
   │                                                │  familia: RT1 → RT2 (misma cadena)
   │─ POST /token (grant=refresh, RT2) ────────────►│  ok → RT3
   │                                                │
   │  ⚠️ ALGUIEN usa RT1 otra vez (lo había robado)  │
   │─ POST /token (grant=refresh, RT1) ────────────►│  RT1 YA ESTABA USADO
   │◄─ 400 invalid_grant ──────────────────────────│  → REVOCA TODA LA FAMILIA
   │                                                │     (RT1, RT2, RT3…) y audita
   │  Resultado: tanto el atacante como el          │
   │  usuario legítimo tienen que reautenticarse.   │
   │  Molesto, pero has DETECTADO y CORTADO el robo.│

Sin rotación, un refresh token robado da acceso silencioso durante semanas.
Con rotación + detección de reuso, el segundo uso delata el robo.
Requisitos: almacenar los RT (su hash) con estado usado/no usado y su familia.
Keycloak, Spring Authorization Server, Auth0 y Okta lo implementan de serie.

4.7 Emitir y validar JWT en Spring Boot 3

Antes de escribir una línea: si puedes usar un authorization server existente (Keycloak, Auth0, Entra ID, Cognito) o Spring Authorization Server, hazlo. Emitir tokens a mano es fácil de hacer mal y hay que mantener rotación de claves, endpoint JWKS, revocación, consentimiento y discovery. El código de abajo es correcto y útil para un servicio autónomo pequeño, o para entender qué hace un AS por debajo.
// Dependencia: spring-boot-starter-oauth2-resource-server (trae Nimbus JOSE + JWT)
@Configuration
public class ConfiguracionJwt {

    /** Par de claves RSA. En producción: cargado de un KMS o de un keystore montado, NUNCA generado al
     *  arrancar (si se genera al arrancar, cada réplica firma con una clave distinta y cada reinicio
     *  invalida todos los tokens emitidos). */
    @Bean
    RSAKey claveFirma(@Value("${jwt.key-id}") String kid,
                      KeyStore keyStore,
                      @Value("${jwt.key-alias}") String alias,
                      @Value("${jwt.key-password}") char[] password) throws Exception {
        var privada = (RSAPrivateKey) keyStore.getKey(alias, password);
        var certificado = (X509Certificate) keyStore.getCertificate(alias);
        return new RSAKey.Builder((RSAPublicKey) certificado.getPublicKey())
                .privateKey(privada)
                .keyID(kid)                       // aparecerá como "kid" en el header y en el JWKS
                .algorithm(JWSAlgorithm.RS256)
                .build();
    }

    @Bean
    JWKSource<SecurityContext> jwkSource(RSAKey clave) {
        // Publica SOLO la parte pública en /oauth2/jwks para que otros servicios validen
        return new ImmutableJWKSet<>(new JWKSet(clave));
    }

    @Bean
    JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwks) {
        return new NimbusJwtEncoder(jwks);
    }

    @Bean
    JwtDecoder jwtDecoder(RSAKey clave, @Value("${jwt.issuer}") String emisor) throws Exception {
        NimbusJwtDecoder decoder = NimbusJwtDecoder
                .withPublicKey(clave.toRSAPublicKey())
                .signatureAlgorithm(SignatureAlgorithm.RS256)     // fijado: sin alg confusion
                .build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                JwtValidators.createDefaultWithIssuer(emisor),
                new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
                        aud -> aud != null && aud.contains("pedidos-api"))));
        return decoder;
    }
}

@Service
public class EmisorTokens {

    private static final Duration VIDA_ACCESS = Duration.ofMinutes(10);   // corta: la revocación depende de esto
    private static final Duration VIDA_REFRESH = Duration.ofDays(14);

    private final JwtEncoder encoder;
    private final RSAKey clave;
    private final RepositorioRefreshTokens refrescos;

    public ParTokens emitir(UsuarioAplicacion usuario) {
        Instant ahora = Instant.now();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("https://auth.miempresa.com")
                .subject(usuario.getUsername())
                .audience(List.of("pedidos-api"))
                .issuedAt(ahora)
                .notBefore(ahora)
                .expiresAt(ahora.plus(VIDA_ACCESS))
                .id(UUID.randomUUID().toString())               // jti: permite revocar ESTE token
                .claim("roles", usuario.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .claim("cver", usuario.getVersionCredenciales()) // versión de credenciales
                .build();

        JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256)
                .keyId(clave.getKeyID())                         // para que el verificador elija la clave
                .build();

        String accessToken = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();

        // El refresh token NO es un JWT: es un valor opaco de alta entropía, guardado HASHEADO.
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        String refresh = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        refrescos.guardar(new RefreshToken(
                sha256(refresh),                     // guardamos el HASH, no el token
                usuario.getUsername(),
                ahora.plus(VIDA_REFRESH),
                UUID.randomUUID()));                 // id de familia, para la detección de reuso

        return new ParTokens(accessToken, refresh, VIDA_ACCESS.toSeconds());
    }
}

Resource Server: convertir claims en GrantedAuthority

// Por defecto Spring toma el claim "scope" (o "scp") y prefija las authorities con SCOPE_
//   scope: "pedidos.read"  →  authority "SCOPE_pedidos.read"  →  hasAuthority("SCOPE_pedidos.read")
// Si tus roles vienen en otro claim (o anidados, como en Keycloak), necesitas un convertidor.

@Bean
JwtAuthenticationConverter convertidorJwt() {
    var scopes = new JwtGrantedAuthoritiesConverter();      // mantiene SCOPE_* a partir de "scope"

    JwtAuthenticationConverter convertidor = new JwtAuthenticationConverter();
    convertidor.setPrincipalClaimName("sub");
    convertidor.setJwtGrantedAuthoritiesConverter(jwt -> {
        Collection<GrantedAuthority> autoridades = new ArrayList<>(scopes.convert(jwt));

        // Roles de Keycloak: { "realm_access": { "roles": ["USER","ADMIN"] } }
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) {
            roles.stream()
                 .map(String::valueOf)
                 .map(rol -> new SimpleGrantedAuthority("ROLE_" + rol))   // prefijo para hasRole()
                 .forEach(autoridades::add);
        }
        return autoridades;
    });
    return convertidor;
}

@Bean
SecurityFilterChain apiConversion(HttpSecurity http, JwtAuthenticationConverter convertidor)
        throws Exception {
    return http
        .securityMatcher("/api/**")
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.GET,  "/api/pedidos/**").hasAuthority("SCOPE_pedidos.read")
            .requestMatchers(HttpMethod.POST, "/api/pedidos").hasAuthority("SCOPE_pedidos.write")
            .requestMatchers("/api/admin/**").hasRole("ADMIN")          // busca ROLE_ADMIN
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(convertidor)))
        .build();
}
Scopes y roles no son lo mismo, y confundirlos causa bugs de autorización: el scope dice qué le ha permitido el usuario a la aplicación cliente (“esta app puede leer tus pedidos”); el rol dice qué puede hacer el usuario en el sistema (“es administrador”). El permiso efectivo es la intersección: una app con scope=pedidos.write usada por un usuario sin el rol adecuado no debe poder escribir. Comprueba las dos cosas.

Checklist — tokens

5. OAuth 2 / OAuth 2.1 y OpenID Connect

5.1 El problema que resuelve (y el que no)

Imagina 2008: una web quiere mostrar tus fotos de otro servicio. La única forma era que le dieras tu usuario y tu contraseña. Consecuencias: la web tenía acceso total (podía borrar tu cuenta), guardaba tu contraseña en su base de datos, no podías revocarle el acceso sin cambiar la contraseña y no podías limitar qué hacía. Ese antipatrón tenía nombre propio: el password antipattern.

OAuth 2 es un protocolo de delegación de autorización: permite que una aplicación obtenga acceso limitado y revocable a unos recursos, en nombre del usuario, sin conocer nunca sus credenciales.

El malentendido más extendido: OAuth 2 no es un protocolo de autenticación. Un access token dice “el portador puede leer los pedidos”, no “el usuario es Ana”. Construir un “iniciar sesión con…” usando solo OAuth 2 lleva a fallos reales y conocidos (aceptar un token emitido para otra aplicación y confundirlo con la identidad del usuario). Para autenticar existe OpenID Connect, que es una capa encima de OAuth 2 y define un id_token con reglas de validación estrictas. OAuth 2 para autorizar, OIDC para autenticar.

5.2 Los cuatro roles

RolQuién esEjemplo en nuestra API de pedidos
Resource OwnerEl dueño de los datos: normalmente la persona.Ana, la clienta.
ClientLa aplicación que quiere acceder en nombre del dueño. Público si no puede guardar un secreto (SPA, móvil); confidencial si sí (backend).La SPA de React, la app de iOS, un servicio de facturación.
Authorization Server (AS)Autentica al dueño, pide su consentimiento y emite tokens. Publica su configuración y sus claves.Keycloak en https://auth.miempresa.com/realms/pedidos.
Resource Server (RS)La API que protege los datos y solo valida tokens. No sabe de contraseñas.Nuestro pedidos-api de Spring Boot.

5.3 Authorization Code + PKCE: el flujo correcto para casi todo

AUTHORIZATION CODE + PKCE  (RFC 7636) — para SPA, móvil y web con servidor

  Antes de empezar, el cliente genera en local:
     code_verifier  = 43–128 caracteres aleatorios de SecureRandom
     code_challenge = BASE64URL( SHA-256( code_verifier ) )        method = S256

 Navegador/App        Authorization Server (Keycloak)      Resource Server (API)
      │                          │                                  │
 (1)  │ GET /authorize?          │                                  │
      │   response_type=code     │                                  │
      │   client_id=spa-pedidos  │                                  │
      │   redirect_uri=https://app.miempresa.com/callback           │
      │   scope=openid pedidos.read                                 │
      │   state=xyz789           │   ← anti-CSRF del propio flujo    │
      │   nonce=abc123           │   ← liga el id_token a ESTA sesión│
      │   code_challenge=E9Me…   │                                  │
      │   code_challenge_method=S256                                │
      │─────────────────────────►│                                  │
      │                          │                                  │
 (2)  │  El AS autentica al USUARIO (contraseña + MFA) y pide       │
      │  consentimiento. La contraseña NUNCA pasa por el cliente.   │
      │                          │                                  │
 (3)  │◄─ 302 a redirect_uri?code=SplxlOB…&state=xyz789 ───────────│
      │   El cliente VERIFICA que state coincide con el que envió.  │
      │                          │                                  │
 (4)  │ POST /token              │                                  │
      │   grant_type=authorization_code                             │
      │   code=SplxlOB…          │                                  │
      │   redirect_uri=…         │   ← debe ser IDÉNTICO al de (1)   │
      │   client_id=spa-pedidos  │                                  │
      │   code_verifier=dBjftJ…  │   ← el AS calcula SHA-256 y lo    │
      │─────────────────────────►│      compara con code_challenge   │
      │                          │                                  │
 (5)  │◄─ { access_token, id_token, refresh_token, expires_in } ────│
      │   (canal directo, POST, no pasa por la barra de direcciones) │
      │                          │                                  │
 (6)  │ GET /api/pedidos                                            │
      │ Authorization: Bearer <access_token>                        │
      │────────────────────────────────────────────────────────────►│
      │                          │◄── GET /.well-known/jwks.json ───│ (una vez, cacheado)
      │◄─ 200 [ … ] ───────────────────────────────────────────────│

¿QUÉ APORTA PKCE? El "code" viaja por la barra de direcciones y por el historial. En móvil
puede ser interceptado por otra app registrada en el mismo esquema de URL. PKCE hace que el
code sea INÚTIL sin el code_verifier, que solo existe en la memoria del cliente legítimo.
→ En OAuth 2.1 PKCE es OBLIGATORIO para TODOS los clientes, también los confidenciales.

state vs nonce (pregunta habitual):
  · state → protege contra CSRF en el propio flujo: garantiza que la respuesta corresponde
            a una petición que TÚ iniciaste. Es de OAuth 2.
  · nonce → va dentro del id_token y garantiza que ese id_token se emitió para ESTA
            solicitud concreta (anti-replay). Es de OpenID Connect.

5.4 Client Credentials: servicio a servicio

CLIENT CREDENTIALS  — no hay usuario, la identidad ES la aplicación

  Servicio facturación                Authorization Server           API pedidos
        │                                    │                            │
        │─ POST /token ─────────────────────►│                            │
        │   grant_type=client_credentials    │                            │
        │   client_id=facturacion            │                            │
        │   client_secret=•••  (o mTLS,      │                            │
        │     o private_key_jwt: mejor)      │                            │
        │   scope=pedidos.read               │                            │
        │◄─ { access_token, expires_in } ────│                            │
        │                                    │                            │
        │─ GET /api/pedidos  Bearer … ──────────────────────────────────►│
        │◄─ 200 ───────────────────────────────────────────────────────── │

· No hay redirect_uri, ni consentimiento, ni refresh token (se pide otro y punto).
· El "sub" del token es la APLICACIÓN, no una persona → los logs de auditoría deben
  distinguir "actor=servicio:facturacion" de "actor=usuario:12345678A".
· Autenticación del cliente, de peor a mejor: client_secret_post < client_secret_basic
  < private_key_jwt < tls_client_auth (mTLS). Con mTLS no hay secreto que filtrar.
· Cachea el token hasta su expiración menos un margen; NO pidas uno por petición.
# Cliente de Client Credentials en Spring Boot (para llamar a otra API)
spring:
  security:
    oauth2:
      client:
        registration:
          facturacion:
            provider: keycloak
            client-id: facturacion
            client-secret: ${OAUTH_CLIENT_SECRET}      # de un secreto de la plataforma
            authorization-grant-type: client_credentials
            scope: pedidos.read
        provider:
          keycloak:
            issuer-uri: https://auth.miempresa.com/realms/pedidos
// Y el cliente HTTP que adjunta el token automáticamente y lo refresca solo:
@Bean
RestClient clientePedidos(RestClient.Builder builder,
                          OAuth2AuthorizedClientManager gestor) {
    var interceptor = new OAuth2ClientHttpRequestInterceptor(gestor);
    interceptor.setClientRegistrationIdResolver(request -> "facturacion");
    return builder
            .baseUrl("https://pedidos.interno.miempresa.com")
            .requestInterceptor(interceptor)
            .build();
}

// ❌ Lo que NO debes hacer: propagar el token del USUARIO a otro servicio "porque funciona".
//    Ese token tiene aud=pedidos-api; si facturacion-api lo acepta, está saltándose la
//    validación de audiencia y cualquiera con un token de pedidos entra en facturación.
//    Si necesitas actuar en nombre del usuario, usa Token Exchange (RFC 8693).

5.5 Device Code y Refresh Token

DEVICE AUTHORIZATION GRANT (RFC 8628) — televisores, CLI, IoT: sin teclado o sin navegador

   Dispositivo (o CLI)                Authorization Server            Móvil del usuario
        │                                    │                              │
        │─ POST /device_authorization ──────►│                              │
        │◄─ { device_code, user_code: "WDJB-MJHT",                          │
        │     verification_uri: "https://auth…/device",                     │
        │     interval: 5, expires_in: 600 } │                              │
        │                                    │                              │
        │  Muestra en pantalla:              │                              │
        │  "Ve a auth.miempresa.com/device   │                              │
        │   e introduce WDJB-MJHT"           │◄── el usuario se autentica ──│
        │                                    │    y teclea el código        │
        │─ POST /token (device_code) ───────►│                              │
        │◄─ 400 authorization_pending ───────│  (sondeo cada `interval` s)   │
        │─ POST /token (device_code) ───────►│                              │
        │◄─ { access_token, refresh_token } ─│  ¡aprobado!                   │

Ejemplo cotidiano: `az login`, `gh auth login`, activar Netflix en la tele.

REFRESH TOKEN GRANT — renovar sin volver a molestar al usuario
        │─ POST /token  grant_type=refresh_token&refresh_token=… ──────────►│
        │◄─ { access_token nuevo, refresh_token nuevo (rotación) } ─────────│
  · Los clientes PÚBLICOS deben usar rotación con detección de reuso (ver 4.6).
  · El refresh token es la credencial de larga vida: trátalo como una contraseña.

5.6 Flujos obsoletos y por qué

FlujoEstadoPor qué se retiróQué usar
Implicit (response_type=token) Prohibido en OAuth 2.1 Devolvía el access token en el fragmento de la URL: queda en el historial, en los logs del proxy, en el Referer, accesible a cualquier script de la página, y sin posibilidad de refresh token seguro. Se creó solo porque los navegadores no tenían CORS. Authorization Code + PKCE.
Resource Owner Password Credentials (“password grant”) Prohibido en OAuth 2.1 La aplicación vuelve a manejar la contraseña del usuario: es exactamente el antipatrón que OAuth vino a eliminar. Además impide MFA, federación, SSO y captcha, y no funciona con proveedores externos. Authorization Code + PKCE, incluso en móvil (con navegador del sistema, no un WebView).
Authorization Code sin PKCE Desaconsejado El código interceptado se puede canjear. PKCE lo impide. Siempre con PKCE (S256, nunca plain).
“Pero mi app móvil usa password grant y funciona.” Funciona, sí. Y significa que tu app tiene la contraseña en memoria, que no puedes añadir MFA sin reescribir el cliente, que no puedes federar con el directorio corporativo y que si mañana cambias de proveedor de identidad tienes que rehacerlo todo. Es deuda de seguridad y de arquitectura a la vez.

5.7 Scopes, roles y permisos: tres capas distintas

ConceptoResponde aQuién lo decideDónde viveEjemplo
Scope¿Qué le he permitido a esta aplicación?El usuario, al dar consentimiento (o la configuración del cliente).Claim scope del access token.pedidos.read, openid, profile
Rol¿Qué es esta persona en la organización?Administradores del sistema / directorio corporativo.Claim propio (roles, realm_access.roles) o base de datos local.ADMIN, SOPORTE, CLIENTE
Permiso (autoridad fina)¿Puede hacer esta acción concreta?El modelo de dominio de tu aplicación.Derivado del rol, o tabla de permisos por recurso.pedidos:cancelar, facturas:anular

Consejo de diseño: mete en el token lo estable y pequeño (identidad y scopes) y resuelve en la aplicación lo fino y cambiante (permisos por recurso). Un token con 200 permisos dentro es enorme, se queda obsoleto en el momento en que se emite y acaba superando el límite de tamaño de cabeceras de algún proxy. Y sobre todo: autoriza siempre por permiso, no por rol, en el código (hasAuthority("pedidos:cancelar")), para que cambiar quién tiene un permiso no exija tocar código.

5.8 OpenID Connect: autenticación de verdad

OIDC añade a OAuth 2 tres piezas: el id_token (un JWT con la identidad del usuario, con reglas de validación obligatorias), el endpoint /userinfo (más datos del perfil, con el access token) y el discovery (/.well-known/openid-configuration) que hace que un cliente se configure con una sola URL.

# Discovery: la URL que te ahorra configurar diez cosas a mano
curl -s https://auth.miempresa.com/realms/pedidos/.well-known/openid-configuration | jq
# {
#   "issuer": "https://auth.miempresa.com/realms/pedidos",
#   "authorization_endpoint": ".../protocol/openid-connect/auth",
#   "token_endpoint":         ".../protocol/openid-connect/token",
#   "userinfo_endpoint":      ".../protocol/openid-connect/userinfo",
#   "jwks_uri":               ".../protocol/openid-connect/certs",
#   "end_session_endpoint":   ".../protocol/openid-connect/logout",
#   "introspection_endpoint": ".../protocol/openid-connect/token/introspect",
#   "code_challenge_methods_supported": ["plain","S256"],
#   "id_token_signing_alg_values_supported": ["RS256","ES256"],
#   "grant_types_supported": ["authorization_code","refresh_token","client_credentials"]
# }
# Es lo que Spring Boot consume con spring.security.oauth2.*.issuer-uri
TokenPara quién esQué contieneUso correcto
id_tokenEl clienteiss, sub, aud = client_id, exp, nonce, auth_time, amr, y datos de perfil.Que el cliente sepa quién ha iniciado sesión. Nunca lo envíes a una API como si fuera un access token.
access_tokenEl resource serveraud = la API, scope, sub.Autorizar llamadas a la API. El cliente lo trata como opaco: no debe inspeccionar su contenido.
refresh_tokenEl authorization serverOpaco.Obtener nuevos access tokens. Guardado como una contraseña.

5.9 Keycloak en 15 minutos y conectarlo a Spring

# 1) Arrancar Keycloak para desarrollo (start-dev: sin TLS, con BD en memoria H2)
docker run -d --name keycloak -p 8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0 start-dev
# En versiones anteriores a 26 las variables eran KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD.
# ⚠️ start-dev NO es apto para producción: sin HTTPS, sin caché distribuida, con H2.
#    En producción: `start` con --hostname, --db=postgres y TLS terminado en el ingress.

# 2) Consola de administración → http://localhost:8081  (admin/admin)

# 3) Lo que hay que crear (por la interfaz o con kcadm.sh):
#    a) REALM        "pedidos"                       ← frontera de aislamiento: usuarios, roles, clientes
#    b) ROLES de realm: USER, ADMIN, SOPORTE
#    c) CLIENTE "spa-pedidos"   (público, para la SPA)
#         Client authentication: OFF        ← cliente público: no puede guardar secreto
#         Standard flow: ON  ·  Direct access grants: OFF   ← ¡desactiva el password grant!
#         Valid redirect URIs: https://app.miempresa.com/callback  (NUNCA "*")
#         Valid post logout redirect URIs: https://app.miempresa.com/
#         Web origins: https://app.miempresa.com   (NUNCA "*")
#         PKCE: Advanced → Proof Key for Code Exchange Code Challenge Method = S256
#    d) CLIENTE "pedidos-api"   (audiencia de los tokens; sin flujos activos)
#    e) CLIENTE "facturacion"   (confidencial, Service accounts roles: ON → client_credentials)
#    f) USUARIO de prueba + contraseña + asignación de roles
#    g) MAPPER de audiencia en spa-pedidos:
#         Dedicated scopes → Add mapper → By configuration → Audience
#         Included Client Audience = pedidos-api      ← para que aud contenga tu API

# 4) Comprobar los tokens que emite (solo en desarrollo)
curl -s -X POST http://localhost:8081/realms/pedidos/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d client_id=facturacion -d client_secret=$SECRETO | jq -r .access_token
# Pega el resultado en jwt.io SOLO si es un token de desarrollo. Nunca uno de producción.

# 5) Exportar la configuración del realm para versionarla en git (infraestructura como código)
docker exec keycloak /opt/keycloak/bin/kc.sh export --dir /tmp/export --realm pedidos
docker cp keycloak:/tmp/export/pedidos-realm.json ./infra/keycloak/
# A) Nuestra API como RESOURCE SERVER (solo valida tokens)
# Dependencia: spring-boot-starter-oauth2-resource-server
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8081/realms/pedidos
          audiences: pedidos-api          # imprescindible: sin esto aceptas tokens de otras APIs

---
# B) Una aplicación web como CLIENT (login delegado en Keycloak, sesión propia)
# Dependencia: spring-boot-starter-oauth2-client
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: web-pedidos
            client-secret: ${OAUTH_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid,profile,email,pedidos.read
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8081/realms/pedidos
            user-name-attribute: preferred_username
// Cadena de la aplicación cliente: login OIDC + logout federado (RP-Initiated Logout)
@Bean
SecurityFilterChain webOidc(HttpSecurity http, ClientRegistrationRepository registros) throws Exception {
    var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registros);
    logoutHandler.setPostLogoutRedirectUri("{baseUrl}/");   // cierra sesión también en Keycloak

    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/error", "/css/**").permitAll()
            .anyRequest().authenticated())
        .oauth2Login(login -> login.userInfoEndpoint(ui -> ui.oidcUserService(servicioUsuarioOidc())))
        .logout(logout -> logout.logoutSuccessHandler(logoutHandler))
        .build();
}

// Leer la identidad del usuario autenticado por OIDC
@GetMapping("/panel")
public String panel(@AuthenticationPrincipal OidcUser usuario, Model modelo) {
    modelo.addAttribute("nombre", usuario.getFullName());
    modelo.addAttribute("email", usuario.getEmail());
    modelo.addAttribute("sub", usuario.getSubject());
    modelo.addAttribute("expira", usuario.getIdToken().getExpiresAt());
    return "panel";
}

5.10 Propagar la identidad entre microservicios

EstrategiaCómo funcionaCuándo usarlaRiesgos
Reenviar el token del usuario El servicio A pasa el mismo Authorization al servicio B. Solo si el token tiene a B en su aud y el modelo de confianza lo contempla. Un servicio comprometido puede usar el token contra cualquier API que lo acepte. Fomenta que las APIs dejen de validar aud.
Token Exchange (RFC 8693) A canjea el token del usuario en el AS por otro token con aud=B y scopes reducidos, que conserva la identidad original (act). La opción correcta cuando B necesita saber quién es el usuario final. Un salto extra al AS (cacheable). Requiere soporte en el AS (Keycloak lo tiene).
Token de servicio (client_credentials) A se identifica como servicio y pasa el id de usuario como dato de negocio. Cuando B autoriza por servicio y no necesita autorizar por usuario. Se pierde la autorización por usuario en B: si te equivocas, tienes un “superusuario de servicio”.
mTLS + malla de servicio Cada pod tiene su certificado; la malla autentica y autoriza a nivel de conexión (identidad SPIFFE). Siempre, como capa adicional de identidad de carga de trabajo. Autentica el servicio, no al usuario: complementa los tokens, no los sustituye.

Los detalles de gateway, malla de servicio y comunicación entre servicios están en módulo 08; el despliegue y las políticas de red, en módulo 09.

Patrón recomendado para el borde: el API gateway valida el token una vez (firma, iss, aud, exp), rechaza lo inválido y añade cabeceras de contexto. Pero cada servicio interno vuelve a validar el token que recibe: eso es confianza cero. Si el gateway es el único que valida, cualquiera que llegue a la red interna (un SSRF, un pod comprometido) habla con tus servicios sin credenciales.

5.11 API keys, MFA, passkeys y SSO

API keys: son aceptables en un caso concreto y con condiciones.

Segundo factorResistente a phishingNotas
SMS / OTP por correoNoVulnerable a SIM swapping y a que el usuario teclee el código en una web falsa. Mejor que nada, pero es el factor más débil.
TOTP (aplicaciones autenticadoras)NoBarato y sin dependencia de la red. Se puede phishear en tiempo real. Guarda el secreto cifrado y ofrece códigos de recuperación.
Notificación push con número a compararParcialmenteMitiga la fatiga de aprobaciones (MFA fatigue) frente al simple “Aprobar”.
Passkeys / WebAuthn / FIDO2La clave privada no sale del dispositivo (o del TPM) y está ligada al origen: una web falsa no puede usarla, porque el navegador se niega. Es el único mecanismo que elimina el phishing de credenciales por diseño.

Por qué las passkeys son el futuro: eliminan el secreto compartido. No hay nada que filtrar en un volcado de base de datos (el servidor solo guarda una clave pública), no hay nada que reutilizar en otro sitio, no hay nada que teclear en una web falsa y no hay nada que el usuario pueda olvidar. Spring Security incorpora soporte de passkeys/WebAuthn desde la versión 6.4 con el DSL webAuthn(), y también one-time tokens para enlaces de acceso por correo.

SSO empresarial: si la organización ya tiene un proveedor de identidad, no inventes nada. Con OIDC lo conectas con issuer-uri. Si el proveedor es antiguo y solo habla SAML 2.0 (muy común en entornos corporativos y en administraciones públicas), Spring Security tiene spring-security-saml2-service-provider: mismo concepto (delegas la autenticación en un IdP), sintaxis XML y metadatos en lugar de JSON y discovery. Migra a OIDC cuando puedas; conviven bien mientras tanto.

Checklist — OAuth2 y OIDC

6. OWASP Top 10 aplicado a Java y Spring

El OWASP Top 10 no es un estándar de cumplimiento ni una lista exhaustiva: es un documento de concienciación sobre las categorías de riesgo más extendidas, construido con datos reales de cientos de miles de aplicaciones. Para requisitos verificables existe OWASP ASVS; para recetas concretas, las Cheat Sheets.

Sobre la numeración: esta sección sigue la edición de 2021, que es la que casi todo el mundo cita y la que aparece en entrevistas. OWASP publica revisiones periódicas que reordenan y reagrupan categorías (en la más reciente, por ejemplo, la cadena de suministro gana peso como categoría propia y la configuración incorrecta sube posiciones). Los números cambian; las vulnerabilidades no. Consulta owasp.org/Top10 para la numeración vigente y aprende los mecanismos, no las etiquetas.

A01 · Control de acceso roto

Qué es: el usuario hace algo que no debería poder hacer. Incluye IDOR (acceder a un recurso ajeno cambiando el identificador), escalada vertical (usuario → admin), escalada horizontal (usuario A → datos de usuario B), forzado de navegación a endpoints “ocultos”, manipulación de metadatos (cookies, JWT, campos ocultos) y falta de comprobación en operaciones de escritura. Es la categoría número uno y la que más dinero cuesta.

// ❌ VULNERABLE — IDOR en cadena. Los tres endpoints tienen el mismo fallo.
@RestController
@RequestMapping("/pedidos")
public class PedidoControllerVulnerable {

    @GetMapping("/{id}")                                  // GET /pedidos/4712 → pedido de otro cliente
    public PedidoDto ver(@PathVariable Long id) {
        return mapper.aDto(repositorio.findById(id).orElseThrow());
    }

    @GetMapping("/{id}/factura")                          // ¡y su factura en PDF, con su dirección!
    public ResponseEntity<byte[]> factura(@PathVariable Long id) {
        return ResponseEntity.ok(generador.pdf(repositorio.findById(id).orElseThrow()));
    }

    @DeleteMapping("/{id}")                               // y puede CANCELAR pedidos ajenos
    public void cancelar(@PathVariable Long id) {
        servicio.cancelar(id);
    }
}

// EXPLOTACIÓN (resumida y sin herramientas): el usuario legítimo abre su propio pedido, ve
// /pedidos/4711 en la barra de direcciones y prueba 4710, 4712, 4713… Los identificadores
// secuenciales convierten un fallo de autorización en una descarga masiva de la base de datos.
// No hace falta ninguna habilidad técnica: es un bucle de 20 líneas o incluso el navegador a mano.

// ✅ CORREGIDO — la propiedad forma parte de la consulta y el 404 no confirma la existencia
@RestController
@RequestMapping("/pedidos")
public class PedidoController {

    private final RepositorioPedidos repositorio;

    @GetMapping("/{id}")
    public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
        return repositorio.findByIdAndClienteNif(id, jwt.getSubject())
                .map(mapper::aDto)
                .orElseThrow(() -> new PedidoNoEncontrado(id));      // → 404 siempre
    }

    @GetMapping                                                       // listar SOLO lo propio
    public Page<PedidoDto> mios(@AuthenticationPrincipal Jwt jwt,
                                @PageableDefault(size = 20) Pageable pagina) {
        return repositorio.findAllByClienteNif(jwt.getSubject(), pagina).map(mapper::aDto);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("@guardiaPedidos.puedeCancelar(#id, authentication)")   // segunda barrera
    public void cancelar(@PathVariable Long id) {
        servicio.cancelar(id);
    }
}
MitigaciónDetalle
Deny by defaultTodo denegado salvo lo explícitamente permitido, en la cadena y en los servicios.
Autorización centralizada y reutilizableUn AuthorizationManager o un bean de reglas, no un if copiado en 40 controladores.
Identificadores no adivinablesUUID v4 o ULID en las URLs públicas. No es la mitigación (es ofuscación), pero sube muchísimo el coste de la enumeración masiva.
La propiedad, en la consultafindByIdAndClienteNif: imposible de olvidar y no carga datos ajenos.
404 en lugar de 403Cuando la existencia del recurso ya es información sensible.
Test por endpoint“Usuario ajeno → 404”. En CI, para siempre.

Qué aporta Spring: authorizeHttpRequests, @PreAuthorize/@PostAuthorize, AuthorizationManager, derivación de consultas de Spring Data y spring-security-test para los tests.

A02 · Fallos criptográficos

Qué es: datos sensibles expuestos por ausencia de cifrado o por criptografía mal empleada. Antes se llamaba “exposición de datos sensibles”, y se renombró porque el síntoma era la exposición pero la causa es criptográfica.

// ❌ VULNERABLE
@Entity
public class ClienteVulnerable {
    private String numeroTarjeta;        // PAN en claro: incumple PCI-DSS y es una bomba de relojería
    private String password;             // sin hashear, o con MD5
    private String iban;                 // dato financiero en claro
}
// application.yml en el repositorio:
//   spring.datasource.password: Prod2024!      ← secreto versionado para siempre en git
// Cliente HTTP:
HttpClient.newBuilder().sslContext(contextoQueAceptaTodo()).build();   // TLS sin validar
// Cifrado:
Cipher.getInstance("AES/ECB/PKCS5Padding");                           // patrón visible

// ✅ CORREGIDO
@Entity
public class Cliente {
    @Column(name = "password_hash")
    private String passwordHash;                        // {bcrypt}$2a$12$… (sección 2.2)

    @Convert(converter = ConvertidorCifrado.class)      // cifrado a nivel de campo, AES-GCM
    @Column(name = "iban_cifrado")
    private String iban;

    private String tarjetaUltimos4;                     // "4242": suficiente para la interfaz
    private String tarjetaToken;                        // token del PSP. El PAN NO ESTÁ AQUÍ.
}

@Converter
public class ConvertidorCifrado implements AttributeConverter<String, String> {
    private final CifradoCampo cifrado;                 // clave del KMS, IV aleatorio por valor
    @Override public String convertToDatabaseColumn(String claro) {
        return claro == null ? null : cifrado.cifrar(claro.getBytes(UTF_8), CONTEXTO);
    }
    @Override public String convertToEntityAttribute(String bd) {
        return bd == null ? null : new String(cifrado.descifrar(bd, CONTEXTO), UTF_8);
    }
}
// ⚠️ Consecuencia de cifrar un campo: dejas de poder buscar por él con LIKE o índices normales.
//    Si necesitas buscar por igualdad, guarda además un HMAC determinista del valor (blind index).

Mitigaciones: clasifica los datos antes de decidir controles; no almacenes lo que no necesitas (el dato que no guardas no se filtra); TLS obligatorio en tránsito y cifrado en reposo; algoritmos y modos actuales (sección 2); secretos fuera del repositorio; claves en un KMS con rotación.

Qué aporta Spring: PasswordEncoder y DelegatingPasswordEncoder, SSL bundles, AttributeConverter de JPA, spring-cloud-vault / integración con Secrets Manager para secretos.

A03 · Inyección

Qué es: datos no confiables se interpretan como código o como parte de una instrucción. La causa raíz siempre es la misma: mezclar datos con instrucciones. La solución también: separarlos mediante parámetros, y validar con listas blancas lo que no se puede parametrizar.

// ─── 1) SQL con JDBC ────────────────────────────────────────────────────────────
// ❌ VULNERABLE
String sql = "SELECT * FROM usuarios WHERE email = '" + email + "' AND activo = true";
// Con un email que cierre la comilla y añada  OR '1'='1'  la condición se vuelve siempre
// verdadera y devuelve toda la tabla; con más creatividad se llega a otras tablas.

// ✅ CORRECTO: PreparedStatement. El parámetro NUNCA se interpreta como SQL.
var consulta = "SELECT id, email FROM usuarios WHERE email = ? AND activo = true";
jdbcTemplate.query(consulta, filaMapper, email);

// ─── 2) JPA / JPQL ──────────────────────────────────────────────────────────────
// ❌ VULNERABLE — que sea JPA no te salva si concatenas
em.createQuery("SELECT p FROM Pedido p WHERE p.cliente.nif = '" + nif + "'").getResultList();

// ✅ CORRECTO: parámetros nombrados
em.createQuery("SELECT p FROM Pedido p WHERE p.cliente.nif = :nif", Pedido.class)
  .setParameter("nif", nif).getResultList();

// ✅ Y en Spring Data
@Query("SELECT p FROM Pedido p WHERE p.estado = :estado AND p.total > :minimo")
List<Pedido> buscar(@Param("estado") EstadoPedido estado, @Param("minimo") BigDecimal minimo);

// ⚠️ CASO ESPECIAL: el ORDEN y los nombres de columna NO se pueden parametrizar.
// ❌
var sqlOrden = "SELECT * FROM pedidos ORDER BY " + campo + " " + direccion;
// ✅ lista blanca estricta
private static final Set<String> ORDENABLES = Set.of("fecha", "total", "estado");
if (!ORDENABLES.contains(campo)) throw new IllegalArgumentException("campo no permitido");
Sort orden = Sort.by(Sort.Direction.fromString(direccion), campo);   // fromString ya valida ASC/DESC

// ─── 3) Inyección de comandos del sistema ───────────────────────────────────────
// ❌ VULNERABLE: el shell interpreta ; | && $() como separadores/sustituciones
Runtime.getRuntime().exec("convert " + nombreRecibido + " salida.png");
new ProcessBuilder("sh", "-c", "gzip " + rutaRecibida).start();

// ✅ CORRECTO: sin shell, argumentos como lista, rutas validadas
Path base = Path.of("/var/subidas").toAbsolutePath().normalize();
Path entrada = base.resolve(nombreRecibido).normalize();
if (!entrada.startsWith(base)) throw new SecurityException("ruta fuera del directorio permitido");

var pb = new ProcessBuilder("/usr/bin/convert", entrada.toString(), "/var/salida/out.png");
pb.redirectErrorStream(true);
Process p = pb.start();
if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly();      // timeout: también es seguridad

// ─── 4) LDAP ────────────────────────────────────────────────────────────────────
// ❌ VULNERABLE: con caracteres especiales en el usuario, el filtro se convierte en otro filtro
String filtro = "(uid=" + usuario + ")";
// ✅ CORRECTO: parámetros con {0}, que Spring escapa según RFC 4515
ldapTemplate.search("", "(uid={0})", new Object[]{usuario}, mapper);

// ─── 5) SpEL: inyección que da EJECUCIÓN DE CÓDIGO ──────────────────────────────
// ❌ CRÍTICO: evaluar una expresión que viene del usuario = RCE
var parser = new SpelExpressionParser();
Object r = parser.parseExpression(expresionDelUsuario).getValue(new StandardEvaluationContext(obj));

// ✅ Si de verdad necesitas expresiones dinámicas, contexto restringido y sin acceso a tipos
EvaluationContext contexto = SimpleEvaluationContext.forReadOnlyDataBinding().build();
Object valor = parser.parseExpression(expresion).getValue(contexto, obj);
// ✅ Mejor todavía: no evalúes expresiones del usuario. Ofrece un enum de operaciones soportadas.

// ─── 6) Plantillas (SSTI) ───────────────────────────────────────────────────────
// ❌ CRÍTICO: el NOMBRE de la vista o el contenido de la plantilla viene del usuario
return "redirect:" + destinoDelUsuario;                 // + open redirect
return new ModelAndView(nombreDeVistaDelUsuario);       // SSTI → RCE en Thymeleaf/FreeMarker
// ✅ Lista blanca de vistas y de destinos.

// ─── 7) Log injection / CRLF ────────────────────────────────────────────────────
// ❌ El usuario mete saltos de línea y falsifica una línea de log completa
log.info("Intento de login de " + emailRecibido);
// ✅ Logging estructurado (JSON: los saltos de línea van escapados en el valor del campo)
//    y saneado explícito si el destino es texto plano
log.info("login_intento email={}", emailRecibido.replaceAll("[\\r\\n]", "_"));

Qué aporta Spring: JdbcTemplate/JdbcClient y Spring Data parametrizan por defecto; Bean Validation (@Valid, @Pattern, @Size) valida en la frontera; LdapTemplate escapa filtros; Thymeleaf escapa la salida.

A04 · Diseño inseguro

Qué es: fallos que no se arreglan con código porque el diseño era incorrecto: no se pensó en el abuso. No hay una función mal escrita; falta un control que nadie especificó.

// ─── Caso 1: falta rate limiting → fuerza bruta, enumeración y abuso de coste ───
// ❌ Un endpoint de login (o de "recuperar contraseña", o de OTP, o de búsqueda costosa)
//    sin límite permite millones de intentos por hora. El código es "correcto"; el diseño no.

// ✅ Rate limiting con Bucket4j (por IP y por cuenta, porque solo por IP se sortea con proxies)
@Component
public class FiltroRateLimit extends OncePerRequestFilter {

    private final Cache<String, Bucket> cubos = Caffeine.newBuilder()
            .expireAfterAccess(Duration.ofMinutes(10)).maximumSize(100_000).build();

    private static Bucket nuevoCubo() {
        return Bucket.builder()
                .addLimit(limite -> limite.capacity(10).refillIntervally(10, Duration.ofMinutes(1)))
                .build();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String clave = clavePorIpYUsuario(req);
        Bucket cubo = cubos.get(clave, k -> nuevoCubo());
        ConsumptionProbe sonda = cubo.tryConsumeAndReturnRemaining(1);

        if (!sonda.isConsumed()) {
            res.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());          // 429
            res.setHeader("Retry-After",
                    String.valueOf(Duration.ofNanos(sonda.getNanosToWaitForRefill()).toSeconds()));
            log.warn("rate_limit_superado clave={} ruta={}", clave, req.getRequestURI());
            return;
        }
        res.setHeader("X-RateLimit-Remaining", String.valueOf(sonda.getRemainingTokens()));
        chain.doFilter(req, res);
    }

    @Override protected boolean shouldNotFilter(HttpServletRequest req) {
        return !req.getRequestURI().startsWith("/api/auth");        // aplícalo donde importa
    }
}
// Además: bloqueo temporal de cuenta con retroceso exponencial, captcha tras N fallos,
// y el límite duro en el ingress/WAF (un filtro Java no te protege de un DDoS volumétrico).

// ─── Caso 2: lógica de negocio explotable ───────────────────────────────────────
// ❌ Cupón de descuento sin límite de usos: el mismo código aplicado 50 veces → total negativo.
// ❌ Devolución que reembolsa el precio ACTUAL en lugar del pagado → comprar en oferta, devolver caro.
// ❌ "Envío gratis a partir de 50 €": pedir 60 €, devolver una línea y quedarse el envío gratis.
// ✅ Invariantes en el dominio: importe nunca negativo, un cupón por pedido, reembolso ≤ pagado,
//    y recálculo del envío en cada modificación. Y tests de esos casos de abuso.

// ─── Caso 3: race condition en pagos (doble gasto) ──────────────────────────────
// ❌ VULNERABLE: leer-comprobar-escribir sin atomicidad.
//    Dos peticiones simultáneas leen saldo=100, ambas ven que 100 >= 80 y ambas cobran.
@Transactional
public void pagar(Long cuentaId, BigDecimal importe) {
    Cuenta c = repositorio.findById(cuentaId).orElseThrow();
    if (c.getSaldo().compareTo(importe) < 0) throw new SaldoInsuficiente();
    c.setSaldo(c.getSaldo().subtract(importe));       // se pierde una de las dos restas
}

// ✅ CORRECTO: tres capas de defensa
// (a) La condición, en la propia sentencia atómica de la base de datos
@Modifying
@Query("UPDATE Cuenta c SET c.saldo = c.saldo - :importe " +
       "WHERE c.id = :id AND c.saldo >= :importe")
int debitarSiHaySaldo(@Param("id") Long id, @Param("importe") BigDecimal importe);
// filas afectadas = 0 → no había saldo. Atómico, sin condición de carrera.

// (b) Restricción en el esquema: la última línea de defensa es la base de datos
//     ALTER TABLE cuenta ADD CONSTRAINT saldo_no_negativo CHECK (saldo >= 0);

// (c) Idempotencia para evitar el doble cobro por reintentos de red
//     Cabecera Idempotency-Key + índice UNIQUE en (cuenta_id, idempotency_key)
//     El segundo intento choca con la restricción y devuelve el resultado del primero.
// Alternativas: @Version (bloqueo optimista) o SELECT … FOR UPDATE (pesimista). Ver módulo 06.

Mitigaciones estructurales: modelado de amenazas en la fase de diseño (sección 1.3); historias de usuario “negativas” (“como atacante, quiero…”); límites y cuotas por defecto; invariantes en el dominio y restricciones en el esquema; y tests de casos de abuso, no solo de camino feliz.

A05 · Configuración de seguridad incorrecta

FalloConsecuenciaCorrección
Actuator expuesto (include: "*") /actuator/env y /actuator/configprops revelan variables de entorno y propiedades (a menudo con secretos); /heapdump descarga toda la memoria del proceso, con tokens y contraseñas dentro; /threaddump, /mappings y /beans dan un mapa completo del sistema. Solo health e info públicos; el resto autenticado y, mejor, en un puerto distinto no publicado en el ingress.
Stack traces al cliente Versiones, rutas, esquema de BD y estructura interna, gratis. server.error.include-stacktrace: never y un @ControllerAdvice con ProblemDetail.
CORS abierto Cualquier web lee respuestas autenticadas del usuario. Sección 3.8.
Credenciales por defecto admin/admin en Keycloak, Grafana, Kafka UI, bases de datos de desarrollo publicadas. Sin valores por defecto en producción; arranque que falla si falta el secreto.
Listado de directorios y ficheros de más en el JAR .git, .env, copias de seguridad, application-local.yml con secretos. Desactivar el listado en el servidor web y revisar qué entra en la imagen (.dockerignore).
Perfiles de desarrollo activos en producción Consola H2, spring.jpa.show-sql, seguridad relajada, debug: true. SPRING_PROFILES_ACTIVE explícito y un test que falle si un perfil de desarrollo está activo.
Cabeceras de seguridad ausentes XSS, clickjacking y degradación a HTTP más fáciles. Sección 3.9.
Verbos HTTP y endpoints innecesarios habilitados TRACE, PUT en rutas estáticas, consola H2, Swagger UI en producción. Superficie mínima: desactiva lo que no usas.
# ✅ Configuración correcta de Actuator en producción
management:
  server:
    port: 9090                    # puerto separado: NO se publica en el ingress
  endpoints:
    web:
      exposure:
        include: health,info,prometheus     # lista blanca explícita
        exclude: "*"
      base-path: /interno
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true             # /health/liveness y /health/readiness para Kubernetes
    env:
      show-values: never          # aunque esté activo, no muestra los valores
    configprops:
      show-values: never
  info:
    env:
      enabled: false              # no expongas propiedades arbitrarias en /info

spring:
  jpa:
    show-sql: false               # las consultas con parámetros acaban en los logs
  h2:
    console:
      enabled: false              # NUNCA en producción: es una consola SQL abierta
server:
  error:
    include-stacktrace: never
    include-message: never
    whitelabel:
      enabled: false
// Test que evita el despiste más caro: publicar con un perfil de desarrollo
@SpringBootTest
class ConfiguracionProduccionTest {

    @Autowired Environment entorno;
    @Autowired WebEndpointProperties actuator;

    @Test
    void no_hay_perfiles_de_desarrollo_activos() {
        assertThat(entorno.getActiveProfiles())
                .doesNotContain("dev", "local", "test-datos");
    }

    @Test
    void actuator_no_expone_endpoints_sensibles() {
        assertThat(actuator.getExposure().getInclude())
                .doesNotContain("*", "env", "heapdump", "configprops", "beans");
    }

    @Test
    void no_hay_secretos_con_valor_por_defecto() {
        assertThat(entorno.getProperty("spring.datasource.password"))
                .isNotIn("", "postgres", "password", "changeme", "admin");
    }
}

A06 · Componentes vulnerables y desactualizados

Una aplicación Spring Boot típica arrastra entre 150 y 400 dependencias transitivas. Tú escribes el 2% del código que ejecutas. Esta categoría no se arregla programando mejor: se arregla con proceso y automatización.

CasoQué pasóLección
Log4Shell
CVE-2021-44228 (dic. 2021)
Log4j 2 interpretaba expresiones ${…} dentro del mensaje de log. Una expresión JNDI hacía que la librería fuese a buscar y cargase una clase remota: ejecución de código con solo loguear una cadena controlada por el usuario (un User-Agent, un nombre de usuario). CVSS 10.0. Corregido en 2.17.1 (y en las ramas 2.12.4 / 2.3.2). El logging es superficie de ataque. Una función “interna” de una librería puede convertir un dato en código. Y hacía falta saber en minutos qué versión de Log4j había en cada artefacto: eso es exactamente para lo que sirve un SBOM.
Spring4Shell
CVE-2022-22965 (mar. 2022)
El enlace de datos de Spring MVC permitía llegar a propiedades del ClassLoader mediante nombres de parámetro anidados y modificar la configuración de logs de Tomcat para escribir un fichero ejecutable. Requería una combinación concreta (JDK 9+, despliegue WAR en Tomcat, enlace a POJO). Corregido en Spring Framework 5.3.18 / 5.2.20. El data binding automático a objetos es potente y peligroso (ver también mass assignment). Y las condiciones de explotabilidad importan: no todo CVSS 9 te afecta, pero hay que comprobarlo, no suponerlo.
# 1) Saber qué tienes de verdad (incluidas las transitivas)
mvn dependency:tree -Dverbose
mvn dependency:tree -Dincludes=org.apache.logging.log4j     # ¿tengo log4j? ¿en qué versión y por quién?
./gradlew dependencies --configuration runtimeClasspath

# 2) Análisis de composición (SCA) en CI: falla el build si hay CVEs graves
mvn org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7
trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .
trivy image --severity HIGH,CRITICAL --exit-code 1 miempresa/pedidos:1.4.2
grype dir:. --fail-on high

# 3) SBOM (inventario firmado de lo que compone el artefacto)
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom     # → target/bom.json (CycloneDX)
syft packages miempresa/pedidos:1.4.2 -o cyclonedx-json > sbom.json
# Con el SBOM guardado por versión, la pregunta "¿estamos afectados?" se responde con un grep.

# 4) Actualizar con criterio, no a ciegas
mvn versions:display-dependency-updates
mvn versions:display-plugin-updates
# Y sobre todo: el BOM de Spring Boot decide las versiones coherentes. Sube el PADRE,
# no dependencias sueltas, o acabarás con combinaciones que nadie ha probado.
# .github/dependabot.yml — actualizaciones automáticas agrupadas y revisables
version: 2
updates:
  - package-ecosystem: maven
    directory: "/"
    schedule: { interval: weekly }
    open-pull-requests-limit: 10
    groups:
      spring:
        patterns: ["org.springframework*", "org.springframework.boot*"]
      test:
        patterns: ["*junit*", "*mockito*", "*testcontainers*"]
    labels: ["dependencias", "seguridad"]
  - package-ecosystem: docker
    directory: "/"
    schedule: { interval: weekly }
  - package-ecosystem: github-actions      # las propias acciones de CI también tienen CVEs
    directory: "/"
    schedule: { interval: monthly }
Las cuatro reglas que de verdad funcionan: (1) versiones fijadas y gestionadas por un BOM, nunca LATEST ni rangos; (2) SCA en cada pull request, con el build en rojo para CRITICAL/HIGH; (3) actualizaciones pequeñas y continuas —el salto de tres años es lo que hace imposible parchear con urgencia—; y (4) elimina dependencias: la mejor forma de no tener un CVE es no tener la librería. Prioriza con EPSS (probabilidad real de explotación) y con la lista KEV de CISA, no solo con el CVSS.

A07 · Fallos de identificación y autenticación

// ❌ VULNERABLE — cuatro fallos en catorce líneas
@PostMapping("/login")
public TokenDto login(@RequestBody LoginDto dto) {
    Usuario u = repositorio.porEmail(dto.email())
            .orElseThrow(() -> new NotFound("No existe ningún usuario con ese correo"));  // (1)
    if (!u.getPassword().equals(sha256(dto.password()))) {                                // (2) (3)
        throw new Unauthorized("Contraseña incorrecta");                                  // (1)
    }
    return new TokenDto(emitirToken(u, Duration.ofDays(30)));                             // (4)
}
// (1) ENUMERACIÓN DE USUARIOS: dos mensajes distintos revelan qué correos están registrados.
// (2) SHA-256 rápido y sin salt (sección 2.2).
// (3) Comparación con equals: canal lateral de tiempo. Y sin límite de intentos: fuerza bruta libre.
// (4) Token de 30 días para un cliente público: ventana de exposición enorme.

// ✅ CORREGIDO
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@Valid @RequestBody LoginDto dto, HttpServletRequest req) {
    limitador.comprobar(dto.email(), req.getRemoteAddr());       // 429 con retroceso exponencial

    var usuario = repositorio.porEmail(dto.email());
    // Hash de compensación: si el usuario no existe, gastamos el mismo tiempo → no hay canal lateral
    String hashAComparar = usuario.map(Usuario::getPasswordHash).orElse(HASH_SEÑUELO);
    boolean valido = encoder.matches(dto.password(), hashAComparar) && usuario.isPresent();

    if (!valido) {
        limitador.registrarFallo(dto.email(), req.getRemoteAddr());
        auditoria.loginFallido(dto.email(), req.getRemoteAddr());
        // MENSAJE IDÉNTICO en ambos casos, y sin pistas en el cuerpo ni en el código de estado
        throw new BadCredentialsException("Credenciales no válidas");
    }
    Usuario u = usuario.get();
    if (u.estaBloqueado()) throw new BadCredentialsException("Credenciales no válidas");

    limitador.limpiar(dto.email());
    auditoria.loginCorrecto(u.getId(), req.getRemoteAddr());
    return ResponseEntity.ok(emisor.emitir(u));                  // access 10 min + refresh rotativo
}

Otros puntos de esta categoría que se olvidan:

A08 · Fallos de integridad de software y datos

// ─── Deserialización insegura: el caso más grave del ecosistema Java ───────────
// ❌ CRÍTICO: deserializar datos que vienen de fuera con la serialización nativa.
//    Al leer el objeto se ejecutan métodos (readObject, readResolve…) de las clases que haya
//    EN TU CLASSPATH. Encadenando esos métodos (gadget chains) se consigue ejecución de código.
//    No hace falta que TU código haga nada raro: basta con tener ciertas librerías.
ObjectInputStream ois = new ObjectInputStream(peticion.getInputStream());
Object objeto = ois.readObject();                                    // RCE potencial

// ❌ CRÍTICO: tipado polimórfico abierto en Jackson.
//    enableDefaultTyping() (obsoleto) y activateDefaultTyping con un validador permisivo
//    permiten que el JSON diga QUÉ CLASE instanciar → el atacante elige un gadget del classpath.
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();                                        // nunca
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,  // nunca con datos externos
        ObjectMapper.DefaultTyping.NON_FINAL);

// ✅ CORRECTO (1): no uses serialización nativa con datos externos. JSON con tipos explícitos.
record CrearPedidoDto(@NotBlank String referencia,
                      @NotEmpty List<LineaDto> lineas) { }           // el tipo lo decides TÚ
CrearPedidoDto dto = mapper.readValue(cuerpo, CrearPedidoDto.class);

// ✅ CORRECTO (2): si necesitas polimorfismo, ciérralo con una lista blanca
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "tipo")
@JsonSubTypes({ @JsonSubTypes.Type(value = Tarjeta.class, name = "tarjeta"),
                @JsonSubTypes.Type(value = Transferencia.class, name = "transferencia") })
public sealed interface MetodoPago permits Tarjeta, Transferencia { }
// o, a nivel de ObjectMapper:
var validador = BasicPolymorphicTypeValidator.builder()
        .allowIfBaseType(MetodoPago.class)
        .allowIfSubType("com.miempresa.pagos.")
        .build();

// ✅ CORRECTO (3): si estás OBLIGADO a la serialización nativa (RMI, JMX, sistemas antiguos),
//    filtro de deserialización (JEP 290, Java 9+) con lista blanca
ObjectInputFilter filtro = ObjectInputFilter.Config.createFilter(
        "com.miempresa.dominio.*;java.util.*;java.lang.*;!*");   // "!*" prohíbe todo lo demás
ois.setObjectInputFilter(filtro);
// A nivel de JVM, para todo el proceso:
//   -Djdk.serialFilter=com.miempresa.**;java.base/**;!*
//   -Djdk.serialFilterFactory=…   (filtros por contexto, Java 17+)
// Y limita la profundidad y el número de referencias: maxdepth=20;maxrefs=1000;maxbytes=100000

La otra mitad de A08 es la cadena de suministro y las actualizaciones: aceptar un artefacto, un plugin, una imagen de contenedor o una actualización automática sin verificar su origen ni su integridad. Se desarrolla en la sección 7.

A09 · Fallos de logging y monitorización

No es una vulnerabilidad que un atacante explote directamente: es lo que hace que no te enteres. Y el tiempo medio de detección de una brecha se sigue midiendo en meses. Un ataque detectado en minutos es un incidente; el mismo ataque detectado en seis meses es una catástrofe regulatoria.

✅ Qué SÍ hay que registrar

  • Autenticaciones correctas y fallidas, con identidad, IP, user agent y resultado.
  • Fallos de autorización (403): quién intentó qué. Es la señal más temprana de enumeración.
  • Cambios de credenciales, de correo, de MFA, de permisos y de roles.
  • Operaciones de negocio críticas: pagos, devoluciones, cancelaciones masivas, exportaciones de datos.
  • Uso de funciones administrativas y accesos a datos personales (obligatorio para auditoría RGPD).
  • Errores de validación de entrada llamativos y repetidos (indicio de sondeo).
  • Activación de límites: 429, bloqueos de cuenta, rechazos del WAF.
  • Siempre con traceId correlacionable y en JSON estructurado a stdout.

❌ Qué NUNCA debe entrar en un log

  • Contraseñas, ni siquiera las incorrectas (los usuarios se equivocan… con la contraseña de otra cuenta).
  • Tokens completos, cookies de sesión, API keys, claves privadas.
  • Números de tarjeta, CVV, datos de salud, credenciales de terceros.
  • Datos personales innecesarios: DNI, dirección completa, teléfono. Usa identificadores internos.
  • Cuerpos de petición y respuesta completos en producción.
  • El resultado de toString() de una entidad JPA con todos sus campos.
// Evento de auditoría estructurado y separado del log de aplicación
@Component
public class Auditoria {

    private static final Logger AUDIT = LoggerFactory.getLogger("AUDITORIA");

    public void registrar(String accion, String recurso, String resultado) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        // Campos estables y consultables; nada de frases libres
        AUDIT.info("evento=auditoria actor={} actor_tipo={} accion={} recurso={} resultado={} ip={} trace={}",
                auth != null ? auth.getName() : "anonimo",
                esServicio(auth) ? "servicio" : "usuario",
                accion, recurso, resultado,
                MDC.get("clientIp"), MDC.get("traceId"));
    }
}

// Escuchar los eventos que Spring Security ya publica: auditoría sin tocar tu código
@Component
public class EscuchaSeguridad {

    @EventListener
    void onExito(AuthenticationSuccessEvent e) {
        log.info("evento=login resultado=ok usuario={}", e.getAuthentication().getName());
    }

    @EventListener
    void onFallo(AbstractAuthenticationFailureEvent e) {
        log.warn("evento=login resultado=fallo usuario={} causa={}",
                e.getAuthentication().getName(), e.getException().getClass().getSimpleName());
    }

    @EventListener
    void onDenegado(AuthorizationDeniedEvent<?> e) {
        log.warn("evento=autorizacion resultado=denegado usuario={}",
                e.getAuthentication().get().getName());
    }
}

// Enmascarado: filtro de Logback para que un descuido no acabe en el SIEM
// (además de la disciplina de no loguear lo que no toca)
public class EnmascararSensibles extends MessageConverter {
    private static final Pattern PATRONES = Pattern.compile(
            "(?i)(password|passwd|secret|token|authorization|api[_-]?key)\"?\\s*[:=]\\s*\"?([^\",;\\s]+)");
    @Override public String convert(ILoggingEvent evento) {
        return PATRONES.matcher(super.convert(evento)).replaceAll("$1=***");
    }
}

Alertas que deberías tener configuradas (en Prometheus/Grafana, CloudWatch o el SIEM): picos de 401/403 desde una misma IP o contra un mismo usuario; un usuario que accede a muchos recursos distintos en poco tiempo (patrón de IDOR o de exfiltración); logins correctos desde países o dispositivos nuevos; creación de usuarios administradores; uso de endpoints de administración fuera de horario; caída del volumen de logs (¿alguien ha desactivado el logging?); y despliegues sin pull request asociado.

A10 · SSRF (Server-Side Request Forgery)

Qué es: tu servidor hace una petición HTTP a una URL que controla (total o parcialmente) el usuario. El atacante no llega a esa red, pero tu servidor sí: le sirves de proxy hacia la red interna. Es especialmente devastador en la nube, donde el servicio de metadatos (169.254.169.254) puede entregar credenciales de IAM del rol de la instancia.

// ❌ VULNERABLE — "importar una imagen desde una URL": el clásico
@PostMapping("/productos/{id}/imagen")
public void importar(@PathVariable Long id, @RequestParam String urlImagen) throws IOException {
    try (InputStream in = new URL(urlImagen).openStream()) {          // ← cualquier URL, cualquier red
        almacen.guardar(id, in.readAllBytes());
    }
}
// Lo que un atacante puede pedir (a modo ilustrativo, sin detallar explotación):
//   · el servicio de metadatos de la nube (169.254.169.254) → credenciales temporales de IAM
//   · servicios "internos" que confían en localhost (por ejemplo, Actuator en otro puerto)
//   · la API del clúster de Kubernetes
//   · un escaneo de puertos internos: el TIEMPO de respuesta y el error revelan qué hay abierto
//   · otros esquemas de URL (file:, gopher:, jar:) que amplían el impacto

// ✅ CORREGIDO — lista blanca, resolución explícita y sin redirecciones
@Service
public class DescargadorSeguro {

    private static final Set<String> HOSTS_PERMITIDOS =
            Set.of("cdn.proveedor.com", "imagenes.socio.es");
    private static final long TAMANO_MAXIMO = 5L * 1024 * 1024;

    private final HttpClient cliente = HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NEVER)      // ⚠️ una redirección sortea la lista blanca
            .connectTimeout(Duration.ofSeconds(3))
            .build();

    public byte[] descargar(String urlTexto) throws IOException, InterruptedException {
        URI uri = URI.create(urlTexto);

        // 1) Esquema: solo https
        if (!"https".equalsIgnoreCase(uri.getScheme()))
            throw new IllegalArgumentException("Solo se admite https");

        // 2) Host: lista blanca EXACTA (no "contains", no "endsWith": cdn.proveedor.com.atacante.io)
        String host = uri.getHost();
        if (host == null || !HOSTS_PERMITIDOS.contains(host.toLowerCase(Locale.ROOT)))
            throw new IllegalArgumentException("Host no permitido");

        // 3) Resolver el DNS y comprobar que NINGUNA IP es interna
        //    (protege contra un dominio permitido que resuelva a una dirección privada)
        for (InetAddress ip : InetAddress.getAllByName(host)) {
            if (esInterna(ip)) throw new IllegalArgumentException("Destino interno no permitido");
        }

        // 4) Petición con timeout y límite de tamaño
        HttpRequest peticion = HttpRequest.newBuilder(uri)
                .timeout(Duration.ofSeconds(10))
                .GET().build();
        HttpResponse<byte[]> respuesta = cliente.send(peticion, BodyHandlers.ofByteArray());

        if (respuesta.statusCode() != 200) throw new IOException("Respuesta " + respuesta.statusCode());
        if (respuesta.body().length > TAMANO_MAXIMO) throw new IOException("Fichero demasiado grande");
        if (!esImagenPorFirma(respuesta.body())) throw new IOException("No es una imagen válida");
        return respuesta.body();
    }

    private static boolean esInterna(InetAddress ip) {
        return ip.isLoopbackAddress()      // 127.0.0.0/8, ::1
            || ip.isLinkLocalAddress()     // 169.254.0.0/16  ← metadatos de la nube
            || ip.isSiteLocalAddress()     // 10/8, 172.16/12, 192.168/16
            || ip.isAnyLocalAddress()
            || ip.isMulticastAddress()
            || esCgnat(ip);                // 100.64.0.0/10
    }
}
La validación de IP tiene una trampa sutil: DNS rebinding. Entre el momento en que compruebas la IP y el momento en que la librería HTTP resuelve el nombre otra vez, el DNS del atacante puede haber cambiado la respuesta. La defensa robusta no es solo la validación en código, sino arquitectónica: todas las peticiones salientes a través de un proxy de egreso con lista blanca, políticas de red de Kubernetes que impidan salir del pod salvo a destinos concretos, y bloqueo explícito del endpoint de metadatos (en AWS, exigir IMDSv2 y poner el límite de saltos a 1). Ver módulo 09.

6.11 Otras vulnerabilidades que debes conocer

XSS en plantillas y en APIs

<!-- Thymeleaf ESCAPA por defecto: es su mayor virtud de seguridad -->
<p th:text="${comentario}"></p>
<!-- Si el comentario contiene una etiqueta <script>, se renderiza como TEXTO. Correcto. -->

<!-- ❌ th:utext NO escapa: "u" de unescaped. Solo con HTML ya saneado
     (OWASP Java HTML Sanitizer) -->
<div th:utext="${descripcionHtml}"></div>

<!-- ❌ Contextos donde el escapado HTML NO BASTA porque el contexto no es HTML -->
<script>var usuario = "[[${nombre}]]";</script>      contexto JS: usa th:inline="javascript"
<a th:href="${urlDelUsuario}">enlace</a>             un esquema javascript: ejecuta código
<div th:attr="onclick=${accion}">                     manejador de eventos: nunca datos del usuario
<style th:text="${cssDelUsuario}">                    contexto CSS

✅ Reglas: escapa SEGÚN EL CONTEXTO, valida las URLs (solo http/https o rutas relativas),
   nunca inyectes en manejadores de eventos ni dentro de <script> sin th:inline, y añade
   una CSP estricta con nonce como red de seguridad.

En una API REST no hay XSS… salvo que lo provoques tú. Devolver JSON con Content-Type: application/json es seguro (el navegador no lo ejecuta). Se rompe si: sirves contenido subido por el usuario con un Content-Type adivinado (de ahí X-Content-Type-Options: nosniff); devuelves HTML construido a mano; usas JSONP; o el frontend hace innerHTML = respuesta.campo, que es XSS en el cliente y ninguna cabecera del servidor lo evita.

VulnerabilidadEn qué consisteMitigación en Java/Spring
Path traversal Un nombre de fichero con secuencias de subida de directorio (también codificadas, o con separadores de Windows) escapa del directorio previsto y lee o escribe donde no debe. base.resolve(nombre).normalize() y comprobar startsWith(base). Mejor todavía: no uses el nombre del usuario: genera un UUID y guarda el nombre original solo como metadato.
XXE (entidades externas en XML) Un XML declara una entidad que apunta a un fichero local o a una URL interna: lectura de ficheros, SSRF y denegación de servicio (billion laughs). Endurecer la factoría del parser (código abajo) o, si puedes, no aceptar XML.
ReDoS Una expresión regular con cuantificadores anidados tarda tiempo exponencial con una entrada construida a propósito: un núcleo al 100% con una sola petición. Expresiones simples y ancladas; límite de longitud de entrada antes de aplicar la regex; cuantificadores posesivos; nunca aceptes una regex del usuario; Pattern.compile como constante.
Mass assignment El JSON de entrada se enlaza directamente a la entidad y el atacante añade campos que no le corresponden ("rol":"ADMIN", "saldo":99999, "id":1). DTO explícito con solo los campos editables (un record es perfecto). Nunca @RequestBody Usuario sobre una entidad. Si usas la entidad, @JsonIgnore y setDisallowedFields en un @InitBinder.
Subida de ficheros insegura Se acepta cualquier fichero, se confía en la extensión o en el Content-Type declarado, se guarda con el nombre original y se sirve desde el mismo dominio. Validar por firma de bytes (magic bytes), no por extensión; límite de tamaño; nombre generado; almacenar fuera del directorio servido (o en un bucket privado con URL prefirmada); servir desde otro dominio con Content-Disposition: attachment y nosniff; antivirus (ClamAV) para ficheros que otros usuarios descargarán.
Open redirect /login?next=https://sitio-falso.example: tu dominio de confianza redirige a la web del atacante, lo que da credibilidad al phishing y puede filtrar tokens en el fragmento. Solo rutas relativas, o lista blanca de destinos absolutos. Valida también los redirect_uri de OAuth2 con coincidencia exacta.
Clickjacking Tu página en un iframe transparente sobre otra: el usuario cree pulsar “Ver vídeo” y pulsa “Transferir”. frame-ancestors 'none' en la CSP y X-Frame-Options: DENY (Spring Security ya lo pone). Confirmaciones que requieran interacción explícita.
Fuga por caché y por Referer Datos privados cacheados en un CDN, o tokens en la URL que viajan en la cabecera Referer a terceros. Cache-Control: no-store en respuestas privadas; nunca secretos en la query string; Referrer-Policy.
// XXE: endurecer un parser XML (haz un @Bean con esto y no crees factorías a mano)
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);  // lo esencial
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

// Mass assignment: el DTO es la mitigación, no un capricho de arquitectura
// ❌
@PutMapping("/usuarios/{id}")
public Usuario actualizar(@PathVariable Long id, @RequestBody Usuario usuario) { … }
//   Un PUT con {"nombre":"Ana","rol":"ADMIN","saldo":999999,"emailVerificado":true}
//   escribiría TODOS esos campos.

// ✅
public record ActualizarUsuarioDto(@NotBlank @Size(max = 100) String nombre,
                                   @Email String email) { }        // SOLO lo que puede cambiar

@PutMapping("/usuarios/{id}")
public UsuarioDto actualizar(@PathVariable Long id,
                             @Valid @RequestBody ActualizarUsuarioDto dto,
                             @AuthenticationPrincipal Jwt jwt) {
    return servicio.actualizarPerfil(id, dto, jwt.getSubject());     // y comprueba la propiedad
}

// Subida de ficheros: validar por contenido, no por lo que diga el cliente
private static final Map<String, byte[]> FIRMAS = Map.of(
        "image/png",  new byte[]{(byte) 0x89, 'P', 'N', 'G'},
        "image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});

public String guardar(MultipartFile fichero) throws IOException {
    if (fichero.getSize() > 5 * 1024 * 1024) throw new PayloadTooLarge();
    byte[] cabecera = Arrays.copyOf(fichero.getBytes(), 8);
    String tipoReal = FIRMAS.entrySet().stream()
            .filter(e -> empiezaPor(cabecera, e.getValue()))
            .map(Map.Entry::getKey).findFirst()
            .orElseThrow(() -> new TipoNoPermitido("Solo PNG o JPEG"));

    String nombre = UUID.randomUUID() + extensionDe(tipoReal);       // el nombre del usuario NO se usa
    almacen.subir(nombre, fichero.getInputStream(), tipoReal);        // bucket privado
    return nombre;
}

Checklist — OWASP en mi proyecto

7. Cadena de suministro y seguridad del repositorio

7.1 Dependencias: proceso, no heroísmo

Ya vimos las herramientas en A06. Aquí lo importante es el proceso, porque la seguridad de dependencias falla por falta de rutina, no por falta de herramientas.

PrácticaCómo se implementaPor qué importa
Versiones fijadas y gestionadas por BOMspring-boot-starter-parent o dependencyManagement con el BOM importado. Cero rangos, cero LATEST.Builds reproducibles y combinaciones probadas por el equipo de Spring. Sin esto, dos builds del mismo commit pueden dar artefactos distintos.
Actualizaciones automatizadasDependabot o Renovate con agrupación por familia y ejecución semanal.Diez PRs pequeños al mes son manejables; un salto de dos versiones mayores en plena crisis, no.
SCA en cada PRdependency-check, Trivy, Grype o Snyk, con --exit-code 1 para HIGH/CRITICAL.Detecta la vulnerabilidad antes de fusionar.
Repositorio interno como proxyNexus o Artifactory delante de Maven Central, con políticas de bloqueo.Te protege de la retirada de un artefacto y del dependency confusion (un paquete público con el nombre de tu paquete interno).
Comprobación de checksums y firmasmvn --strict-checksums; verificación de firmas PGP de los artefactos.Detecta manipulación en el trayecto o en un espejo.
Presupuesto de dependenciasRevisión explícita al añadir una nueva: ¿está mantenida? ¿cuántas transitivas arrastra? ¿podríamos escribir esas 30 líneas?Cada dependencia es código de terceros ejecutándose con los permisos de tu proceso.

7.2 SBOM, firma de artefactos y procedencia

# SBOM: la "lista de ingredientes" del artefacto, generada en el build y ARCHIVADA por versión
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom     # → target/bom.json y bom.xml
syft packages miempresa/pedidos:1.4.2 -o cyclonedx-json > sbom-1.4.2.json

# Con el SBOM archivado, responder a "¿nos afecta el CVE de X?" tarda segundos:
grep -c '"name":"log4j-core"' sbom-1.4.2.json
trivy sbom sbom-1.4.2.json --severity HIGH,CRITICAL   # reevaluar SIN reconstruir la imagen

# Firma sin claves (identidad OIDC de tu CI: no hay secreto que rotar ni que filtrar)
cosign sign --yes ghcr.io/miempresa/pedidos:1.4.2
cosign attest --yes --predicate sbom-1.4.2.json \
              --type cyclonedx ghcr.io/miempresa/pedidos:1.4.2

# Verificación EN EL CLÚSTER antes de admitir la imagen (Kyverno, Gatekeeper, política del runtime)
cosign verify ghcr.io/miempresa/pedidos:1.4.2 \
  --certificate-identity-regexp 'https://github\.com/miempresa/.+' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# SLSA: niveles de garantía sobre CÓMO se construyó el artefacto
#  L1: existe procedencia documentada
#  L2: build en un servicio alojado, con procedencia firmada
#  L3: build aislado y procedencia no falsificable
# Objetivo realista para un equipo de producto: L2 pronto, L3 en lo que se publica hacia fuera.
Por qué firmar importa aunque tu repositorio sea privado: la firma responde a una pregunta que el tag no puede responder: ¿esta imagen la construyó mi pipeline a partir de mi código, o la subió alguien con un token robado? Los tags son mutables (:1.4.2 puede apuntar mañana a otra cosa); una firma verificada en el momento de la admisión, no. Es la diferencia entre confiar en un nombre y confiar en una prueba criptográfica. Por eso también se despliega por digest y no por tag.

7.3 Secretos: el fallo más común y más fácil de evitar

# Detección ANTES de que el secreto entre en el historial (hook local)
gitleaks protect --staged --redact --verbose

# Y en CI, sobre TODO el historial (por si ya entró hace meses):
gitleaks detect --source . --log-opts="--all" --redact

# Con pre-commit, para que lo tenga todo el equipo sin esfuerzo:
#   .pre-commit-config.yaml
#   repos:
#     - repo: https://github.com/gitleaks/gitleaks
#       rev: v8.21.0
#       hooks: [ { id: gitleaks } ]

# Complementos útiles: trufflehog (comprueba si la credencial está VIVA), el escaneo de
# secretos y la "push protection" de GitHub, y detect-secrets con una línea base.
Qué hacer si un secreto llega al repositorio (en este orden, y sin saltarse el paso 1):
  1. ROTAR la credencial inmediatamente. Asume que está comprometida. Los bots escanean los repositorios públicos en segundos: una clave de acceso a la nube filtrada se usa en menos de un minuto. Este es el único paso urgente.
  2. Revisar los logs de uso de esa credencial (CloudTrail o el equivalente) para saber si se usó, desde dónde y para qué. Eso determina si hay un incidente que notificar.
  3. Reducir su alcance al reemitirla: si era una clave con permisos amplios, la nueva debe tener el mínimo.
  4. Limpiar el historial con git filter-repo (o BFG) y coordinar el push forzado con todo el equipo. Esto no “deshace” la fuga: los forks, los clones locales y la caché de la plataforma pueden conservarla. Es higiene, no remediación.
  5. Añadir la detección que faltaba (hook y CI) y escribir un postmortem sin culpables: la persona que hizo el commit no es el problema; la ausencia de una barrera automática, sí.
Dónde poner un secretoVeredictoComentario
application.yml del repositorioNUNCAQueda en el historial para siempre y lo ve cualquiera con acceso de lectura, incluidos los forks y las herramientas de análisis.
Variable de entornoAceptableEstándar y sencillo. Ojo: visible en /proc/PID/environ, en docker inspect, en kubectl describe pod y en cualquier volcado de memoria. No rota sin reinicio.
Secret de KubernetesAceptable con condicionesEs Base64, no cifrado. Exige cifrado en reposo en etcd, RBAC estricto y, mejor, montarlo como fichero en lugar de como variable de entorno.
Sealed Secrets / SOPSBien para GitOpsPermite versionar el secreto cifrado en git. La clave de descifrado vive en el clúster o en un KMS.
Vault / Secrets Manager / Key VaultLo mejorRotación automática, credenciales dinámicas de corta vida, auditoría de cada acceso y revocación central.
Identidad de la carga de trabajo (IRSA, Workload Identity, Managed Identity)Lo idealElimina el secreto: el pod obtiene credenciales temporales por su propia identidad. No hay nada que filtrar ni que rotar.
# Spring Boot leyendo secretos de Vault (spring-cloud-starter-vault-config)
spring:
  config:
    import: "vault://secret/pedidos"
  cloud:
    vault:
      uri: https://vault.miempresa.com
      authentication: KUBERNETES     # el pod se autentica con su ServiceAccount: sin secreto inicial
      kubernetes:
        role: pedidos
        service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
      kv:
        enabled: true
        backend: secret
  datasource:
    # El valor lo resuelve Vault en el arranque; el YAML del repositorio no contiene el secreto.
    # Y SIN valor por defecto: si Vault no responde, la aplicación NO ARRANCA (fallar de forma segura).
    password: ${db-password}

7.4 Endurecer el repositorio y la integración continua

# .github/workflows/ci.yml — permisos mínimos y controles de seguridad en el pipeline
name: CI
on:
  pull_request:
  push: { branches: [main] }

permissions:
  contents: read                 # ← por defecto MÍNIMO; se amplía solo en el job que lo necesita

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  verificar:
    runs-on: ubuntu-latest
    steps:
      # Acciones ancladas por SHA, no por tag: un tag se puede mover a código malicioso
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
        with: { fetch-depth: 0 }

      - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
        with: { java-version: '21', distribution: temurin, cache: maven }

      - name: Fugas de secretos
        run: gitleaks detect --source . --redact

      - name: Compilar y probar
        run: mvn -B verify

      - name: SAST
        run: mvn -B com.github.spotbugs:spotbugs-maven-plugin:check   # con find-sec-bugs

      - name: SCA de dependencias
        run: mvn -B org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7

      - name: SBOM
        run: mvn -B org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom

      - name: Escaneo de la imagen
        run: trivy image --severity HIGH,CRITICAL --exit-code 1 miempresa/pedidos:${{ github.sha }}

      - uses: actions/upload-artifact@v4
        with: { name: sbom, path: target/bom.json }

  publicar:
    needs: verificar
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
      id-token: write            # necesario para firmar con cosign sin claves (OIDC)
    environment: produccion      # con revisores obligatorios configurados en el entorno
    runs-on: ubuntu-latest
    steps: []                    # build, push y cosign sign
Control del repositorioQué evita
Protección de main: sin push directo, sin force push, sin borradoReescrituras del historial y saltarse la revisión.
Revisión obligatoria (≥ 1 revisor, ≥ 2 en código sensible) y CODEOWNERSQue un solo desarrollador —o un token robado— meta código en producción.
Status checks obligatorios y rama actualizada antes de fusionarFusionar con CI en rojo o con conflictos semánticos.
Commits firmados y verificación de firmaSuplantación de autoría, que es trivial: basta un git config user.email.
Permisos mínimos del token de CI, declarados por jobQue un paso comprometido publique artefactos o modifique el repositorio.
Secretos por entorno, con revisores obligatoriosQue un PR desde un fork alcance credenciales de producción.
Prohibido pull_request_target con checkout del código del PREjecutar código no revisado con los secretos del repositorio: vector clásico de compromiso de CI.
Acciones ancladas por SHA y limitadas a fuentes permitidasQue una acción de terceros cambie bajo el mismo tag y exfiltre secretos.

Checklist — cadena de suministro

8. Seguridad en la nube y en contenedores

Resumen con enfoque de seguridad; el detalle de Docker, Kubernetes, despliegue y observabilidad está en módulo 09.

# Dockerfile endurecido para una aplicación Spring Boot 3
# ─── Fase de construcción ─────────────────────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests && \
    java -Djarmode=tools -jar target/app.jar extract --layers --destination /app/extraido

# ─── Fase de ejecución ────────────────────────────────────────────────────────
FROM gcr.io/distroless/java21-debian12:nonroot     # sin shell, sin gestor de paquetes, sin curl
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/extraido/dependencies/ ./
COPY --from=build --chown=nonroot:nonroot /app/extraido/application/  ./
USER nonroot                                       # UID 65532: NUNCA root
EXPOSE 8080
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","org.springframework.boot.loader.launch.JarLauncher"]

POR QUÉ CADA DECISIÓN:
 · imagen distroless o mínima → decenas de CVEs menos y superficie de ataque ínfima
 · sin shell                  → un RCE no encuentra /bin/sh con el que moverse
 · usuario no root            → un escape del proceso no es root en el nodo
 · multi-stage                → el JDK, Maven, el código fuente y ~/.m2 NO viajan a producción
 · capas de Spring Boot       → mejor caché y despliegues más rápidos (menos ventana de parcheo)
# Kubernetes: contexto de seguridad restrictivo (Pod Security Standard "restricted")
apiVersion: apps/v1
kind: Deployment
metadata: { name: pedidos }
spec:
  template:
    spec:
      serviceAccountName: pedidos          # SA propia con RBAC mínimo (NUNCA la "default")
      automountServiceAccountToken: false  # si el pod no habla con la API de K8s, no le des token
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        fsGroup: 65532
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: app
          image: ghcr.io/miempresa/pedidos@sha256:3f1c9a…   # por DIGEST: inmutable y verificable
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true                    # nada de escribir en la imagen
            capabilities: { drop: ["ALL"] }                 # ninguna capacidad de Linux
          resources:                                       # los límites TAMBIÉN son seguridad
            requests: { cpu: 250m, memory: 512Mi }
            limits:   { cpu: "1",  memory: 1Gi }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }                # el único directorio escribible
      volumes:
        - name: tmp
          emptyDir: { medium: Memory, sizeLimit: 64Mi }
---
# Política de red: denegar el egreso y permitir solo lo necesario.
# Es LA mitigación arquitectónica del SSRF y del movimiento lateral.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: pedidos-egreso }
spec:
  podSelector: { matchLabels: { app: pedidos } }
  policyTypes: [Egress]
  egress:
    - to: [{ podSelector: { matchLabels: { app: postgres } } }]
      ports: [{ port: 5432 }]
    - to: [{ namespaceSelector: { matchLabels: { name: kube-system } } }]     # DNS
      ports: [{ port: 53, protocol: UDP }]
    - to: [{ ipBlock: { cidr: 0.0.0.0/0,
                        except: ["169.254.169.254/32", "10.0.0.0/8"] } }]     # sin metadatos
      ports: [{ port: 443 }]
ÁreaPrácticaPor qué
ImagenBase mínima, sin root, sistema de ficheros raíz de solo lectura, sin capacidades, escaneo en CI y en el registro.Reduce lo que un atacante encuentra dentro y lo que puede hacer con ello.
RedNetworkPolicy por defecto denegando ingreso y egreso; segmentación por namespace; sin IPs públicas en las cargas internas.Convierte un compromiso puntual en un problema contenido, no en acceso a toda la red.
IAMRoles en lugar de claves (IRSA, Workload Identity), un rol por servicio, permisos por recurso, sin comodines, sin usuarios con claves de larga vida.Las credenciales estáticas se filtran; las temporales caducan solas. Y el mínimo privilegio limita el daño.
CifradoEn reposo con claves gestionadas (y propias, CMK, para datos sensibles); en tránsito TLS en todas partes, incluido el tráfico interno.Un disco, una copia de seguridad o un snapshot mal configurado dejan de ser una fuga.
BordeWAF con reglas gestionadas, protección DDoS, rate limiting en el ingress, TLS con certificados renovados automáticamente.Filtra el ruido genérico y absorbe el volumen antes de que llegue a tu aplicación.
EntornosCuentas o suscripciones separadas para desarrollo, preproducción y producción; sin datos reales fuera de producción.Un error o un compromiso en desarrollo no puede tocar producción.
AuditoríaCloudTrail / Activity Log / Audit Logs activos en todas las regiones, en un destino inmutable y en otra cuenta.Sin registros no hay investigación posible; y un atacante con permisos borra los registros que estén a su alcance.
ConfiguraciónInfraestructura como código con revisión, más escaneo de IaC (Checkov, tfsec, trivy config).La mayoría de las brechas en la nube son configuraciones incorrectas, no exploits.
El fallo de nube más repetido de la última década: almacenamiento de objetos público por accidente (un bucket con listado abierto, una política demasiado amplia, un “temporal” que se quedó). Activa el bloqueo de acceso público a nivel de cuenta, no solo por bucket, y usa URLs prefirmadas de corta duración cuando necesites compartir un objeto concreto.

9. Privacidad y cumplimiento

9.1 RGPD: lo que un desarrollador necesita saber de verdad

El Reglamento General de Protección de Datos (RGPD, o GDPR) no es solo un asunto del departamento legal: varios de sus principios se traducen directamente en decisiones de código y de esquema. En España lo complementa la LOPDGDD y la autoridad de control es la AEPD.

Principio / derechoQué exigeTraducción técnica
MinimizaciónRecoger solo los datos necesarios para la finalidad.Revisa cada campo del formulario y de la tabla: ¿para qué se usa? Si nadie sabe responderlo, bórralo. El dato que no guardas no se puede filtrar.
Limitación de la finalidadLos datos recogidos para X no se usan para Y.No reutilices la tabla de clientes para entrenar un modelo o para enviar publicidad si no era la finalidad declarada.
Base legalTodo tratamiento necesita una: consentimiento, contrato, obligación legal, interés legítimo…Consentimiento granular (una casilla por finalidad, nunca premarcada) y registrado: qué, cuándo, versión del texto y desde qué IP.
Derecho de acceso y portabilidadEntregar al interesado sus datos en formato legible por máquina.Un endpoint o proceso de exportación que reúna los datos de todos los sistemas, no solo de la base de datos principal.
Derecho de supresión (“al olvido”)Borrar cuando ya no haya base legal para conservarlos.Aquí está la dificultad real: hay que borrar también en réplicas, copias de seguridad, índices de búsqueda, cachés, logs, almacén de datos analíticos y sistemas de terceros. Diséñalo desde el principio.
Rectificación y oposiciónCorregir datos y oponerse a determinados tratamientos.Datos editables por el usuario y banderas de preferencias respetadas en todos los procesos.
Seguridad del tratamiento (art. 32)Medidas técnicas apropiadas al riesgo.Es, literalmente, todo lo demás de este módulo: cifrado, seudonimización, control de acceso, pruebas.
Notificación de brechasA la autoridad en 72 horas desde que se tiene conocimiento; a los afectados sin dilación si el riesgo es alto.Necesitas detectar (A09) y poder determinar el alcance: qué datos, de cuántas personas. Sin logs de auditoría es imposible, y no poder determinarlo empeora tu posición.
Encargados del tratamientoContrato (DPA) con cada proveedor que trate datos por ti, y control de transferencias internacionales.Inventario de subencargados: proveedor de nube, correo transaccional, analítica, soporte, monitorización, y ahora también proveedores de IA.
Privacidad por diseño y por defecto (art. 25)La configuración más protectora es la predeterminada.Perfiles privados por defecto, sin casillas premarcadas, retención mínima como valor inicial.

9.2 Datos personales en logs y en entornos de prueba

La práctica más extendida y más grave: copiar la base de datos de producción a preproducción o al portátil de desarrollo “para tener datos realistas”. En ese momento los datos personales de miles de personas pasan a un entorno con contraseñas débiles, sin auditoría, accesible a todo el equipo, con copias en portátiles y sin política de retención. Es una brecha esperando a ocurrir, y ante la autoridad de control es difícil de defender.
TécnicaReversible¿Sigue siendo dato personal?Cuándo usarla
Seudonimización (sustituir identificadores por tokens con tabla aparte)Sí, con la tabla de correspondencia: el RGPD sigue aplicando, pero reduce el riesgo y es una medida reconocida.Analítica interna, soporte, trazabilidad.
Anonimización (irreversible: agregación, supresión, generalización)NoNo, si de verdad es irreversible.Estadística e informes. Cuidado con la reidentificación por combinación de campos.
Enmascarado (ES••••••••3456)No para quien lo veDepende de lo que quede visible.Interfaces de soporte, exportaciones parciales.
Datos sintéticos (generados con Datafaker/Instancio, con el mismo esquema y distribución)No aplicaNoLa mejor opción para desarrollo, pruebas y demostraciones.
// Generar datos de prueba realistas SIN datos reales (Datafaker + Testcontainers, módulo 07)
var faker = new Faker(new Locale("es", "ES"));
for (int i = 0; i < 1_000; i++) {
    repositorio.save(new Cliente(
            faker.name().fullName(),
            faker.internet().emailAddress(),
            faker.address().streetAddress(),
            faker.number().numberBetween(18, 90)));
}

// Enmascarado en la capa de presentación (no en la de dominio: allí necesitas el valor)
public static String enmascararIban(String iban) {
    if (iban == null || iban.length() < 8) return "••••";
    return iban.substring(0, 4) + "•".repeat(iban.length() - 8) + iban.substring(iban.length() - 4);
}

// Serialización: que un DTO no arrastre por accidente un campo sensible
public record ClienteDto(
        Long id,
        String nombre,
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password,  // entra, nunca sale
        @JsonIgnore String iban,
        String ibanEnmascarado) { }

// Y en la entidad, un toString() que no filtre nada en un log
@Override
public String toString() {
    return "Cliente[id=" + id + "]";          // ni nombre, ni email, ni dirección
}

9.3 PCI-DSS, tokenización y auditoría de acceso

Si tu aplicación toca datos de tarjetas, entras en el alcance de PCI-DSS. La estrategia correcta no es “cumplir PCI-DSS”, sino salir del alcance:

-- Auditoría de acceso a datos personales: obligatoria de facto para el RGPD
-- y la única forma de detectar el abuso por parte de usuarios internos.
CREATE TABLE auditoria_acceso_datos (
    id           BIGSERIAL PRIMARY KEY,
    momento      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    actor        VARCHAR(100) NOT NULL,      -- quién (usuario o servicio)
    actor_tipo   VARCHAR(20)  NOT NULL,      -- 'usuario' | 'servicio'
    accion       VARCHAR(40)  NOT NULL,      -- 'LEER' | 'EXPORTAR' | 'MODIFICAR' | 'BORRAR'
    entidad      VARCHAR(60)  NOT NULL,      -- 'Cliente'
    entidad_id   VARCHAR(60)  NOT NULL,      -- el id, NO el contenido del dato
    motivo       VARCHAR(200),               -- nº de ticket de soporte, por ejemplo
    ip           INET,
    trace_id     VARCHAR(40)
);

-- Índices para las consultas que de verdad harás en una investigación
CREATE INDEX idx_aud_actor_momento  ON auditoria_acceso_datos (actor, momento DESC);
CREATE INDEX idx_aud_entidad        ON auditoria_acceso_datos (entidad, entidad_id, momento DESC);

-- Reglas de oro de una tabla de auditoría:
--   · SOLO INSERT: el usuario de la aplicación no tiene UPDATE ni DELETE sobre ella
--     REVOKE UPDATE, DELETE ON auditoria_acceso_datos FROM app_pedidos;
--   · Retención definida y particionado por mes para poder archivar y purgar.
--   · Nunca guardes el VALOR del dato consultado: guardas quién vio qué registro, no su contenido.
--   · Copia hacia un destino externo e inmutable (WORM), fuera del alcance de la aplicación.

-- Consulta típica de detección: ¿alguien está mirando demasiados clientes?
SELECT actor, count(DISTINCT entidad_id) AS clientes_distintos
FROM   auditoria_acceso_datos
WHERE  entidad = 'Cliente' AND accion = 'LEER' AND momento > now() - interval '1 hour'
GROUP  BY actor
HAVING count(DISTINCT entidad_id) > 50;      -- umbral según tu operación normal
Requisito de retenciónImplementación
Cada categoría de dato tiene un plazoDocumentado en el registro de tratamientos y ejecutado por un proceso automático, no por la buena voluntad de nadie.
Borrado que respeta obligaciones legalesLa normativa fiscal y contable obliga a conservar facturas durante años: se conserva el documento y se suprimen o seudonimizan los datos no necesarios. El “derecho al olvido” no es absoluto.
Borrado en cascada realBase de datos, réplicas, copias de seguridad (con su propio plazo documentado), índices de búsqueda, cachés, almacén analítico, logs y proveedores externos.
Prueba del borradoUn test de integración que cree un usuario, ejecute el borrado y verifique que no queda en ninguno de los almacenes. Si no está probado, no funciona.

Checklist — privacidad

10. Proceso: SDLC seguro, incidentes y vulnerabilidades

10.1 Ciclo de vida seguro

SDLC SEGURO — qué se hace en cada fase y con qué coste de corregir

 REQUISITOS         DISEÑO            DESARROLLO         PRUEBAS          PRODUCCIÓN
     │                 │                   │                 │                 │
 · requisitos      · modelado de      · linters +       · DAST en        · WAF y rate limiting
   de seguridad      amenazas           SAST en el IDE    staging        · monitorización y alertas
   explícitos        (STRIDE)         · revisión de     · pruebas de     · gestión de parches
 · clasificación   · elección de        código con        autorización   · respuesta a incidentes
   de datos          controles          checklist         por rol        · pentest periódico
 · normativa       · decisiones       · SCA en cada     · escaneo de     · bug bounty
   aplicable         registradas        PR                imagen         · postmortems
     │                 │                   │                 │                 │
 COSTE RELATIVO DE ARREGLAR EL MISMO FALLO:
     1x               ~5x                ~10x             ~30x           ~100x  (+ multa
                                                                                  + reputación)

 Eso es todo el argumento del "shift-left": no es una moda, es aritmética.

Checklist de revisión de código orientada a seguridad

  • ¿Se valida toda la entrada en el servidor (tipo, rango, longitud, formato)?
  • ¿Hay autorización, y comprueba la propiedad del recurso, no solo el rol?
  • ¿Se construye alguna consulta, comando o plantilla por concatenación?
  • ¿Se escapa la salida según el contexto (HTML, atributo, JS, URL)?
  • ¿Hay algún secreto, clave o token en el diff?
  • ¿Los logs nuevos contienen datos personales, tokens o cuerpos completos?
  • ¿Los mensajes de error revelan información interna?
  • ¿El endpoint nuevo tiene límite de tamaño, paginación con tope y rate limiting?
  • ¿Hay tests de “anónimo → 401” y “usuario ajeno → 404/403”?
  • ¿Se ha añadido alguna dependencia? ¿Está justificada y sin CVEs?
  • ¿Se usa criptografía? ¿Con los modos y parámetros de la sección 2?
  • ¿Alguna petición saliente usa una URL controlada por el usuario?

Tipos de prueba: qué encuentra cada una

  • SAST (código estático: SpotBugs con find-sec-bugs, Semgrep, SonarQube, CodeQL): patrones peligrosos y flujos de datos contaminados. Rápido y temprano; muchos falsos positivos.
  • SCA (dependencias: Dependency-Check, Trivy, Grype, Snyk): CVEs conocidos en librerías. Alta señal, poco ruido. Empieza por aquí.
  • DAST (aplicación en ejecución: OWASP ZAP, Burp): configuración, cabeceras, autorización real, inyecciones reflejadas. Encuentra lo que el código no muestra.
  • IAST (agente dentro de la aplicación durante las pruebas): combina ambos; menos falsos positivos, coste de licencia y de rendimiento.
  • Pentest manual: lógica de negocio, cadenas de fallos y todo lo que ninguna herramienta entiende. Imprescindible antes de un lanzamiento importante.
  • Bug bounty: cobertura continua y con incentivos alineados. Requiere madurez: una política pública (security.txt), triaje ágil y capacidad de arreglar rápido.

10.2 Respuesta a incidentes

CICLO DE RESPUESTA A INCIDENTES (NIST SP 800-61)

 PREPARACIÓN ──► DETECCIÓN ──► CONTENCIÓN ──► ERRADICACIÓN ──► RECUPERACIÓN ──► LECCIONES
  (antes)          y ANÁLISIS                                                   APRENDIDAS
                                                                                    │
      └─────────────────────── realimenta la preparación ◄────────────────────────┘

 PREPARACIÓN (lo único que se hace con calma, así que hazlo bien)
   · Runbook escrito y accesible SIN acceso a los sistemas comprometidos.
   · Roles: quién decide, quién comunica, quién investiga, quién habla con negocio y con legal.
   · Canal de comunicación alternativo (si el atacante está dentro, no uses el chat corporativo).
   · Contactos: proveedor de nube, seguro, asesoría jurídica, autoridad de control.
   · Logs centralizados, con retención suficiente y FUERA del alcance del atacante.
   · Simulacros: una vez al año como mínimo. Un runbook nunca ensayado no funciona.

 DETECCIÓN Y ANÁLISIS
   · ¿Qué sistemas? ¿Qué datos? ¿Desde cuándo? ¿Sigue el acceso activo?
   · Preserva evidencias ANTES de tocar nada: snapshot del disco y de la memoria, copia de logs.
   · Cronología escrita desde el primer minuto: hora, quién, qué observó, qué hizo.

 CONTENCIÓN  (rápido, pero pensando)
   · Aislar (política de red, grupo de seguridad), NO apagar sin más: apagar destruye
     la memoria y con ella la evidencia. Y a veces avisa al atacante.
   · Rotar credenciales y revocar sesiones y tokens (aquí se paga el precio del JWT no revocable).
   · Bloquear el vector: parchear, cerrar el endpoint, activar una regla de WAF.

 ERRADICACIÓN
   · Eliminar persistencia: cuentas creadas, claves SSH, webshells, tareas programadas,
     imágenes o funciones modificadas, reglas de reenvío de correo.
   · Reconstruir desde una imagen limpia y verificada. NO "limpiar" un servidor comprometido.

 RECUPERACIÓN
   · Restaurar servicio de forma controlada, con monitorización reforzada.
   · Verificar integridad de datos y de copias de seguridad ANTES de restaurarlas.

 LECCIONES APRENDIDAS  (en 1–2 semanas, por escrito)
   · SIN CULPABLES: se busca la causa sistémica, no la persona. Si hay culpa, no habrá informes
     sinceros la próxima vez, y eso es peor que el incidente.
   · Acciones concretas con dueño y fecha. Y al menos un TEST AUTOMÁTICO nuevo.

 RELOJ REGULATORIO: 72 h para notificar a la autoridad de control desde que se CONOCE la
 brecha de datos personales. Es un plazo corto: la decisión de notificar se toma con
 información incompleta y debe estar prevista en el runbook, no improvisada.

10.3 Gestión de vulnerabilidades y formación

ConceptoQué mideCómo usarlo
CVSS (0–10)Severidad técnica teórica en el peor caso.Punto de partida, nunca la decisión final. Ajusta con las métricas de entorno: un CVSS 9.8 en una librería que ni cargas no es tu prioridad.
EPSS (0–1)Probabilidad de que se explote en los próximos 30 días.Combínalo con CVSS: severidad alta y probabilidad alta = arréglalo hoy.
KEV (CISA)Lista de vulnerabilidades explotadas activamente.Si algo tuyo está en KEV, deja lo que estés haciendo.
Contexto propio¿Es alcanzable? ¿Requiere autenticación? ¿Está expuesto a Internet? ¿Hay mitigación en el WAF?Es lo que convierte una lista de 400 hallazgos en una lista de 6 acciones reales.
PrioridadCriterioPlazo objetivo (SLA típico)
EmergenciaEn KEV, o crítica y explotable desde Internet sin autenticación (un Log4Shell).Horas. Con mitigación temporal inmediata (WAF, apagar la función) si el parche tarda.
CríticaCVSS ≥ 9 y alcanzable en tu configuración.72 horas.
AltaCVSS 7–8,9 alcanzable.2 semanas.
Media / BajaResto, o no alcanzable.Siguiente ciclo de actualización de dependencias.

Formación del equipo: lo que funciona no es el curso anual de dos horas que todos hacen con el vídeo en otra pestaña. Funciona lo que está pegado al trabajo diario: un campeón de seguridad por equipo (una persona con interés y tiempo asignado, no un cargo honorífico); revisar los hallazgos reales del propio proyecto en la reunión semanal; laboratorios prácticos (PortSwigger Academy, Juice Shop, WebGoat) en horario laboral; sesiones de modelado de amenazas donde se aprende haciendo; y difundir los postmortems sin culpables, que son el material didáctico más eficaz que existe porque hablan de vuestro propio sistema.

Checklist — proceso

11. Errores comunes y soluciones

Síntoma o errorCausa realSolución
403 inesperado en todo justo después de añadir spring-boot-starter-security Con la dependencia en el classpath, Spring Boot protege todos los endpoints por defecto (HTTP Basic con un usuario user y contraseña generada en el log). Si además haces POST desde una herramienta, CSRF lo bloquea. Declara tu propia SecurityFilterChain con reglas explícitas. Es la señal de que la configuración por defecto —correcta y restrictiva— está funcionando.
403 en POST/PUT/DELETE de mi API, y en GET no El CsrfFilter exige el token en métodos que cambian estado. Si es una API stateless con Bearer, desactiva CSRF solo en esa cadena con securityMatcher. Si es una SPA con cookie, envía el token en X-XSRF-TOKEN. Nunca lo desactives globalmente “para que funcione Postman”.
403 solo al llamar a un método interno con @PreAuthorize Ausencia de @EnableMethodSecurity, o el método es private/final, o se llama con this.metodo() y no pasa por el proxy. Activa @EnableMethodSecurity; método public en un bean; extrae la lógica a otro bean si la llamada es interna.
“Error de CORS” en la consola del navegador Casi nunca es CORS: el preflight OPTIONS recibe 401, o el origen no está en la lista, o hay dos configuraciones de CORS que se contradicen. http.cors(...) con un único CorsConfigurationSource y orígenes explícitos. Mira el código de estado real del OPTIONS en la pestaña de red, no el mensaje del navegador.
Arranque fallido: “When allowCredentials is true, allowedOrigins cannot contain the special value \"*\"” Combinación prohibida por la especificación de CORS. Lista explícita de orígenes, o allowedOriginPatterns si de verdad necesitas comodines.
Mi API acepta tokens de otra API del mismo emisor No se valida el claim aud. Es el fallo de JWT más frecuente y más grave. spring.security.oauth2.resourceserver.jwt.audiences, o un JwtClaimValidator para aud. Y un test que lo demuestre.
401 intermitentes tras un despliegue o de madrugada Reloj del contenedor desincronizado (exp/nbf), o rotación de claves con caché de JWKS antigua, o varias réplicas firmando con claves distintas porque se generan al arrancar. NTP en los nodos; margen de reloj de 30–60 s; clave de firma persistente y compartida; respetar el kid y refrescar el JWKS al ver uno desconocido.
Un XSS ha robado las sesiones de todos los usuarios Token en localStorage: cualquier script de la página lo lee y lo exfiltra. Cookie HttpOnly+Secure+SameSite (patrón BFF), CSP estricta, escapado por contexto y revisión de las dependencias del frontend.
Contraseñas con SHA-256 (o MD5, o en claro) Se usó un hash rápido de propósito general, pensando que “hash = seguro”. DelegatingPasswordEncoder y migración perezosa con upgradeEncoding en el login (sección 2.2). No obliga a nadie a cambiar de contraseña.
Secreto en el repositorio Un application.yml con la contraseña “solo para probar” que se quedó, y nadie tenía detección automática. Rotar primero; revisar logs de uso; reducir permisos; limpiar el historial; añadir gitleaks en hook y CI (sección 7.3).
Actuator abierto en producción management.endpoints.web.exposure.include: "*" copiado de un tutorial. Lista blanca (health,info,prometheus), puerto de gestión separado y no publicado, show-values: never, y un test de configuración (sección A05).
Un cliente ha visto los pedidos de otro (IDOR) El endpoint carga por id sin comprobar la propiedad: autenticado ≠ autorizado. La propiedad, dentro de la consulta (findByIdAndClienteNif); 404 en lugar de 403; test de “usuario ajeno” por endpoint; revisión de todos los endpoints con {id}.
Miles de intentos de login y cuentas comprometidas Sin rate limiting ni bloqueo temporal: fuerza bruta y credential stuffing gratis. Límite por IP y por cuenta, retroceso exponencial, captcha tras N fallos, MFA, comprobación contra listas de contraseñas filtradas, y límite duro en el WAF.
Se puede saber qué correos están registrados Mensajes de error distintos (o tiempos de respuesta distintos) en login, registro y recuperación. Mensaje y código idénticos; hash de compensación para igualar el tiempo; envío del correo de recuperación siempre, de forma asíncrona.
Dependencia vulnerable en producción durante meses Sin SCA en CI y sin rutina de actualización: se descubre por un aviso externo. Dependency-Check o Trivy con --exit-code 1 en cada PR, Dependabot semanal, SBOM archivado y priorización con EPSS/KEV.
RCE por deserialización ObjectInputStream con datos externos, o Jackson con default typing abierto (enableDefaultTyping). JSON con tipos explícitos y DTO; polimorfismo cerrado con @JsonSubTypes o BasicPolymorphicTypeValidator; filtro JEP 290 si estás obligado a la serialización nativa.
TLS deshabilitado “solo en desarrollo” que llegó a producción Un TrustManager que acepta todo o un HostnameVerifier que devuelve true, dentro del código y no en la configuración. Nunca en el código de producción: usa un truststore con la CA interna, o cert-manager. Añade una regla de SAST que falle el build si aparece ese patrón.
Logs con datos personales log.info("Usuario: " + usuario) con un toString() que imprime todos los campos; cuerpos de peticiones registrados en producción. toString() con solo el id; logging estructurado con campos elegidos; filtro de enmascarado en Logback; revisión de logs nuevos en la revisión de código.
Un pago se cobró dos veces (o el saldo quedó negativo) Leer-comprobar-escribir sin atomicidad, más reintentos de red sin idempotencia. UPDATE … WHERE saldo >= importe atómico, CHECK en el esquema, clave de idempotencia con índice único (sección A04).
SecurityContextHolder devuelve anonymousUser o null dentro de un @Async El contexto vive en un ThreadLocal que no se propaga a otros hilos. DelegatingSecurityContextExecutor / …Runnable, o pasa la identidad como parámetro explícito al trabajo asíncrono.
El logout no cierra la sesión de verdad Con JWT, el cliente borra el token pero este sigue siendo válido; o con OIDC no se cierra la sesión en el proveedor de identidad. Access tokens cortos + revocación del refresh token en el servidor; OidcClientInitiatedLogoutSuccessHandler para el logout federado; sesión en Redis si necesitas cierre inmediato.

12. Preguntas frecuentes

¿JWT o sesión? ¿Cuál elijo para mi proyecto?

Depende de la arquitectura, no de la seguridad: ninguno es “más seguro”. Si tienes una aplicación web con navegador (monolito o BFF) y quieres poder expulsar a alguien al instante, usa sesión con cookie HttpOnly+Secure+SameSite: es más simple, revocable y no expone nada al JavaScript. Si tienes APIs consumidas por móvil o por terceros, microservicios que deben validar sin estado compartido, o federación con un proveedor de identidad, usa JWT y asume el coste: tokens cortos, refresh rotativo y una estrategia de revocación. Elegir JWT en un monolito con navegador es complicarse la vida y perder revocación a cambio de nada.

¿Dónde guardo el token en el navegador?

Preferentemente en una cookie HttpOnly+Secure+SameSite gestionada por un BFF, con token CSRF. La segunda mejor opción es el access token en una variable en memoria (se pierde al recargar) con el refresh en cookie HttpOnly. Evita localStorage: cualquier XSS —incluido el que llegue por una dependencia npm comprometida— lo lee y lo exfiltra, y el atacante lo usa desde su máquina durante días. Matiz honesto: HttpOnly no anula el XSS (con XSS activo se pueden hacer peticiones en tu nombre), pero impide el robo de la credencial, que es lo que convierte un incidente en una catástrofe.

¿Qué es PKCE y por qué es obligatorio ahora?

Proof Key for Code Exchange. El cliente genera un secreto aleatorio (code_verifier) y envía solo su hash SHA-256 (code_challenge) al iniciar el flujo; al canjear el código presenta el verificador original. Así, un código interceptado —en la barra de direcciones, en el historial, en un log de proxy o por otra app registrada en el mismo esquema de URL en móvil— es inútil sin el verificador, que nunca sale de la memoria del cliente legítimo. En OAuth 2.1 es obligatorio para todos los clientes, también los confidenciales, porque protege además contra la inyección de código de autorización. Usa siempre S256, nunca plain.

¿Hace falta CSRF en una API que usa JWT?

La pregunta correcta no es “¿uso JWT?”, sino “¿el navegador envía la credencial automáticamente?”. Si el token viaja en la cabecera Authorization puesta por tu JavaScript, no hace falta: el navegador nunca añade esa cabecera por su cuenta y un origen ajeno no puede fabricarla. Si guardas el JWT en una cookie, entonces hace falta, porque se comporta como cualquier cookie. Y si tu aplicación tiene una parte web con sesión, no desactives CSRF globalmente: separa las dos cadenas con securityMatcher.

¿Roles o authorities? ¿Y qué pasa con el prefijo ROLE_?

Técnicamente son lo mismo: GrantedAuthority es una cadena. La diferencia está en la convención: hasRole("ADMIN") añade el prefijo y busca literalmente ROLE_ADMIN, mientras que hasAuthority("X") busca X exacto. Si tu GrantedAuthority no empieza por ROLE_, hasRole nunca coincidirá: es la causa de la mitad de los 403 “inexplicables”. Recomendación de diseño: usa roles como agrupación para asignar (“es SOPORTE”) pero autoriza por permiso fino en el código (hasAuthority("pedidos:cancelar")). Así, cambiar quién puede cancelar un pedido es una modificación de datos, no un despliegue.

¿Cómo hasheo contraseñas correctamente?

Con Argon2id (primera opción en proyectos nuevos) o BCrypt con coste 10–12, siempre a través de PasswordEncoder. Lo práctico es declarar un PasswordEncoderFactories.createDelegatingPasswordEncoder(): codifica con el algoritmo actual y sabe verificar los formatos históricos gracias al prefijo {bcrypt}, lo que te permite cambiar de algoritmo sin migraciones. Verifica siempre con matches(), nunca comparando hashes a mano, y usa upgradeEncoding() en el login para rehashear de forma transparente. Nunca MD5, SHA-1 ni SHA-256 “con sal”: son demasiado rápidos y una GPU prueba miles de millones por segundo.

Se ha filtrado una clave o un secreto. ¿Qué hago exactamente?

Rotar es lo primero y lo urgente; todo lo demás va después. Emite una credencial nueva con el mínimo privilegio, despliega, invalida la antigua. Después: revisa los logs de uso (CloudTrail o equivalente) para determinar si se usó y desde dónde, valora si hay incidente notificable, limpia el historial con git filter-repo —sabiendo que eso no deshace la fuga: los forks y los clones la conservan— y añade la detección automática que faltaba. Si la clave protegía datos cifrados, además hay que recifrar: por eso conviene diseñar con cifrado en sobre y versionado de claves desde el principio.

¿Cómo protejo un endpoint interno que solo debe llamar otro servicio?

Por capas, y nunca solo por red: (1) que no esté publicado en el ingress y esté en un puerto o ruta aparte; (2) políticas de red que solo permitan el tráfico de los pods autorizados; (3) autenticación de la carga de trabajo: mTLS con la malla de servicio, o un token de client credentials con un scope propio; (4) autorización explícita en la aplicación (hasAuthority("SCOPE_interno.pedidos")). Lo que no vale como control de seguridad: comprobar la IP de origen, confiar en una cabecera que puede falsificar cualquiera (X-Internal: true) o suponer que “está en la VPN”. Eso es seguridad de cáscara de huevo.

¿Qué es mTLS y cuándo lo necesito?

TLS mutuo: además de que el cliente valide el certificado del servidor, el servidor pide y valida el certificado del cliente. Resultado: identidad criptográfica en ambos extremos, a nivel de conexión, sin secretos compartidos que rotar. Se usa para servicio a servicio (es la base de Istio y Linkerd, que lo aplican de forma transparente y rotan los certificados solos), para integraciones con socios en banca y sanidad, y para autenticar clientes OAuth2 sin client_secret. Para usuarios finales es poco práctico (distribuir y renovar certificados en cada dispositivo). Importante: mTLS autentica el servicio, no al usuario: complementa los tokens, no los sustituye.

¿Cómo hago logout con JWT?

Aquí está la factura de haber elegido JWT: un token firmado no se puede “desfirmar”. Lo que se hace en la práctica: (1) el cliente borra el token —necesario pero insuficiente, porque una copia robada sigue valiendo—; (2) el servidor revoca el refresh token, que sí tiene estado, de modo que no se puedan obtener más accesos; (3) el access token expira solo en 5–15 minutos, que es la ventana que has aceptado al diseñar; (4) si necesitas corte inmediato, añade una denylist por jti en Redis o una versión de credenciales en el token que invalide todos los anteriores. Y si usas OIDC, llama al end_session_endpoint del proveedor (OidcClientInitiatedLogoutSuccessHandler) o el usuario seguirá con sesión abierta allí y volverá a entrar sin teclear nada.

¿Cómo evito los IDOR de forma sistemática?

Con un patrón, no con disciplina individual. Mete la propiedad en la consulta (findByIdAndClienteNif) en lugar de cargar y comprobar después: así es imposible olvidarlo, no cargas datos ajenos y devuelves 404 en lugar de 403, sin confirmar la existencia del recurso. Añade una segunda barrera con @PreAuthorize o un AuthorizationManager; usa UUID en las URLs públicas (ofuscación útil, no mitigación); y, sobre todo, escribe un test por endpoint del tipo “usuario ajeno → 404”. Cuando revises un proyecto heredado, empieza buscando todos los @PathVariable y @RequestParam que sean identificadores.

¿Cómo pruebo la seguridad de mi aplicación?

En cuatro niveles, del más barato al más caro. Tests automáticos con spring-security-test: “anónimo → 401”, “sin permiso → 403”, “usuario ajeno → 404”, cabeceras presentes, y tests de configuración (Actuator restringido, sin perfiles de desarrollo). SCA en cada PR: el mejor retorno por esfuerzo invertido. SAST (SpotBugs con find-sec-bugs, Semgrep, CodeQL) aceptando que habrá falsos positivos. Y DAST y pentest sobre un entorno desplegado (OWASP ZAP en CI nocturna, Burp a mano) para lo que no se ve en el código. Para practicar de forma legal y sin riesgo: PortSwigger Web Security Academy, OWASP Juice Shop y WebGoat.

¿Qué es SSRF y por qué es tan grave en la nube?

Server-Side Request Forgery: consigues que el servidor haga una petición a una URL que tú controlas. El atacante no tiene acceso a la red interna, pero tu servidor sí, así que le sirves de proxy: servicios internos que confían en localhost, la API del clúster, escaneo de puertos internos… En la nube es especialmente crítico porque el servicio de metadatos (169.254.169.254) puede entregar credenciales temporales del rol de la instancia, y con eso el atacante pasa de “leer una URL” a “tener permisos en tu cuenta”. Mitigación en dos planos: en código, lista blanca de host exacta, solo https, sin seguir redirecciones y comprobando que ninguna IP resuelta sea interna; y en arquitectura, proxy de egreso, políticas de red y bloqueo del endpoint de metadatos (IMDSv2 con límite de saltos 1). La parte arquitectónica es la robusta, porque el DNS rebinding puede burlar la validación en código.

En microservicios, ¿quién valida el token: el gateway o cada servicio?

Los dos. El gateway valida en el borde y rechaza pronto lo inválido, lo que ahorra trabajo y da un punto único para el rate limiting y el registro. Pero cada servicio vuelve a validar el token que recibe, incluida su audiencia: eso es confianza cero. Si solo valida el gateway, cualquiera que alcance la red interna —un SSRF, un pod comprometido, un trabajo programado mal configurado— habla con tus servicios sin credenciales. El coste de validar es una verificación de firma con la clave pública cacheada: microsegundos. No hay excusa de rendimiento.

¿Cómo gestiono secretos en Kubernetes?

Por orden de preferencia: (1) identidad de la carga de trabajo (IRSA, Workload Identity, Managed Identity), que elimina el secreto porque el pod obtiene credenciales temporales por su identidad; (2) un gestor externo (Vault, Secrets Manager, Key Vault) vía External Secrets Operator o el CSI driver, con rotación automática y auditoría de accesos; (3) Sealed Secrets o SOPS si trabajas con GitOps y necesitas versionar el secreto cifrado; (4) Secret nativo, recordando que es Base64, no cifrado, y que requiere cifrado en reposo en etcd y RBAC estricto. Preferiblemente montado como fichero y no como variable de entorno (las variables aparecen en describe pod, en volcados y en los logs de arranque de muchos frameworks). Y jamás en el application.yml del repositorio.

¿Es seguro poner datos en un JWT? He leído que va “cifrado”.

No va cifrado. Un JWS es Base64URL, que es codificación, no cifrado: cualquiera con el token lee todo el contenido con un comando. La firma garantiza integridad y autenticidad, no confidencialidad. Por tanto: no metas datos personales innecesarios, ni DNI, ni direcciones, ni secretos, ni datos de salud. Mete un identificador y los claims mínimos de autorización. Si de verdad necesitas confidencialidad del contenido, existe JWE (cifrado), pero casi siempre es más simple usar un token opaco y consultar el endpoint de introspección del emisor.

¿Necesito un WAF si mi código es seguro?

El WAF no sustituye al código seguro: es defensa en profundidad y, sobre todo, tiempo. Su valor real es que te permite mitigar en minutos una vulnerabilidad cuyo parche tardará días (fue exactamente lo que se hizo con Log4Shell), absorbe el ruido automatizado que llena tus logs y añade rate limiting y protección DDoS en el borde, donde el volumen se aguanta. Sus límites: genera falsos positivos que rompen funcionalidad legítima, se puede evadir con codificaciones creativas y no entiende nada de tu lógica de negocio, así que no detectará un IDOR ni un cupón reutilizable. Ponlo, y no lo uses como excusa para no arreglar el código.

¿Cada cuánto debo obligar a cambiar la contraseña?

Nunca, de forma periódica. La guía actual (NIST SP 800-63B, y también el criterio del NCSC británico) descarta la rotación obligatoria: produce contraseñas predecibles (Verano2026!Otoño2026!), reutilización y notas adhesivas. Fuerza el cambio solo cuando haya indicio de compromiso. Lo que sí funciona: longitud mínima de 8 (recomendado 12+) y máxima de al menos 64, permitir todos los caracteres y espacios, comprobar contra listas de contraseñas filtradas, MFA (idealmente passkeys) y detección de accesos anómalos. Es menos molesto para el usuario y objetivamente más seguro.

13. Ejercicios y retos

Ejercicios guiados

Retos

14. Resumen y recursos

Las 15 ideas que debes llevarte

  1. La seguridad es una propiedad del diseño, no una capa que se añade al final. Modelar amenazas una hora en la pizarra rinde más que un mes de parches.
  2. Todo requisito se expresa en términos de confidencialidad, integridad y disponibilidad, más autenticidad y no repudio.
  3. Defensa en profundidad y mínimo privilegio son los dos principios que más incidentes evitan. “Estamos detrás del firewall” no es un argumento.
  4. La mayoría de los fallos graves son de autorización, no de autenticación: el atacante suele ser un usuario legítimo mirando datos que no son suyos.
  5. No implementes criptografía. Elige bien (Argon2id o BCrypt para contraseñas, AES-GCM con IV aleatorio para datos, TLS 1.3 en el canal) y gestiona las claves en un KMS.
  6. En Spring Security 6, entender la cadena de filtros convierte los errores misteriosos en diagnósticos de treinta segundos.
  7. Configura con beans y lambda DSL, de lo específico a lo general, y con anyRequest().authenticated() al final: deny by default.
  8. Autoriza también en la capa de servicio: la autorización por URL es frágil y no cubre colas, tareas programadas ni llamadas internas.
  9. La propiedad del recurso va dentro de la consulta. Es la forma sistemática de eliminar los IDOR, que son la vulnerabilidad número uno.
  10. Un JWT no es más seguro que una sesión. Es una decisión de arquitectura: ganas escalado, pierdes revocación. Elige a conciencia.
  11. Validar un token es validar firma, algoritmo, iss, aud, exp/nbf y kid. Olvidar aud es el fallo más común.
  12. OAuth 2 autoriza, OpenID Connect autentica. El flujo correcto para casi todo es Authorization Code + PKCE; Implicit y Password grant están fuera.
  13. El OWASP Top 10 no es una lista para memorizar: es un mapa de las diez formas en que tu aplicación va a fallar si no lo evitas a propósito.
  14. Tú escribes el 2% del código que ejecutas. La cadena de suministro —dependencias, SBOM, firma, secretos, CI— es tan importante como tu propio código.
  15. Si no hay logs, alertas y un test automático, la medida de seguridad no existe: nadie sabrá que ha fallado y volverá a romperse en el próximo refactor.

Recursos imprescindibles

  • OWASP Top 10 (owasp.org/Top10): el punto de partida y lo que preguntan en las entrevistas.
  • OWASP ASVS (Application Security Verification Standard): requisitos verificables por niveles. Úsalo como checklist de proyecto, es lo que el Top 10 no es.
  • OWASP Cheat Sheet Series: la referencia práctica. Fíjate en las de Password Storage, Authentication, JWT for Java, SSRF Prevention, Deserialization y Secrets Management.
  • Documentación de Spring Security: excelente y actualizada. Lee entera la sección de arquitectura y la de autorización.
  • oauth.net y las RFC: 6749 (OAuth 2), 7636 (PKCE), 8693 (Token Exchange), 8252 (apps nativas), 9700 (buenas prácticas actuales) y el borrador de OAuth 2.1.

Para profundizar y practicar

  • “OAuth 2 in Action” (Richer y Sanso, Manning): el mejor libro para entender OAuth de verdad, construyéndolo paso a paso.
  • Documentación de Keycloak: guía del administrador y de securización de aplicaciones.
  • PortSwigger Web Security Academy: gratuita, con laboratorios legales y de calidad excepcional. Es lo que más te va a enseñar por hora invertida.
  • OWASP Juice Shop y WebGoat: aplicaciones vulnerables a propósito para practicar sin riesgo.
  • Snyk, Trivy, Grype, Dependency-Check, gitleaks, cosign: las herramientas que deberías tener en CI mañana.
  • NIST SP 800-63B (identidad y contraseñas), NIST SP 800-61 (respuesta a incidentes), CIS Benchmarks (endurecimiento), AEPD (guías de RGPD en español).
Cómo seguir a partir de aquí: no intentes aplicarlo todo de golpe. Elige tres cosas para esta semana: SCA en CI (la mejor relación entre esfuerzo y riesgo evitado), revisar todos los endpoints con un identificador buscando IDOR, y comprobar que validas la audiencia de los tokens. Con esas tres habrás eliminado una porción desproporcionada del riesgo real de tu aplicación. El resto viene después, y llega antes si lo conviertes en tests y no en buenas intenciones.