Passkeys - Implementando autenticación sin contraseñas en web
El futuro de la autenticación ya está aquí: Passkeys
¿Qué son Passkeys?
Passkeys son credenciales FIDO2 que reemplazan contraseñas. Basadas en criptografía de clave pública:
- Resistentes a phishing (vinculadas al dominio)
- Sin secretos compartidos (servidor no guarda password)
- Sincronizadas entre dispositivos (iCloud, Google, Microsoft)
- Biometría como factor de autenticación
Cómo funcionan
Registro:
1. Usuario elige registrar passkey
2. Dispositivo genera par de claves (pública/privada)
3. Clave privada se guarda en dispositivo (Keychain, Google PM)
4. Clave pública se envía al servidor
5. Servidor guarda clave pública
Login:
1. Servidor envía challenge
2. Dispositivo firma challenge con clave privada
3. Usuario verifica con biometría/PIN
4. Servidor verifica firma con clave pública
5. ✅ Autenticado
Implementación: Backend (.NET)
Instalar Fido2 library
dotnet add package Fido2
Configuración
// Program.cs
builder.Services.AddFido2(options =>
{
options.ServerDomain = "tusitio.com";
options.ServerName = "Mi Aplicación";
options.Origins = new HashSet<string> { "https://tusitio.com" };
});
Registro de Passkey
[ApiController]
[Route("api/[controller]")]
public class PasskeyController : ControllerBase
{
private readonly IFido2 _fido2;
private readonly IUserRepository _userRepository;
// Paso 1: Generar opciones de registro
[HttpPost("register/options")]
public async Task<IActionResult> GetRegistrationOptions([FromBody] string username)
{
var user = await _userRepository.GetOrCreateUser(username);
var existingCredentials = await _userRepository.GetCredentials(user.Id);
var options = _fido2.RequestNewCredential(
new Fido2User
{
Id = Encoding.UTF8.GetBytes(user.Id),
Name = user.Email,
DisplayName = user.DisplayName
},
existingCredentials.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)).ToList(),
AuthenticatorSelection.Default,
AttestationConveyancePreference.None
);
// Guardar challenge en sesión
HttpContext.Session.SetString("fido2.registration", options.ToJson());
return Ok(options);
}
// Paso 2: Verificar y guardar credencial
[HttpPost("register/verify")]
public async Task<IActionResult> VerifyRegistration([FromBody] AuthenticatorAttestationRawResponse response)
{
var jsonOptions = HttpContext.Session.GetString("fido2.registration");
var options = CredentialCreateOptions.FromJson(jsonOptions);
var result = await _fido2.MakeNewCredentialAsync(
response,
options,
async (args, cancellationToken) =>
{
// Verificar que credentialId no existe
var exists = await _userRepository.CredentialExists(args.CredentialId);
return !exists;
}
);
if (result.Status != "ok")
{
return BadRequest(result.ErrorMessage);
}
// Guardar credencial
await _userRepository.SaveCredential(new StoredCredential
{
UserId = Encoding.UTF8.GetString(result.Result.User.Id),
CredentialId = result.Result.CredentialId,
PublicKey = result.Result.PublicKey,
SignCount = result.Result.Counter
});
return Ok(new { success = true });
}
}
Login con Passkey
// Paso 1: Generar opciones de login
[HttpPost("login/options")]
public async Task<IActionResult> GetLoginOptions([FromBody] string? username)
{
IEnumerable<PublicKeyCredentialDescriptor> allowedCredentials = null;
if (!string.IsNullOrEmpty(username))
{
var user = await _userRepository.GetUser(username);
var credentials = await _userRepository.GetCredentials(user.Id);
allowedCredentials = credentials.Select(c =>
new PublicKeyCredentialDescriptor(c.CredentialId));
}
var options = _fido2.GetAssertionOptions(
allowedCredentials,
UserVerificationRequirement.Preferred
);
HttpContext.Session.SetString("fido2.login", options.ToJson());
return Ok(options);
}
// Paso 2: Verificar assertion
[HttpPost("login/verify")]
public async Task<IActionResult> VerifyLogin([FromBody] AuthenticatorAssertionRawResponse response)
{
var jsonOptions = HttpContext.Session.GetString("fido2.login");
var options = AssertionOptions.FromJson(jsonOptions);
var credential = await _userRepository.GetCredentialById(response.Id);
if (credential == null)
{
return Unauthorized("Credential not found");
}
var result = await _fido2.MakeAssertionAsync(
response,
options,
credential.PublicKey,
credential.SignCount,
async (args, cancellationToken) =>
{
// Verificar que el usuario es el dueño de la credencial
var cred = await _userRepository.GetCredentialById(args.CredentialId);
return cred?.UserId == Encoding.UTF8.GetString(args.UserHandle);
}
);
if (result.Status != "ok")
{
return Unauthorized(result.ErrorMessage);
}
// Actualizar sign count (previene replay attacks)
await _userRepository.UpdateSignCount(response.Id, result.Counter);
// Generar JWT
var token = GenerateJwtToken(credential.UserId);
return Ok(new { token });
}
Implementación: Frontend
JavaScript/TypeScript
// passkey.ts
export async function registerPasskey(username: string): Promise<void> {
// 1. Obtener opciones del servidor
const optionsResponse = await fetch('/api/passkey/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(username),
});
const options = await optionsResponse.json();
// 2. Convertir strings a ArrayBuffer
options.challenge = base64ToArrayBuffer(options.challenge);
options.user.id = base64ToArrayBuffer(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((c: any) => ({
...c,
id: base64ToArrayBuffer(c.id),
}));
}
// 3. Crear credencial (esto activa biometría/PIN)
const credential = await navigator.credentials.create({
publicKey: options,
}) as PublicKeyCredential;
// 4. Enviar al servidor para verificar
const attestationResponse = credential.response as AuthenticatorAttestationResponse;
await fetch('/api/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64(attestationResponse.clientDataJSON),
attestationObject: arrayBufferToBase64(attestationResponse.attestationObject),
},
type: credential.type,
}),
});
}
export async function loginWithPasskey(username?: string): Promise<string> {
// 1. Obtener opciones
const optionsResponse = await fetch('/api/passkey/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(username),
});
const options = await optionsResponse.json();
options.challenge = base64ToArrayBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((c: any) => ({
...c,
id: base64ToArrayBuffer(c.id),
}));
}
// 2. Obtener assertion (biometría/PIN)
const credential = await navigator.credentials.get({
publicKey: options,
}) as PublicKeyCredential;
const assertionResponse = credential.response as AuthenticatorAssertionResponse;
// 3. Verificar en servidor
const verifyResponse = await fetch('/api/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64(assertionResponse.clientDataJSON),
authenticatorData: arrayBufferToBase64(assertionResponse.authenticatorData),
signature: arrayBufferToBase64(assertionResponse.signature),
userHandle: assertionResponse.userHandle
? arrayBufferToBase64(assertionResponse.userHandle)
: null,
},
type: credential.type,
}),
});
const { token } = await verifyResponse.json();
return token;
}
// Helpers
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
React component
// PasskeyLogin.tsx
import { useState } from 'react';
import { registerPasskey, loginWithPasskey } from './passkey';
export function PasskeyLogin() {
const [status, setStatus] = useState<string>('');
const handleRegister = async () => {
try {
setStatus('Registering...');
await registerPasskey('user@example.com');
setStatus('Passkey registered successfully!');
} catch (error) {
setStatus(`Error: ${error.message}`);
}
};
const handleLogin = async () => {
try {
setStatus('Authenticating...');
const token = await loginWithPasskey();
setStatus('Logged in successfully!');
// Guardar token, redirigir, etc.
} catch (error) {
setStatus(`Error: ${error.message}`);
}
};
return (
<div>
<h2>Passkey Authentication</h2>
<button onClick={handleRegister}>Register Passkey</button>
<button onClick={handleLogin}>Login with Passkey</button>
<p>{status}</p>
</div>
);
}
Soporte de navegadores
// Verificar soporte
async function isPasskeySupported(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false;
}
// Verificar si conditional UI está disponible
if (PublicKeyCredential.isConditionalMediationAvailable) {
return await PublicKeyCredential.isConditionalMediationAvailable();
}
return true;
}
Soporte actual (2024):
- ✅ Chrome 108+
- ✅ Safari 16+
- ✅ Firefox 122+
- ✅ Edge 108+
Passkeys sincronizados
Las passkeys se sincronizan automáticamente:
- Apple: iCloud Keychain
- Google: Google Password Manager
- Microsoft: Microsoft Authenticator
Usuario registra passkey en iPhone → disponible en Mac, iPad automáticamente.
Fallback a password
Durante transición, ofrecer ambos:
function LoginPage() {
const [usePasskey, setUsePasskey] = useState(true);
if (usePasskey) {
return (
<div>
<PasskeyLogin />
<button onClick={() => setUsePasskey(false)}>
Use password instead
</button>
</div>
);
}
return <PasswordLogin />;
}
Resultados en producción
Después de 4 meses con passkeys:
- Adopción: 35% de usuarios activos
- Login time: 8s (password) → 2s (passkey)
- Phishing exitoso: 5/mes → 0/mes
- Password resets: -60%
Recomendaciones
- Ofrece passkeys, no fuerces (aún)
- Mantén password como fallback
- Educa a usuarios sobre beneficios
- Monitorea adopción gradualmente
- Prepárate para el futuro passwordless
¿Has implementado passkeys? ¿Qué tasa de adopción ves?
Por Vicente José Moreno Escobar el
12 de
julio
de
2024
Puedes disfrutar de otros artículos como éste en el archivo del sitio.