Este tutorial explica cómo implementar passkeys en tu aplicación web. Usamos Node.js (TypeScript), SimpleWebAuthn, HTML / JavaScript Vanilla y MySQL.
Vincent
Created: June 17, 2025
Updated: June 20, 2025
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
En este tutorial, te ayudamos en tus esfuerzos de implementación de passkeys, ofreciendo una guía paso a paso sobre cómo añadir passkeys a tu sitio web.
Tener una autenticación moderna, robusta y fácil de usar es clave cuando quieres construir un gran sitio web o aplicación. Las passkeys han surgido como la respuesta a este desafío. Sirviendo como el nuevo estándar para los inicios de sesión, prometen un futuro sin las desventajas de las contraseñas tradicionales, proporcionando una experiencia de inicio de sesión genuinamente sin contraseña (que no solo es segura sino también muy conveniente).
Recent Articles
📖
Passkeys en aplicaciones nativas: Implementación nativa frente a WebView
👤
Resolución de problemas con passkeys: Soluciones para incidencias y errores
👤
Cómo habilitar las passkeys en Windows
⚙️
Tutorial de Passkeys: Cómo Implementar Passkeys en Aplicaciones Web
⚙️
Pruebas E2E de Passkeys con Playwright mediante el Autenticador Virtual WebAuthn
Lo que realmente expresa el potencial de las passkeys es el respaldo que han obtenido. Todos los navegadores importantes, ya sea Chrome, Firefox, Safari o Edge, y todos los fabricantes de dispositivos importantes (Apple, Microsoft, Google) han incorporado soporte. Esta adopción unánime demuestra que las passkeys son el nuevo estándar para los inicios de sesión.
Sí, ya existen tutoriales sobre la integración de passkeys en aplicaciones web. Ya sea para frameworks de frontend como React, Vue.js o Next.js, hay una plétora de guías diseñadas para mitigar los desafíos y acelerar tus implementaciones de passkeys. Sin embargo, falta un tutorial de extremo a extremo que se mantenga minimalista y básico. Muchos desarrolladores se han acercado a nosotros y nos han pedido un tutorial que aclare la implementación de passkeys para aplicaciones web.
Es precisamente por eso que hemos creado esta guía. ¿Nuestro objetivo? Crear una configuración mínima viable para passkeys, que abarque la capa de frontend, backend y base de datos (esta última a menudo descuidada, aunque puede causar serios dolores de cabeza).
Al final de este viaje, habrás construido una aplicación web mínima viable, donde podrás:
Para aquellos con prisa o que deseen una referencia, todo el código base está disponible en GitHub.
¿Curioso por ver el resultado final? Aquí tienes un adelanto del proyecto final (admitimos que parece muy básico, pero lo interesante está bajo la superficie):
Somos plenamente conscientes de que partes del código y del proyecto pueden hacerse de manera diferente o más sofisticada, pero queríamos centrarnos en lo esencial. Por eso, mantuvimos las cosas simples y centradas en las passkeys intencionadamente.
¿Cómo añadir passkeys a mi sitio web de producción?
Este es un ejemplo muy mínimo de autenticación con passkeys. Los siguientes aspectos NO se consideran / implementan en este tutorial o solo de forma muy básica:
Obtener soporte completo para todas estas características requiere un esfuerzo de desarrollo tremendamente mayor. Para los interesados, recomendamos echar un vistazo a este artículo sobre conceptos erróneos de los desarrolladores de passkeys.
Antes de sumergirnos en la implementación de passkeys, echemos un vistazo a las habilidades y herramientas necesarias. Esto es lo que necesitas para empezar:
Un sólido conocimiento de los componentes básicos de la web —HTML, CSS y JavaScript— es esencial. Hemos mantenido las cosas sencillas intencionadamente, absteniéndonos de cualquier framework de JavaScript moderno y hemos confiado en JavaScript / HTML Vanilla. Lo único más sofisticado que usamos es la biblioteca contenedora de WebAuthn @simplewebauthn/browser.
Para nuestro backend, usamos un servidor Node.js (Express)
escrito en TypeScript. También hemos decidido trabajar con la implementación de servidor
WebAuthn de SimpleWebAuthn (@simplewebauthn/server
junto con
@simplewebauthn/typescript-types
). Hay numerosas implementaciones de servidor WebAuthn
disponibles, así que por supuesto también puedes usar cualquiera de ellas. Como hemos
optado por el servidor WebAuthn de TypeScript, se requieren conocimientos básicos de
Node.js y npm.
Todos los datos de usuario y las claves públicas de las passkeys se almacenan en una base de datos. Hemos seleccionado MySQL como tecnología de base de datos. Un conocimiento fundamental de MySQL y las bases de datos relacionales es beneficioso, aunque te guiaremos a través de los pasos individuales.
A continuación, a menudo usamos los términos WebAuthn y passkeys de manera intercambiable, aunque oficialmente no signifiquen lo mismo. Sin embargo, para una mejor comprensión, especialmente en la parte del código, hacemos esta suposición.
Con estos prerrequisitos en su lugar, estás listo para sumergirte en el mundo de las passkeys.
Ben Gould
Head of Engineering
I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.
Más de 10.000 desarrolladores confían en Corbado y hacen que Internet sea más seguro con passkeys. ¿Tienes preguntas? Hemos escrito más de 150 entradas de blog sobre passkeys.
Únete a la Comunidad de PasskeysAntes de entrar en el código y las configuraciones, echemos un vistazo a la arquitectura del sistema que queremos construir. Aquí hay un desglose de la arquitectura que vamos a configurar:
Con este resumen arquitectónico, deberías tener un mapa conceptual de cómo funcionan los componentes de nuestra aplicación. A medida que avancemos, profundizaremos en cada uno de estos componentes, detallando su configuración e interacción.
El siguiente gráfico describe el flujo del proceso durante el registro (sign-up):
El siguiente gráfico describe el flujo del proceso durante la autenticación (login):
Además, aquí encuentras la estructura del proyecto (solo los archivos más importantes):
passkeys-tutorial ├── src # Contiene todo el código fuente de TypeScript del backend │ ├── controllers # Lógica de negocio para manejar tipos específicos de solicitudes │ │ ├── authentication.ts # Lógica de autenticación con passkey │ │ └── registration.ts # Lógica de registro con passkey │ ├── middleware │ │ ├── customError.ts # Añade mensajes de error personalizados de manera estandarizada │ │ └── errorHandler.ts # Manejador de errores general │ ├── public │ │ ├── index.html # Archivo HTML principal en el frontend │ │ ├── css │ │ │ └── style.css # Estilos básicos │ │ └── js │ │ └── script.js # Lógica de JavaScript (incl. API de WebAuthn) │ ├── routes # Definiciones de rutas de API y sus manejadores │ │ └── routes.ts # Rutas específicas de passkey │ ├── services │ │ ├── credentialService.ts# Interactúa con la tabla de credenciales │ │ └── userService.ts # Interactúa con la tabla de usuarios │ ├── utils # Funciones de ayuda y utilidades │ | ├── constants.ts # Algunas constantes (p. ej. rpID) │ | └── utils.ts # Función de ayuda │ ├── database.ts # Crea la conexión de Node.js a la base de datos MySQL │ ├── index.ts # Punto de entrada del servidor Node.js │ └── server.ts # Gestiona todas las configuraciones del servidor ├── config.json # Algunas configuraciones para el proyecto Node.js ├── docker-compose.yml # Define servicios, redes y volúmenes para contenedores Docker ├── Dockerfile # Crea una imagen Docker del proyecto ├── init-db.sql # Define nuestro esquema de base de datos MySQL ├── package.json # Gestiona las dependencias y scripts del proyecto Node.js └── tsconfig.json # Configura cómo TypeScript compila tu código
Al implementar passkeys, la configuración de la base de datos es un componente clave. Nuestro enfoque utiliza un contenedor Docker que ejecuta MySQL, ofreciendo un entorno sencillo y aislado esencial para pruebas y despliegues fiables.
Nuestro esquema de base de datos es intencionadamente minimalista, con solo dos tablas. Esta simplicidad ayuda a una comprensión más clara y un mantenimiento más fácil.
Estructura Detallada de las Tablas
1. Tabla Credentials: Central para la autenticación con passkeys, esta tabla almacena las credenciales de las passkeys. Columnas críticas:
credential_id
, el tipo de datos y el formato adecuados son cruciales.2. Tabla Users: Vincula las cuentas de usuario con sus credenciales correspondientes.
Ten en cuenta que nombramos la primera tabla credentials
ya que esto se ajusta a nuestra
experiencia y a lo que otras bibliotecas recomiendan como más adecuado (contrariamente a
la sugerencia de SimpleWebAuthn de llamarla authenticator
o authenticator_device
).
Los tipos de datos para credential_id
y public_key
son cruciales. Los errores a menudo
surgen de tipos de datos, codificación o formato incorrectos (especialmente la diferencia
entre Base64 y Base64URL es una causa común de errores), lo que puede interrumpir todo el
proceso de registro (sign-up) o autenticación (login).
Todos los comandos SQL necesarios para configurar estas tablas están contenidos en el
archivo init-db.sql
. Este script asegura una inicialización de la base de datos rápida y
sin errores.
Para casos más sofisticados, puedes añadir credential_device_type
o
credential_backed_up
para almacenar más información sobre las credenciales y mejorar la
experiencia del usuario. Sin embargo, en este tutorial nos abstenemos de hacerlo.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
Después de haber creado este archivo, creamos un nuevo archivo docker-compose.yml
en el
nivel raíz del proyecto:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
Este archivo inicia la base de datos MySQL en el puerto 3306 y crea la estructura de base de datos definida. Es importante tener en cuenta que el nombre y la contraseña de la base de datos utilizados aquí se mantienen simples para fines de demostración. En un entorno de producción, debes usar credenciales más complejas para una mayor seguridad.
A continuación, pasamos a ejecutar nuestro contenedor Docker. En este punto, nuestro
archivo docker-compose.yml
solo incluye este único contenedor, pero añadiremos más
componentes más adelante. Para iniciar el contenedor, usa el siguiente comando:
docker compose up -d
Una vez que el contenedor esté en funcionamiento, necesitamos verificar si la base de datos funciona como se espera. Abre una terminal y ejecuta el siguiente comando para interactuar con la base de datos MySQL:
docker exec -it <container ID> mysql -uroot -p
Se te pedirá que introduzcas la contraseña de root, que es my-secret-pw
en nuestro
ejemplo. Después de iniciar sesión, selecciona la base de datos webauthn_db
y muestra
las tablas usando estos comandos:
use webauthn_db; show tables;
En esta etapa, deberías ver las dos tablas definidas en nuestro script. Inicialmente, estas tablas estarán vacías, lo que indica que nuestra configuración de la base de datos está completa y lista para los siguientes pasos en la implementación de passkeys.
El backend es el núcleo de cualquier aplicación de passkeys, actuando como el centro neurálgico para procesar las solicitudes de autenticación de usuario desde el frontend. Se comunica con la biblioteca del servidor WebAuthn para manejar las solicitudes de registro (sign-up) y autenticación (login), e interactúa con tu base de datos MySQL para almacenar y recuperar las credenciales de los usuarios. A continuación, te guiaremos a través de la configuración de tu backend usando Node.js (Express) con TypeScript, que expondrá una API pública para manejar todas las solicitudes.
Primero, crea un nuevo directorio para tu proyecto y navega hacia él usando tu terminal o línea de comandos.
Ejecuta el comando
npx create-express-typescript-application passkeys-tutorial
Esto crea un esqueleto de código básico de una aplicación Node.js (Express) escrita en TypeScript que podemos usar para adaptaciones posteriores.
Tu proyecto requiere varios paquetes clave que necesitamos instalar adicionalmente:
Cambia al nuevo directorio e instálalos con los siguientes comandos (también instalamos los tipos de TypeScript requeridos):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Para confirmar que todo está instalado correctamente, ejecuta
npm run dev:nodemon
Esto debería iniciar tu servidor Node.js en modo de desarrollo con Nodemon, que reinicia automáticamente el servidor ante cualquier cambio en los archivos.
Consejo para la solución de problemas: Si encuentras errores, intenta actualizar
ts-node
a la versión 10.8.1 en el archivo package.json
y luego ejecuta npm i
para
instalar las actualizaciones.
Tu archivo server.ts
tiene la configuración básica y el middleware para una aplicación
Express. Para integrar la funcionalidad de passkey, necesitarás
añadir:
Estas mejoras son clave para habilitar la autenticación con passkeys en el backend de tu aplicación. Las configuraremos más adelante.
Después de crear e iniciar la base de datos en la sección 4, ahora debemos asegurarnos de
que nuestro backend pueda conectarse a la base de datos MySQL. Por lo tanto, creamos un
nuevo archivo database.ts
en la carpeta /src
y añadimos el siguiente contenido:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
Este archivo será utilizado más tarde por nuestro servidor para acceder a la base de datos.
Echemos un vistazo breve a nuestro config.json
, donde ya están definidas dos variables:
el puerto en el que ejecutamos la aplicación y el entorno:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
puede quedarse como está y debería verse así:
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
se ve así:
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
En server.ts
, necesitamos adaptar algunas cosas más. Además, se necesita una caché
temporal de algún tipo (p. ej. redis, memcache o express-session)
para almacenar los desafíos temporales contra los que los usuarios pueden autenticarse.
Decidimos usar express-session
y declarar el módulo express-session
en la parte
superior para que funcione con express-session
. Adicionalmente, simplificamos el
enrutamiento y eliminamos el manejo de errores por ahora (esto se añadirá al middleware
más tarde):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
Para gestionar eficazmente los datos en nuestras dos tablas creadas, desarrollaremos dos
servicios distintos en un nuevo directorio src/services
: authenticatorService.ts
y
userService.ts
.
Cada servicio encapsulará los métodos CRUD (Crear, Leer, Actualizar, Eliminar),
permitiéndonos interactuar con la base de datos de una manera modular y organizada. Estos
servicios facilitarán el almacenamiento, la recuperación y la actualización de datos en
las tablas de authenticator
y user
. Así es como debe ser la estructura de estos
archivos requeridos:
userService.ts
se ve así:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
se ve de la siguiente manera:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
Para manejar los errores de forma centralizada y también para facilitar la depuración,
añadimos un archivo errorHandler.ts
:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
Además, añadimos un nuevo archivo customError.ts
ya que más adelante querremos ser
capaces de crear errores personalizados para ayudarnos a encontrar bugs más rápidamente:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
En la carpeta utils
, creamos dos archivos constants.ts
y utils.ts
.
constant.ts
contiene información básica del servidor WebAuthn, como el nombre de la
relying party, el ID de la
relying party y el origen:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
contiene dos funciones que necesitaremos más tarde para codificar y decodificar
datos:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Ahora, llegamos al corazón de nuestro backend: los controladores. Creamos dos
controladores, uno para crear una nueva passkey (registration.ts
) y otro para iniciar
sesión con una passkey (authentication.ts
).
registration.ts
se ve así:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
Revisemos las funcionalidades de nuestros controladores, que manejan los dos puntos finales clave en el proceso de registro (sign-up) de WebAuthn. Aquí también reside una de las mayores diferencias con la autenticación basada en contraseñas: por cada intento de registro (sign-up) o autenticación (login), se requieren dos llamadas a la API del backend, que necesitan contenido específico del frontend entre ellas. Las contraseñas generalmente solo necesitan un punto final.
1. Punto final handleRegisterStart:
Este punto final es activado por el frontend, recibiendo un nombre de usuario para crear una nueva passkey y cuenta. En este ejemplo, solo permitimos la creación de una nueva cuenta / passkey si aún no existe una cuenta. En aplicaciones del mundo real, necesitarías manejar esto de manera que se informe a los usuarios que ya existe una passkey y que no es posible añadirla desde el mismo dispositivo (pero el usuario podría añadir passkeys desde un dispositivo diferente después de alguna forma de confirmación). Por simplicidad, omitimos esto en este tutorial.
Se preparan las PublicKeyCredentialCreationOptions
. residentKey
se establece en
preferred
, y attestationType
en direct
, recopilando más datos del authenticator
para un posible almacenamiento en la base de datos.
En general, las PublicKeyCredentialCreationOptions
consisten en los siguientes datos:
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
) y el dominio (rp.id
).user.name
, user.id
y
user.displayName
.authenticator
varias veces.authenticator
, como si debe
soportar la verificación del usuario o cómo se deben fomentar las claves residentes.attestation
deseada, como
"none", "indirect" o "direct".El ID de usuario y el challenge
se almacenan en un objeto de sesión, simplificando el
proceso para fines del tutorial. Además, la sesión se borra después de cada intento de
registro (sign-up) o autenticación (login).
2. Punto final handleRegisterFinish:
Este punto final recupera el ID de usuario y el challenge
establecidos anteriormente.
Verifica la RegistrationResponse
con el challenge
. Si es válido, almacena una nueva
credencial para el usuario. Una vez almacenado en la base de datos, el ID de usuario y el
challenge
se eliminan de la sesión.
Consejo: Al depurar tu aplicación, recomendamos encarecidamente usar Chrome como navegador y sus funciones integradas para mejorar la experiencia del desarrollador de aplicaciones basadas en passkeys, p. ej. el autenticador WebAuthn virtual y el registro de dispositivos (consulta nuestros consejos para desarrolladores a continuación para obtener más información).
A continuación, pasamos a authentication.ts
, que tiene una estructura y funcionalidad
similares.
authentication.ts
se ve así:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
Nuestro proceso de autenticación (login) involucra dos puntos finales:
1. Punto final handleLoginStart:
Este punto final se activa cuando un usuario intenta iniciar sesión. Primero verifica si el nombre de usuario existe en la base de datos, devolviendo un error si no se encuentra. En un escenario del mundo real, podrías ofrecer crear una nueva cuenta en su lugar.
Para los usuarios existentes, recupera el ID de usuario de la base de datos, lo almacena
en la sesión y genera opciones PublicKeyCredentialRequestOptions
. allowCredentials
se
deja vacío para no restringir el uso de credenciales. Es por eso que todas las passkeys
disponibles para esta relying party
se pueden seleccionar en el modal de passkey.
El challenge
generado también se almacena en la sesión y las
PublicKeyCredentialRequestOptions
se envían de vuelta al frontend.
Las PublicKeyCredentialRequestOptions
consisten en los siguientes datos:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Punto final handleLoginFinish:
Este punto final recupera el currentChallenge
y loggedInUserId
de la sesión.
Consulta la base de datos para la credencial correcta usando el ID de credencial del
cuerpo de la solicitud. Si se encuentra la credencial, esto significa que el usuario
asociado con este ID de credencial ahora puede ser autenticado (iniciar sesión). Luego,
podemos consultar al usuario de la tabla de usuarios a través del ID de usuario que
obtenemos de la credencial y verificar la authenticationResponse
usando el challenge
y
el cuerpo de la solicitud. Si todo es exitoso, mostramos el mensaje de éxito de inicio de
sesión. Si no se encuentra una credencial coincidente, se envía un error.
Además, si la verificación tiene éxito, se actualiza el contador de la credencial, y el
challenge
usado y loggedInUserId
se eliminan de la sesión.
Además de eso, podemos eliminar la carpeta src/app
y src/constant
junto con todos los
archivos que contienen.
Nota: La gestión adecuada de sesiones y la protección de rutas, cruciales en aplicaciones de la vida real, se omiten aquí por simplicidad en este tutorial.
Por último, pero no menos importante, debemos asegurarnos de que nuestros controladores
sean accesibles añadiendo las rutas apropiadas a routes.ts
, que se encuentra en un nuevo
directorio src/routes
:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Esta parte del tutorial de passkeys se centra en cómo dar soporte a las passkeys en el
frontend de tu aplicación. Tenemos un frontend muy básico que consta de tres archivos:
index.html
, styles.css
y script.js
. Los tres archivos están en una nueva carpeta
src/public
.
El archivo index.html
contiene un campo de entrada para el nombre de usuario y dos
botones para registrarse e iniciar sesión. Además, importamos el script
@simplewebauthn/browser
que simplifica la interacción con la API de Autenticación Web
del navegador en el archivo js/script.js
.
index.html
se ve así:
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
se ve de la siguiente manera:
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
En script.js
, hay tres funciones principales:
1. Función showMessage:
Esta es una función de utilidad utilizada principalmente para mostrar mensajes de error, ayudando en la depuración.
2. Función Register:
Se activa cuando el usuario hace clic en "Register". Extrae el nombre de usuario del campo
de entrada y lo envía al punto final passkeyRegisterStart
. La respuesta incluye
PublicKeyCredentialCreationOptions
, que se convierten a JSON y se pasan a
SimpleWebAuthnBrowser.startRegistration
. Esta llamada activa el authenticator
del
dispositivo (como Face ID o Touch ID). Tras una autenticación local exitosa, el
challenge
firmado se envía de vuelta al punto final passkeyRegisterFinish
, completando
el proceso de creación de la passkey.
Durante el proceso de registro (sign-up), el objeto attestation
juega un papel crucial,
así que echemos un vistazo más de cerca.
El objeto attestation
consta principalmente de tres componentes: fmt
, attStmt
y
authData
. El elemento fmt
significa el formato de la declaración de attestation
,
mientras que attStmt
representa la declaración de atestación real en sí. En escenarios
donde la attestation
se considera innecesaria, el fmt
se designará como "none", lo que
lleva a un attStmt
vacío.
El enfoque está en el segmento authData
dentro de esta estructura. Este segmento es
clave para recuperar elementos esenciales como el ID de la relying party, flags, contador
y datos de credenciales atestiguadas en nuestro servidor. En cuanto a los flags, de
particular interés son BS (Backup State) y BE (Backup Eligibility) que proporcionan más
información si una passkey está sincronizada (p. ej. a través de
iCloud Keychain o
1Password). Además, UV (User
Verification) y UP (User Presence) proporcionan información más útil.
Es importante tener en cuenta que varias partes del objeto de atestación, incluidos los
datos del autenticador, el ID de la relying party y la declaración de attestation
, son
hasheadas o firmadas digitalmente por el authenticator
usando su clave privada. Este
proceso es integral para mantener la integridad general del objeto de atestación.
3. Función Login:
Se activa cuando el usuario hace clic en "Login". Similar a la función de registro, extrae
el nombre de usuario y lo envía al punto final passkeyLoginStart
. La respuesta, que
contiene PublicKeyCredentialRequestOptions
, se convierte a JSON y se usa con
SimpleWebAuthnBrowser.startAuthentication
. Esto desencadena la autenticación local en el
dispositivo. El challenge
firmado se envía de vuelta al punto final
passkeyLoginFinish
. Una respuesta exitosa de este punto final indica que el usuario ha
iniciado sesión en la aplicación con éxito.
Además, el archivo CSS adjunto proporciona un estilo simple para la aplicación:
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
Para ver tu aplicación en acción, compila y ejecuta tu código TypeScript con:
npm run dev
Tu servidor debería estar ahora en funcionamiento en http://localhost:8080.
Consideraciones para Producción:
Recuerda, lo que hemos cubierto es un esquema básico. Al desplegar una aplicación de passkey en un entorno de producción, necesitas profundizar en:
Ya hemos configurado un contenedor Docker para nuestra base de datos. A continuación,
ampliaremos nuestra configuración de Docker Compose para incluir el servidor con tanto el
backend como el frontend. Tu archivo docker-compose.yml
debe actualizarse en
consecuencia.
Para contenerizar nuestra aplicación, creamos un nuevo Dockerfile que instala los paquetes requeridos e inicia el servidor de desarrollo:
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
Luego, también extendemos el archivo docker-compose.yml
para iniciar este contenedor:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
Si ahora ejecutas docker compose up
en tu terminal y accedes a
http://localhost:8080, deberías ver la versión funcional de tu
aplicación web de passkey (aquí ejecutándose en Windows 11
23H2 + Chrome 119):
Dado que hemos estado trabajando durante bastante tiempo con implementaciones de passkeys, nos hemos encontrado con un par de desafíos si trabajas en aplicaciones de passkey de la vida real:
Además, tenemos los siguientes consejos para los desarrolladores en lo que respecta a la parte de la implementación:
Utiliza el Depurador de Passkeys
El depurador de Passkeys ayuda a probar diferentes configuraciones del servidor WebAuthn y respuestas del cliente. Además, proporciona un gran analizador para las respuestas del autenticador.
Depura con la Función de Registro de Dispositivos de Chrome
Usa el registro de dispositivos de Chrome (accesible a través de chrome://device-log/) para monitorear las llamadas FIDO/WebAuthn. Esta función proporciona registros en tiempo real del proceso de autenticación (login), permitiéndote ver los datos que se intercambian y solucionar cualquier problema que surja.
Otro atajo muy útil para obtener todas tus passkeys en Chrome es usar chrome://settings/passkeys.
Usa el Autenticador WebAuthn Virtual de Chrome
Para evitar usar el aviso de Touch ID, Face ID o Windows Hello durante el desarrollo, Chrome viene con un autenticador WebAuthn virtual muy práctico que emula un autenticador real. Recomendamos encarecidamente usarlo para acelerar las cosas. Encuentra más detalles aquí.
Prueba en Diferentes Plataformas y Navegadores
Asegura la compatibilidad y funcionalidad en varios navegadores y plataformas. WebAuthn se comporta de manera diferente en diferentes navegadores, por lo que las pruebas exhaustivas son clave.
Prueba en Diferentes Dispositivos
Aquí es especialmente útil trabajar con herramientas como ngrok, donde puedes hacer que tu aplicación local sea accesible en otros dispositivos (móviles).
Establece la Verificación del Usuario en preferred
Al definir las propiedades para userVerification
en las
PublicKeyCredentialRequestOptions
, elige establecerlas en preferred
, ya que es un
buen equilibrio entre usabilidad y seguridad. Esto significa que hay controles de
seguridad en los dispositivos adecuados, mientras que se mantiene la facilidad de uso en
dispositivos sin capacidades biométricas.
Esperamos que este tutorial de passkeys proporcione una comprensión clara de cómo implementar passkeys de manera efectiva. A lo largo del tutorial, hemos recorrido los pasos esenciales para crear una aplicación de passkey, centrándonos en conceptos fundamentales e implementación práctica. Si bien esta guía sirve como punto de partida, hay mucho más por explorar y refinar en el mundo de WebAuthn.
Animamos a los desarrolladores a profundizar en los matices de las passkeys (p. ej. añadir múltiples passkeys, comprobar la preparación para passkeys en los dispositivos u ofrecer soluciones de recuperación). Es un viaje que vale la pena emprender, que ofrece tanto desafíos como inmensas recompensas en la mejora de la autenticación de usuarios. Con las passkeys, no solo estás construyendo una característica; estás contribuyendo a un mundo digital más seguro y fácil de usar.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents