Este tutorial explica como implementar passkeys na sua aplicação web. Usamos Node.js (TypeScript), SimpleWebAuthn, HTML / JavaScript Vanilla e MySQL.
Vincent
Created: June 20, 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.
Neste tutorial, ajudamos você em seus esforços de implementação de passkeys, oferecendo um guia passo a passo sobre como adicionar passkeys ao seu site.
Ter uma autenticação moderna, robusta e amigável ao usuário é fundamental quando você quer construir um ótimo site ou aplicativo. As passkeys surgiram como a resposta para este desafio. Servindo como o novo padrão para logins, elas prometem um futuro sem as desvantagens das senhas tradicionais, proporcionando uma experiência de login genuinamente sem senha (que não é apenas segura, mas também altamente conveniente).
Recent Articles
📖
Passkeys em Aplicações Nativas: Implementação Nativa vs. WebView
👤
Resolução de Problemas com Passkeys: Soluções para Problemas e Erros
👤
Como Ativar Passkeys no Windows
⚙️
Tutorial de Passkeys: Como Implementar Passkeys em Aplicações Web
⚙️
Teste E2E de Passkeys com Playwright via Autenticador Virtual WebAuthn
O que realmente expressa o potencial das passkeys é o endosso que elas receberam. Todos os navegadores significativos, seja Chrome, Firefox, Safari ou Edge, e todos os fabricantes de dispositivos importantes (Apple, Microsoft, Google) incorporaram suporte. Essa adesão unânime mostra que as passkeys são o novo padrão para logins.
Sim, já existem tutoriais sobre a integração de passkeys em aplicações web. Seja para frameworks de frontend como React, Vue.js ou Next.js, há uma infinidade de guias projetados para mitigar desafios e acelerar suas implementações de passkeys. No entanto, falta um tutorial de ponta a ponta que permaneça minimalista e básico. Muitos desenvolvedores nos procuraram e pediram um tutorial que trouxesse clareza sobre a implementação de passkeys para aplicações web.
É precisamente por isso que criamos este guia. Nosso objetivo? Criar uma configuração mínima viável para passkeys, abrangendo a camada de frontend, backend e banco de dados (esta última muitas vezes negligenciada, embora possa causar sérias dores de cabeça).
Ao final desta jornada, você terá construído uma aplicação web mínima viável, onde poderá:
Para aqueles com pressa ou que desejam uma referência, todo o código está disponível no GitHub.
Curioso para saber como fica o resultado final? Aqui está uma prévia do projeto final (admitimos que parece muito básico, mas o interessante está sob a superfície):
Estamos plenamente cientes de que partes do código e do projeto podem ser feitas de maneira diferente ou mais sofisticada, mas queríamos focar no essencial. É por isso que mantivemos as coisas intencionalmente simples e centradas nas passkeys.
Como adicionar passkeys ao meu site de produção?
Este é um exemplo muito mínimo de autenticação com passkey. As seguintes coisas NÃO são consideradas / implementadas neste tutorial ou apenas de forma muito básica:
Obter suporte completo para todos esses recursos requer um esforço de desenvolvimento tremendamente maior. Para os interessados, recomendamos uma olhada neste artigo sobre equívocos de desenvolvedores de passkeys.
Antes de mergulhar fundo na implementação de passkeys, vamos dar uma olhada nas habilidades e ferramentas necessárias. Aqui está o que você precisa para começar:
Um sólido conhecimento dos blocos de construção da web — HTML, CSS e JavaScript — é essencial. Mantivemos as coisas intencionalmente diretas, abstendo-nos de qualquer framework JavaScript moderno e confiando em JavaScript / HTML Vanilla. A única coisa mais sofisticada que usamos é a biblioteca wrapper WebAuthn @simplewebauthn/browser.
Para nosso backend, usamos um servidor Node.js (Express) escrito
em TypeScript. Também decidimos trabalhar com a implementação do servidor WebAuthn do
SimpleWebAuthn (@simplewebauthn/server
juntamente com
@simplewebauthn/typescript-types
). Existem inúmeras implementações de servidor WebAuthn
disponíveis, então você pode, é claro, usar qualquer uma delas. Como decidimos pelo
servidor WebAuthn em TypeScript, é necessário conhecimento básico de
Node.js e npm.
Todos os dados do usuário e as chaves públicas das passkeys são armazenados em um banco de dados. Selecionamos o MySQL como tecnologia de banco de dados. Um entendimento fundamental de MySQL e bancos de dados relacionais é benéfico, embora o guiaremos pelos passos individuais.
A seguir, usamos frequentemente os termos WebAuthn e passkeys de forma intercambiável, embora oficialmente possam não significar a mesma coisa. Para melhor compreensão, especialmente na parte do código, fazemos essa suposição.
Com esses pré-requisitos em vigor, você está pronto para mergulhar no mundo das 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.
Mais de 10.000 desenvolvedores confiam na Corbado e tornam a Internet mais segura com passkeys. Tem perguntas? Escrevemos mais de 150 posts de blog sobre passkeys.
Junte-se à Comunidade PasskeysAntes de entrar no código e nas configurações, vamos dar uma olhada na arquitetura do sistema que queremos construir. Aqui está um detalhamento da arquitetura que estaremos configurando:
Com esta visão geral da arquitetura, você deve ter um mapa conceitual de como os componentes de nossa aplicação funcionam. À medida que avançamos, mergulharemos mais fundo em cada um desses componentes, detalhando sua configuração, ajustes e interação.
O gráfico a seguir descreve o fluxo do processo durante o registro (cadastro):
O gráfico a seguir descreve o fluxo do processo durante a autenticação (login):
Além disso, você encontra a estrutura do projeto aqui (apenas os arquivos mais importantes):
passkeys-tutorial ├── src # Contém todo o código-fonte TypeScript do backend │ ├── controllers # Lógica de negócios para lidar com tipos específicos de solicitações │ │ ├── authentication.ts # Lógica de autenticação de passkey │ │ └── registration.ts # Lógica de registro de passkey │ ├── middleware │ │ ├── customError.ts # Adiciona mensagens de erro personalizadas de maneira padronizada │ │ └── errorHandler.ts # Manipulador de erros geral │ ├── public │ │ ├── index.html # Arquivo HTML principal no frontend │ │ ├── css │ │ │ └── style.css # Estilização básica │ │ └── js │ │ └── script.js # Lógica JavaScript (incl. API WebAuthn) │ ├── routes # Definições de rotas de API e seus manipuladores │ │ └── routes.ts # Rotas específicas de passkey │ ├── services │ │ ├── credentialService.ts# Interage com a tabela de credenciais │ │ └── userService.ts # Interage com a tabela de usuários │ ├── utils # Funções auxiliares e utilitários │ | ├── constants.ts # Algumas constantes (ex. rpID) │ | └── utils.ts # Função auxiliar │ ├── database.ts # Cria a conexão do Node.js para o banco de dados MySQL │ ├── index.ts # Ponto de entrada do servidor Node.js │ └── server.ts # Gerencia todas as configurações do servidor ├── config.json # Algumas configurações para o projeto Node.js ├── docker-compose.yml # Define serviços, redes e volumes para contêineres Docker ├── Dockerfile # Cria uma imagem Docker do projeto ├── init-db.sql # Define nosso esquema de banco de dados MySQL ├── package.json # Gerencia dependências e scripts do projeto Node.js └── tsconfig.json # Configura como o TypeScript compila seu código
Ao implementar passkeys, a configuração do banco de dados é um componente chave. Nossa abordagem usa um contêiner Docker executando MySQL, oferecendo um ambiente direto e isolado, essencial para testes e implantação confiáveis.
Nosso esquema de banco de dados é intencionalmente minimalista, apresentando apenas duas tabelas. Essa simplicidade ajuda a uma compreensão mais clara e manutenção mais fácil.
Estrutura Detalhada da Tabela
1. Tabela de Credenciais: Central para a autenticação com passkey, esta tabela armazena as credenciais da passkey. Colunas Críticas:
credential_id
, o tipo de dados e a formatação apropriados são cruciais.2. Tabela de Usuários: Vincula as contas de usuário às suas credenciais correspondentes.
Note que nomeamos a primeira tabela como credentials
, pois isso está de acordo com nossa
experiência e o que outras bibliotecas recomendam como mais adequado (ao contrário da
sugestão do SimpleWebAuthn de nomeá-la authenticator
ou authenticator_device
).
Os tipos de dados para credential_id
e public_key
são cruciais. Erros frequentemente
surgem de tipos de dados, codificação ou formatação incorretos (especialmente a diferença
entre Base64 e Base64URL é uma causa comum de erros), o que pode interromper todo o
processo de registro (cadastro) ou autenticação (login).
Todos os comandos SQL necessários para configurar essas tabelas estão contidos no arquivo
init-db.sql
. Este script garante uma inicialização rápida e sem erros do banco de dados.
Para casos mais sofisticados, você pode adicionar credential_device_type
ou
credential_backed_up
para armazenar mais informações sobre as credenciais e melhorar a
experiência do usuário. No entanto, nos abstemos disso neste tutorial.
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) );
Depois de criar este arquivo, criamos um novo arquivo docker-compose.yml
no nível raiz
do projeto:
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 arquivo inicia o banco de dados MySQL na porta 3306 e cria a estrutura de banco de dados definida. É importante notar que o nome e a senha do banco de dados usados aqui são mantidos simples para fins de demonstração. Em um ambiente de produção, você deve usar credenciais mais complexas para maior segurança.
Em seguida, passamos a executar nosso contêiner Docker. Neste ponto, nosso arquivo
docker-compose.yml
inclui apenas este único contêiner, mas adicionaremos mais
componentes posteriormente. Para iniciar o contêiner, use o seguinte comando:
docker compose up -d
Uma vez que o contêiner esteja em execução, precisamos verificar se o banco de dados está funcionando como esperado. Abra um terminal e execute o seguinte comando para interagir com o banco de dados MySQL:
docker exec -it <ID do contêiner> mysql -uroot -p
Você será solicitado a inserir a senha root, que é my-secret-pw
em nosso exemplo. Após o
login, selecione o banco de dados webauthn_db
e exiba as tabelas usando estes comandos:
use webauthn_db; show tables;
Nesta fase, você deve ver as duas tabelas definidas em nosso script. Inicialmente, essas tabelas estarão vazias, indicando que nossa configuração de banco de dados está completa e pronta para os próximos passos na implementação de passkeys.
O backend é o núcleo de qualquer aplicação de passkey, atuando como o hub central para processar solicitações de autenticação de usuário do frontend. Ele se comunica com a biblioteca do servidor WebAuthn para lidar com solicitações de registro (cadastro) e autenticação (login), e interage com seu banco de dados MySQL para armazenar e recuperar credenciais de usuário. Abaixo, guiaremos você na configuração do seu backend usando Node.js (Express) com TypeScript, que exporá uma API pública para lidar com todas as solicitações.
Primeiro, crie um novo diretório para o seu projeto e navegue até ele usando seu terminal ou prompt de comando.
Execute o comando
npx create-express-typescript-application passkeys-tutorial
Isso cria um esqueleto de código básico de um aplicativo Node.js (Express) escrito em TypeScript que podemos usar para adaptações futuras.
Seu projeto requer vários pacotes chave que precisamos instalar adicionalmente:
Mude para o novo diretório e instale-os com os seguintes comandos (também instalamos os tipos TypeScript necessários):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Para confirmar que tudo está instalado corretamente, execute
npm run dev:nodemon
Isso deve iniciar seu servidor Node.js em modo de desenvolvimento com o Nodemon, que reinicia automaticamente o servidor após qualquer alteração de arquivo.
Dica de solução de problemas: Se você encontrar erros, tente atualizar o ts-node
para a versão 10.8.1 no arquivo package.json
e depois execute npm i
para instalar as
atualizações.
Seu arquivo server.ts
tem a configuração básica e o middleware para uma aplicação
Express. Para integrar a funcionalidade de passkey, você
precisará adicionar:
Essas melhorias são fundamentais para habilitar a autenticação com passkey no backend da sua aplicação. Nós as configuraremos mais tarde.
Depois de criarmos e iniciarmos o banco de dados na
seção 4, agora precisamos garantir que nosso
backend possa se conectar ao banco de dados MySQL. Para isso, criamos um novo arquivo
database.ts
na pasta /src
e adicionamos o seguinte conteúdo:
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 arquivo será usado posteriormente pelo nosso servidor para acessar o banco de dados.
Vamos dar uma breve olhada no nosso config.json
, onde duas variáveis já estão definidas:
a porta em que executamos a aplicação e o ambiente:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
O package.json
pode permanecer como está e deve se parecer com:
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 parece com:
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); });
Em server.ts
, precisamos adaptar mais algumas coisas. Além disso, um cache temporário de
algum tipo (por exemplo, redis, memcache ou express-session) é
necessário para armazenar desafios temporários contra os quais os usuários podem se
autenticar. Decidimos usar express-session
e declarar o módulo express-session
no topo
para que as coisas funcionem com express-session
. Adicionalmente, simplificamos o
roteamento e removemos o tratamento de erros por enquanto (isso será adicionado ao
middleware posteriormente):
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 gerenciar efetivamente os dados em nossas duas tabelas criadas, desenvolveremos dois
serviços distintos em um novo diretório src/services
: authenticatorService.ts
e
userService.ts
.
Cada serviço encapsulará métodos CRUD (Criar, Ler, Atualizar, Excluir), permitindo-nos interagir com o banco de dados de forma modular e organizada. Esses serviços facilitarão o armazenamento, a recuperação e a atualização de dados nas tabelas de autenticador e usuário. Veja como a estrutura desses arquivos necessários deve ser organizada:
userService.ts
se parece com isto:
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 parece com o seguinte:
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 lidar com erros centralmente e também facilitar a depuração, adicionamos um arquivo
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 }); };
Além disso, adicionamos um novo arquivo customError.ts
, pois mais tarde queremos ser
capazes de criar erros personalizados para nos ajudar a encontrar bugs mais rapidamente:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
Na pasta utils
, criamos dois arquivos constants.ts
e utils.ts
.
constant.ts
contém algumas informações básicas do servidor WebAuthn, como o nome da
relying party, o ID da relying party
e a origem:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
contém duas funções que precisaremos mais tarde para codificar e decodificar
dados:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Agora, chegamos ao coração do nosso backend: os controladores. Criamos dois controladores,
um para criar uma nova passkey (registration.ts
) e outro para fazer login com uma
passkey (authentication.ts
).
registration.ts
se parece com isto:
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; } };
Vamos revisar as funcionalidades de nossos controladores, que lidam com os dois endpoints chave no processo de registro (cadastro) do WebAuthn. É aqui também que reside uma das maiores diferenças em relação à autenticação baseada em senha: para cada tentativa de registro (cadastro) ou autenticação (login), são necessárias duas chamadas de API de backend, que exigem conteúdo de frontend específico entre elas. As senhas geralmente precisam de apenas um endpoint.
1. Endpoint handleRegisterStart:
Este endpoint é acionado pelo frontend, recebendo um nome de usuário para criar uma nova passkey e conta. Neste exemplo, permitimos apenas a criação de uma nova conta/passkey se ainda não existir uma conta. Em aplicações do mundo real, você precisaria lidar com isso de forma que os usuários sejam informados de que uma passkey já existe e que adicionar do mesmo dispositivo não é possível (mas o usuário poderia adicionar passkeys de um dispositivo diferente após alguma forma de confirmação). Por simplicidade, ignoramos isso neste tutorial.
As PublicKeyCredentialCreationOptions
são preparadas. residentKey
é definido como
preferred
, e attestationType
como direct
, coletando mais dados do autenticador para
possível armazenamento no banco de dados.
Em geral, as PublicKeyCredentialCreationOptions
consistem nos seguintes dados:
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
) e o domínio (rp.id
).user.name
, user.id
e
user.displayName
.O ID do usuário e o desafio são armazenados em um objeto de sessão, simplificando o processo para fins de tutorial. Além disso, a sessão é limpa após cada tentativa de registro (cadastro) ou autenticação (login).
2. Endpoint handleRegisterFinish:
Este endpoint recupera o ID do usuário e o desafio definidos anteriormente. Ele verifica a
RegistrationResponse
com o desafio. Se for válido, ele armazena uma nova credencial para
o usuário. Uma vez armazenado no banco de dados, o ID do usuário e o desafio são removidos
da sessão.
Dica: Ao depurar sua aplicação, recomendamos fortemente o uso do Chrome como navegador e seus recursos integrados para melhorar a experiência do desenvolvedor de aplicações baseadas em passkey, por exemplo, autenticador WebAuthn virtual e log de dispositivo (veja nossas dicas para desenvolvedores abaixo para mais informações)
Em seguida, passamos para authentication.ts
, que tem uma estrutura e funcionalidade
semelhantes.
authentication.ts
se parece com isto:
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; } };
Nosso processo de autenticação (login) envolve dois endpoints:
1. Endpoint handleLoginStart:
Este endpoint é ativado quando um usuário tenta fazer login. Ele primeiro verifica se o nome de usuário existe no banco de dados, retornando um erro se não for encontrado. Em um cenário do mundo real, você poderia oferecer a criação de uma nova conta.
Para usuários existentes, ele recupera o ID do usuário do banco de dados, armazena-o na
sessão e gera opções de PublicKeyCredentialRequestOptions
. allowCredentials
é deixado
vazio para não restringir o uso de credenciais. É por isso que todas as passkeys
disponíveis para esta relying party podem ser selecionadas no modal de passkey.
O desafio gerado também é armazenado na sessão e as PublicKeyCredentialRequestOptions
são enviadas de volta para o frontend.
As PublicKeyCredentialRequestOptions
consistem nos seguintes dados:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Endpoint handleLoginFinish:
Este endpoint recupera o currentChallenge
e loggedInUserId
da sessão.
Ele consulta o banco de dados pela credencial correta usando o ID da credencial do corpo
da requisição. Se a credencial for encontrada, isso significa que o usuário associado a
este ID de credencial pode agora ser autenticado (logado). Então, podemos consultar o
usuário na tabela de usuários através do ID do usuário que obtemos da credencial e
verificar a authenticationResponse
usando o desafio e o corpo da requisição. Se tudo for
bem-sucedido, mostramos a mensagem de sucesso de login. Se nenhuma credencial
correspondente for encontrada, um erro é enviado.
Além disso, se a verificação for bem-sucedida, o contador da credencial é atualizado, o desafio usado e o loggedInUserId são removidos da sessão.
Além disso, podemos excluir a pasta src/app
e src/constant
juntamente com todos os
arquivos nela contidos.
Nota: O gerenciamento adequado de sessões e a proteção de rotas, cruciais em aplicações da vida real, são omitidos aqui por simplicidade neste tutorial.
Por último, mas não menos importante, precisamos garantir que nossos controladores sejam
alcançáveis, adicionando as rotas apropriadas a routes.ts
, que está em um novo diretório
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 do tutorial de passkeys foca em como suportar passkeys no frontend da sua
aplicação. Temos um frontend muito básico consistindo em três arquivos: index.html
,
styles.css
e script.js
. Todos os três arquivos estão em uma nova pasta src/public
.
O arquivo index.html
contém um campo de entrada para o nome de usuário e dois botões
para registrar e fazer login. Além disso, importamos o script @simplewebauthn/browser
que simplifica a interação com a API de Autenticação Web do navegador no arquivo
js/script.js
.
index.html
se parece com isto:
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 parece com o seguinte:
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); } }
Em script.js
, existem três funções principais:
1. Função showMessage:
Esta é uma função utilitária usada principalmente para exibir mensagens de erro, auxiliando na depuração.
2. Função Register:
Acionada quando o usuário clica em "Register". Ela extrai o nome de usuário do campo de
entrada e o envia para o endpoint passkeyRegisterStart
. A resposta inclui
PublicKeyCredentialCreationOptions
, que são convertidas para JSON e passadas para
SimpleWebAuthnBrowser.startRegistration
. Esta chamada ativa o autenticador do
dispositivo (como Face ID ou Touch ID). Após a autenticação local bem-sucedida, o desafio
assinado é enviado de volta para o endpoint passkeyRegisterFinish
, completando o
processo de criação da passkey.
Durante o processo de registro (cadastro), o objeto de atestado desempenha um papel crucial, então vamos dar uma olhada mais de perto nele.
O objeto de atestado consiste principalmente em três componentes: fmt
, attStmt
e
authData
. O elemento fmt
significa o formato da declaração de atestado, enquanto
attStmt
representa a própria declaração de atestado. Em cenários onde o atestado é
considerado desnecessário, o fmt
será designado como "none", levando a um attStmt
vazio.
O foco está no segmento authData
dentro desta estrutura. Este segmento é fundamental
para recuperar elementos essenciais como o ID da relying party, flags, contador e dados de
credencial atestada em nosso servidor. Em relação às flags, de particular interesse são BS
(Backup State) e BE (Backup Eligibility), que fornecem mais informações se uma passkey é
sincronizada (por exemplo, via iCloud Keychain ou
1Password). Além disso, UV (User
Verification) e UP (User Presence) fornecem informações mais úteis.
É importante notar que várias partes do objeto de atestado, incluindo os dados do autenticador, o ID da relying party e a declaração de atestado, são ou hasheadas ou assinadas digitalmente pelo autenticador usando sua chave privada. Este processo é integral para manter a integridade geral do objeto de atestado.
3. Função Login:
Ativada quando o usuário clica em "Login". Semelhante à função de registro, ela extrai o
nome de usuário e o envia para o endpoint passkeyLoginStart
. A resposta, contendo
PublicKeyCredentialRequestOptions
, é convertida para JSON e usada com
SimpleWebAuthnBrowser.startAuthentication
. Isso aciona a autenticação local no
dispositivo. O desafio assinado é então enviado de volta para o endpoint
passkeyLoginFinish
. Uma resposta bem-sucedida deste endpoint indica que o usuário fez
login no aplicativo com sucesso.
Além disso, o arquivo CSS que acompanha fornece um estilo simples para a aplicação:
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 sua aplicação em ação, compile e execute seu código TypeScript com:
npm run dev
Seu servidor agora deve estar em execução em http://localhost:8080.
Considerações para Produção:
Lembre-se, o que cobrimos foi um esboço básico. Ao implantar uma aplicação de passkey em um ambiente de produção, você precisa se aprofundar em:
Já configuramos um contêiner Docker para nosso banco de dados. Em seguida, expandiremos
nossa configuração do Docker Compose para incluir o servidor com backend e frontend. Seu
arquivo docker-compose.yml
deve ser atualizado de acordo.
Para containerizar nossa aplicação, criamos um novo Dockerfile que instala os pacotes necessários e inicia o servidor de desenvolvimento:
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"]
Então, também estendemos o arquivo docker-compose.yml
para iniciar este contêiner:
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
Se você agora executar docker compose up
em seu terminal e acessar
http://localhost:8080, deverá ver a versão funcional do seu
aplicativo web de passkey (aqui rodando no Windows 11 23H2 +
Chrome 119):
Como trabalhamos há algum tempo com implementações de passkeys, encontramos alguns desafios ao trabalhar em aplicativos de passkey da vida real:
Além disso, temos as seguintes dicas para desenvolvedores quando se trata da parte de implementação:
Utilize o Depurador de Passkeys
O depurador de Passkeys ajuda a testar diferentes configurações do servidor WebAuthn e respostas do cliente. Além disso, ele fornece um ótimo analisador para respostas do autenticador.
Depure com o Recurso de Log de Dispositivo do Chrome
Use o log de dispositivo do Chrome (acessível via chrome://device-log/) para monitorar chamadas FIDO/WebAuthn. Este recurso fornece logs em tempo real do processo de autenticação (login), permitindo que você veja os dados sendo trocados e solucione quaisquer problemas que surjam.
Outro atalho muito útil para obter todas as suas passkeys no Chrome é usar chrome://settings/passkeys.
Use o Autenticador WebAuthn Virtual do Chrome
Para evitar o uso do prompt do Touch ID, Face ID ou Windows Hello durante o desenvolvimento, o Chrome vem com um autenticador WebAuthn virtual muito útil que emula um autenticador real. Recomendamos fortemente o uso para acelerar as coisas. Encontre mais detalhes aqui.
Teste em Diferentes Plataformas e Navegadores
Garanta a compatibilidade e a funcionalidade em vários navegadores e plataformas. O WebAuthn se comporta de maneira diferente em diferentes navegadores, portanto, testes completos são fundamentais.
Teste em Diferentes Dispositivos
Aqui é especialmente útil trabalhar com ferramentas como o ngrok, onde você pode tornar sua aplicação local acessível em outros dispositivos (móveis).
Defina a Verificação do Usuário como preferred
Ao definir as propriedades para userVerification
nas
PublicKeyCredentialRequestOptions
, opte por defini-las como preferred
, pois este é
um bom equilíbrio entre usabilidade e segurança. Isso significa que as verificações de
segurança estão em vigor em dispositivos adequados, enquanto a facilidade de uso é mantida
em dispositivos sem capacidades biométricas.
Esperamos que este tutorial de passkeys forneça uma compreensão clara de como implementar passkeys de forma eficaz. Ao longo do tutorial, percorremos os passos essenciais para criar uma aplicação de passkey, focando em conceitos fundamentais e implementação prática. Embora este guia sirva como ponto de partida, há muito mais a explorar e refinar no mundo do WebAuthn.
Incentivamos os desenvolvedores a se aprofundarem nas nuances das passkeys (por exemplo, adicionar múltiplas passkeys, verificar a prontidão para passkeys nos dispositivos ou oferecer soluções de recuperação). É uma jornada que vale a pena embarcar, oferecendo tanto desafios quanto imensas recompensas na melhoria da autenticação do usuário. Com as passkeys, você não está apenas construindo um recurso; você está contribuindo para um mundo digital mais seguro e amigável ao usuário.
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