Passkey en lugar de contraseña: cómo funcionan e integración
1. Descripción general
Las contraseñas tienen tres problemas principales: son fáciles de adivinar, se reutilizan en muchos lugares y es fácil engañar a los usuarios para que las escriban en páginas falsas. Las passkey resuelven los tres problemas usando criptografía de clave pública. El servidor ya no guarda ningún secreto del usuario.
Este artículo explica qué son las passkey y cómo funcionan los flujos de registro e inicio de sesión. Luego, habilitarás las passkey en una aplicación Spring Boot 3.x usando Spring Security. Al final del artículo encontrarás los errores comunes y cuándo no deberías usar passkey.
2. ¿Qué es una passkey?
Una passkey es una credencial de WebAuthn, es decir, una credencial que sigue el estándar FIDO2. En esencia, es un par de claves:
- Clave privada que reside en el dispositivo del usuario (teléfono, portátil, llave de seguridad USB) o en un gestor de contraseñas. Esta clave nunca abandona ese lugar.
- Clave pública que se envía al servidor durante el registro. El servidor guarda esta clave junto con la cuenta.
Cada par de claves está vinculado a una Relying Party (RP), es decir, tu sitio web o aplicación, a través del RP ID. El RP ID suele ser el nombre de dominio, por ejemplo shop.example.com.
Hay dos tipos principales de passkey:
| Tipo | Características |
|---|---|
| Passkey sincronizada | Se sincroniza a través de la cuenta en la nube del sistema operativo o del gestor de contraseñas. Si pierdes el dispositivo, puedes recuperarla. |
| Passkey vinculada al dispositivo | Solo existe en un dispositivo, por ejemplo una llave de seguridad de hardware. Más segura, pero si pierdes el dispositivo, pierdes la clave. |
El usuario desbloquea la passkey usando su huella dactilar, rostro o PIN del dispositivo. Los datos biométricos solo se usan para desbloquear localmente. No se envían al servidor.
3. Cómo funcionan las passkey
3.1. Registro (registration)
- El servidor crea un challenge aleatorio. El servidor envía el challenge junto con la información de RP y usuario al navegador.
- El navegador llama a
navigator.credentials.create(). El autenticador genera un nuevo par de claves para ese RP ID exacto. - El navegador envía la clave pública, el ID de credencial y los datos firmados al servidor.
- El servidor verifica el challenge y el origin, luego guarda la clave pública.
3.2. Autenticación (authentication)
- El servidor crea un nuevo challenge.
- El navegador llama a
navigator.credentials.get(). El autenticador firma el challenge con la clave privada. - El servidor usa la clave pública guardada para verificar la firma.
El núcleo es “firmar con la clave privada, verificar con la clave pública”. El siguiente código Java puro simula exactamente este paso:
import java.security.*;
public class PasskeyConcept {
public static void main(String[] args) throws Exception {
// El dispositivo genera un par de claves durante el registro (EC P-256, correspondiente a ES256 común en WebAuthn)
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
KeyPair deviceKeys = generator.generateKeyPair();
PublicKey storedOnServer = deviceKeys.getPublic();
// El servidor crea un challenge aleatorio para el inicio de sesión
byte[] challenge = randomBytes();
// El dispositivo firma el challenge con la clave privada
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(deviceKeys.getPrivate());
signer.update(challenge);
byte[] signature = signer.sign();
// El servidor verifica usando la clave pública guardada
System.out.println("Challenge correcto: " + verify(storedOnServer, challenge, signature));
System.out.println("Challenge diferente: " + verify(storedOnServer, randomBytes(), signature));
}
static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
Signature verifier = Signature.getInstance("SHA256withECDSA");
verifier.initVerify(key);
verifier.update(data);
return verifier.verify(signature);
}
static byte[] randomBytes() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return bytes;
}
}
Resultado:
Challenge correcto: true
Challenge diferente: false
De este resultado, puedes extraer dos conclusiones:
- El servidor solo guarda
storedOnServer, es decir, la clave pública. Incluso si un atacante obtiene la base de datos, no puede usarla para iniciar sesión. - La firma solo es válida para el challenge de esa ocasión. Si un atacante captura una firma antigua y la reenvía (ataque de repetición), no podrá pasar la verificación.
En WebAuthn real, los datos firmados también contienen el origin de la página. El navegador rellena el origin automáticamente, la página web no puede falsificarlo. Por lo tanto, si el usuario accede por error a examp1e.com, el navegador no usará la passkey de example.com. El phishing se bloquea en el nivel del protocolo, no depende de la cuidado del usuario.
4. Por qué las passkey son tendencia
- Anti-phishing por diseño. Las contraseñas y las OTP pueden ser engañadas e introducidas en páginas falsas. Las passkey no.
- Sin secretos en el servidor. Una filtración de base de datos no expone las credenciales de inicio de sesión.
- Mejor experiencia de usuario. El usuario solo necesita usar su huella digital, no tiene que recordar contraseñas ni esperar SMS.
- El ecosistema está listo. Los sistemas operativos, navegadores y gestores de contraseñas populares ya soportan la sincronización de passkey. Gracias a esto, perder un dispositivo ya no es una barrera tan grande como antes.
- Frameworks ya lo soportan. No necesitas escribir tu propia verificación criptográfica, que es propensa a errores.
5. Integración de passkey con Spring Security
A partir de Spring Security 6.4 (que viene con Spring Boot 3.4 en adelante), el framework incluye un DSL webAuthn(). Internamente, la verificación usa la librería WebAuthn4J.
5.1. Dependency
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-core</artifactId>
<version>${webauthn4j.version}</version>
</dependency>
</dependencies>
Debes declarar la property webauthn4j.version con una versión compatible con Spring Security que estés usando. La documentación de Spring Security especifica qué versiones han sido probadas.
5.2. Configuración del SecurityFilterChain
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn(webAuthn -> webAuthn
.rpName("Nón Lá Shop")
.rpId("localhost")
.allowedOrigins("http://localhost:8080")
);
return http.build();
}
@Bean
UserDetailsService userDetailsService() {
UserDetails customer = User.withUsername("minh")
.password("{noop}matkhau-tam")
.roles("CUSTOMER")
.build();
return new InMemoryUserDetailsManager(customer);
}
}
Significado de cada parte:
rpName: el nombre mostrado en el diálogo de passkey del sistema operativo.rpId: el nombre de dominio vinculado a la passkey. Este valor no contiene scheme ni puerto. En producción, seráshop.example.com.allowedOrigins: los orígenes válidos. El origen debe coincidir exactamente con el scheme y puerto.formLogin: Se mantiene. El usuario debe iniciar sesión con contraseña la primera vez para registrar passkey. La passkey es una credencial adicional vinculada a la cuenta existente.
Cuando ejecutas la aplicación, Spring Security crea automáticamente los siguientes endpoints:
GET /webauthn/register página de gestión/registro de passkey (predeterminada)
POST /webauthn/register/options crear challenge de registro
POST /webauthn/register guardar nueva clave pública
POST /webauthn/authenticate/options crear challenge de inicio de sesión
POST /login/webauthn verificar firma e iniciar sesión
La página de inicio de sesión predeterminada tendrá un botón adicional “Iniciar sesión con una passkey”. El flujo de prueba consta de cuatro pasos:
- Inicia sesión con
minh/matkhau-tam. - Abre
/webauthn/registery crea una passkey. - Cierra la sesión.
- Inicia sesión de nuevo con la passkey.
5.3. Almacenamiento de credenciales
Por defecto, Spring Security almacena las credenciales en memoria. Reiniciar la aplicación significa perder todas las passkey. En producción, necesitas proporcionar beans que implementen dos interfaces:
UserCredentialRepository: guarda la clave pública, el ID de credencial y el recuento de firmas.PublicKeyCredentialUserEntityRepository: mapea tus usuarios con el user handle de WebAuthn.
Las versiones recientes de Spring Security incluyen implementaciones JDBC para ambas interfaces. También puedes escribir las tuyas con JPA. En ese caso, cada credencial debe ser un registro separado, ya que un usuario generalmente tiene varias passkey en múltiples dispositivos.
6. Lado del navegador
Si usas la página de inicio de sesión predeterminada, Spring Security maneja el JavaScript por ti. Con una interfaz personalizada, el flujo de inicio de sesión se vería así:
async function loginWithPasskey() {
const csrf = document.querySelector('meta[name="_csrf"]').content;
// 1. Solicitar challenge al servidor
const optionsRes = await fetch('/webauthn/authenticate/options', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf }
});
const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());
// 2. El navegador muestra un diálogo, el autenticador firma el challenge
const credential = await navigator.credentials.get({ publicKey: options });
// 3. Enviar la firma al servidor para verificar
const res = await fetch('/login/webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify(credential.toJSON())
});
if (res.ok) window.location.href = '/';
}
Los pasos en el código:
- El paso 1 obtiene el challenge. La solicitud debe incluir un token CSRF porque es un
POST. parseRequestOptionsFromJSONconvierte los campos base64url aArrayBuffer. Los navegadores antiguos pueden no soportar esta función. En ese caso, necesitas una librería helper o decodificar manualmente.- El paso 3 envía el resultado al servidor. El formato JSON debe coincidir con lo que espera el servidor. El script en la página de inicio de sesión predeterminada de Spring Security es la mejor referencia para comparar.
7. Pruebas
No puedes simular un autenticador real en una prueba unitaria de forma simple. Sin embargo, puedes verificar la parte importante del lado del servidor: que el endpoint de creación del challenge funciona y devuelve un challenge diferente cada vez que se llama.
@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {
@Autowired
MockMvcTester mvc;
@Test
void authenticationOptions_containRpIdAndFreshChallenge() {
String first = requestChallenge();
String second = requestChallenge();
assertThat(first).isNotBlank();
assertThat(second).isNotEqualTo(first);
}
@Test
void authenticationOptions_useConfiguredRpId() {
assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
.hasStatusOk()
.bodyJson()
.extractingPath("$.rpId")
.isEqualTo("localhost");
}
private String requestChallenge() {
MvcTestResult result = mvc.post()
.uri("/webauthn/authenticate/options")
.with(csrf())
.exchange();
assertThat(result).hasStatusOk();
return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
}
}
MockMvcTester (Spring Framework 6.2+) permite escribir aserciones en estilo AssertJ. csrf() viene de SecurityMockMvcRequestPostProcessors. La primera prueba confirma que el challenge no se reutiliza, que es el mecanismo anti-repetición. La segunda prueba asegura que el rpId está configurado correctamente. Este es el error de configuración más común en el despliegue.
Para pruebas end-to-end, usas el autenticador virtual del navegador. Chrome DevTools tiene una pestaña WebAuthn, y WebDriver/Playwright también soportan esta funcionalidad.
8. Errores comunes y cuándo no usar
8.1. Errores comunes
rpIdoallowedOriginsincorrectos. Escribirhttps://enrpIdu olvidar el puerto en el origin causará que el navegador o el servidor rechace. Deberías extraer estos dos valores enapplication.yamlpor cada entorno.- No usar HTTPS. WebAuthn solo se ejecuta en un contexto seguro.
localhostes la única excepción. - Cambiar de dominio. Las passkey están vinculadas al RP ID. Si cambias el dominio principal, todas las passkey antiguas dejarán de funcionar.
- Almacenamiento en memoria en producción. El reinicio significa que los usuarios pierden sus passkey.
- Permitir solo registrar una passkey. Los usuarios necesitan varias passkey para varios dispositivos. Ten una página que las liste y permita eliminar cada una.
Nota: Las passkey solo son fuertes si el flujo de recuperación de cuenta también es fuerte. Si “olvidar la passkey” solo requiere enviar un enlace por correo, los atacantes se dirigirán al correo en lugar de a la passkey.
8.2. Cuándo no usar (o no usar solo)
- Usuarios en dispositivos antiguos o compartidos, como computadoras en mostrador o kioscos. En este caso, necesitas mantener un método alternativo.
- Sistemas que no se basan en navegador o app, como API entre servicios, CLI o trabajos en segundo plano. Estos casos deben usar OAuth2 client credentials, mTLS o API key.
- Requisitos de cumplimiento que solo aceptan claves de hardware. Las passkey sincronizadas pueden no cumplir. Necesitas passkey vinculadas al dispositivo y verificación de atestación.
- El equipo aún no tiene un procedimiento de soporte de recuperación de cuenta. Deberías implementar passkey como una opción complementaria primero, luego pasar a eliminar contraseñas.
9. Conclusión
Las passkey reemplazan el secreto compartido (contraseña) con un par de claves públicas vinculado al nombre de dominio. Así, las passkey eliminan phishing, credential stuffing y el riesgo de filtración de base de datos con un único mecanismo. Con Spring Security, solo necesitas agregar webAuthn() a SecurityFilterChain, configurar correctamente rpId y allowedOrigins, y reemplazar el almacén de credenciales en memoria con uno persistente.
La forma más segura de implementar es paso a paso: permitir que los usuarios agreguen passkey a su cuenta existente, diseñar un flujo de recuperación riguroso, y solo entonces considerar deshabilitar el inicio de sesión con contraseña.