Node.js: autenticação com Passkeys
Olá meus Unicórnios! 🦄✨
Senha é aquela coisa que todo mundo sabe que é ruim e continua usando. 😅 O usuário repete a mesma em oito sites, cai num phishing bem feito e pronto: a senha certa foi digitada no lugar errado. Passkey resolve isso de um jeito elegante — não existe segredo para digitar. O que existe é um par de chaves: a privada trancada dentro do dispositivo e a pública guardada no seu servidor.
Eu comecei achando que ia ser só um formulário diferente. Não é. É uma cerimônia de dois passos, de ida e volta, e a maior parte dos erros mora nos detalhes chatos: um domínio que não bate, um desafio que ninguém guardou, e uma codificação de texto que parece igual a outra e não é. Vamos por partes. 🙏
🔑 O que é uma passkey, sem enrolação
Quando o usuário cadastra uma passkey, o dispositivo dele — celular, notebook, chave física — gera um par de chaves:
- A chave privada fica presa no dispositivo, protegida pela digital, pelo rosto ou pelo PIN. Ela nunca sai de lá. Nem você, dono do servidor, consegue vê-la.
- A chave pública vai para o seu servidor. Ela não é segredo — se vazar o seu banco inteiro, ninguém entra na conta de ninguém só com isso.
Para entrar, o servidor manda um número aleatório (o desafio), o dispositivo assina esse número com a chave privada, e o servidor confere a assinatura com a chave pública. É só isso. Repare no detalhe bonito: não trafega segredo nenhum, então não há o que interceptar nem o que reaproveitar depois.
📦 A escolha que deixa o código legível
Aqui eu tomei uma decisão e quero ser honesta sobre ela: usei a
biblioteca @simplewebauthn/server em vez de verificar a assinatura
na unha. Fazer isso à mão significa decodificar CBOR, remontar uma
chave pública em formato COSE e verificar ECDSA — são centenas de linhas de
criptografia que ninguém revisa direito e que não ensinam nada sobre passkeys.
Com a biblioteca, o fluxo inteiro cabe num arquivo que se lê de cima a baixo.
npm install express @simplewebauthn/server @simplewebauthn/browser
🏠 O rpId: o domínio que é dono das chaves
Esse foi o primeiro lugar onde eu tropecei. O rpId ("Relying
Party ID") é o domínio que possui as passkeys — e ele tem
regra rígida:
rpId = so o dominio, sem esquema e sem porta -> localhost
origem = endereco completo, com esquema e porta -> http://localhost:3010
São duas configurações diferentes e as duas são conferidas. Quem escreve
rpId: 'http://localhost:3010' leva uma recusa do próprio
navegador, antes de o servidor ver qualquer coisa.
E tem uma pegadinha que custa uma tarde: WebAuthn exige HTTPS,
exceto em localhost. O localhost é tratado
como origem segura de propósito, para você conseguir desenvolver. Então:
http://localhost:3010 funciona (localhost e excecao)
https://meuapp.com.br funciona (tem HTTPS)
http://192.0.2.10:3010 NAO funciona (IP da rede, sem HTTPS)
O terceiro caso é o que pega todo mundo. Você testa no seu micro, dá tudo certo, aí abre no celular pelo IP da rede local para mostrar para alguém e simplesmente não acontece nada. Não é bug no seu código: é o navegador se recusando a fazer a cerimônia numa origem que ele não considera segura. 😤
🎲 O desafio mora no servidor — e essa é a regra que segura tudo
Se você só for ler um pedaço deste artigo, leia este. 🙏
O servidor gera um desafio aleatório e manda para o navegador. O navegador devolve a resposta assinada — e dentro dessa resposta vem, de novo, o desafio. A tentação é conferir usando o desafio que voltou. Não faça isso. Se você confia no desafio que o cliente devolveu, ele manda o desafio que quiser: qualquer um assina qualquer coisa e você aceita. Não sobra segurança nenhuma.
O desafio precisa ser guardado no servidor na ida e comparado na volta. E mais: precisa valer uma vez só. Sem isso, uma resposta capturada uma única vez serve para entrar para sempre.
No exemplo, essas duas regras são duas funções de três linhas:
// Guarda o desafio NO SERVIDOR. Quem confia no desafio que o navegador
// devolve nao tem seguranca nenhuma: o atacante manda o desafio que quiser.
function guardarDesafio(usuario, desafio) {
usuario.desafio = desafio;
}
// Devolve o desafio e o apaga na mesma passada: uso unico.
// Sem essa limpeza, uma resposta capturada uma vez vale para sempre.
function tomarDesafio(usuario) {
const desafio = usuario.desafio;
usuario.desafio = null;
return desafio;
}
Repare no tomarDesafio: ele lê e apaga na mesma passada. Não é
elegância, é a regra do uso único escrita em código. Se aquele
usuario.desafio = null sumir, o exemplo continua funcionando
perfeitamente — e fica inseguro sem ninguém perceber. É o pior tipo de bug, o
que não dá sintoma.
🔤 O base64url não é o base64 de sempre
Esse aqui me fez perder um tempo bobo. Tudo que trafega numa cerimônia WebAuthn — o desafio, o ID da credencial, a chave pública — vai em base64url, que é parecido com o base64 comum mas não é igual:
base64 comum -> usa + e / e completa o fim com =
base64url -> usa - e _ e nao tem = nenhum
Peguei quatro bytes escolhidos de propósito para gerar os três caracteres que mudam, e mandei o Node codificar dos dois jeitos:
bytes : fb ff be 00
base64 comum : +/++AA==
base64url : -_--AA
sao iguais? : false
Olha a diferença: +/++AA== contra -_--AA. Os dois
guardam os mesmíssimos quatro bytes. Se um lado do seu código grava num
formato e o outro lê esperando o outro, a chave pública volta como bytes
diferentes e a assinatura nunca confere — sem nenhuma mensagem
dizendo o motivo. E o decodificador do navegador é estrito: mandar um
base64url para o atob dá exceção na hora.
atob("-_--AA") -> DOMException
No Node a conversão é de graça, desde que você escreva o nome certo nos dois lados:
// base64url NAO e o base64 comum: troca "+" por "-", "/" por "_" e some com
// o "=" do fim. Trocar um pelo outro faz a verificacao falhar em silencio.
function paraBase64url(buffer) {
return Buffer.from(buffer).toString('base64url');
}
function deBase64url(texto) {
return Buffer.from(texto, 'base64url');
}
🗄️ Onde guardar as chaves
Para o exemplo caber num arquivo só, os usuários moram num objeto na memória. Num sistema de verdade isso vai para o banco — uma tabela com o ID da credencial, a chave pública e o contador, ligada ao usuário. Trocar o objeto por duas consultas não muda nada do que este artigo ensina, então deixei o objeto:
// Num sistema de verdade isto vai para o banco. Aqui e um objeto na memoria
// para o exemplo caber em um arquivo so.
const usuarios = {};
function pegarUsuario(nome) {
if (!usuarios[nome]) {
usuarios[nome] = { nome, passkeys: [], desafio: null };
}
return usuarios[nome];
}
📝 Cadastrando a passkey
O cadastro tem dois passos, e é sempre assim: o servidor propõe, o dispositivo responde, o servidor confere.
No primeiro, o servidor monta as opções e guarda o desafio:
// 1) Cadastro, passo 1: o servidor monta o desafio e as opcoes.
app.post('/cadastro/opcoes', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const opcoes = await generateRegistrationOptions({
rpName: RP_NOME,
rpID: RP_ID,
userName: usuario.nome,
attestationType: 'none',
// Impede cadastrar duas vezes a mesma chave no mesmo usuario.
excludeCredentials: usuario.passkeys.map((p) => ({ id: p.id })),
authenticatorSelection: { residentKey: 'discouraged', userVerification: 'preferred' },
});
guardarDesafio(usuario, opcoes.challenge);
res.json(opcoes);
} catch (erro) {
res.status(500).json({ erro: 'Nao foi possivel montar o cadastro: ' + erro.message });
}
});
Aquele excludeCredentials não é enfeite: é ele que impede o
usuário de cadastrar duas vezes a mesma chave na mesma conta, o que geraria
uma passkey duplicada e uma lista confusa na tela dele depois.
Rodando de verdade, é isto que a rota devolve:
$ curl -s -X POST localhost:3010/cadastro/opcoes -d '{"nome":"unicornio"}'
{
"challenge": "TVlLPqc3SUcxDuw3oCN1hlnpFEkFWSa9WpM1aA8K0dI",
"rp": {
"name": "Cofre de Exemplo",
"id": "localhost"
},
"user": {
"id": "zghPcfi4BETEoXV4ddWpkBOOXwQVhTgmRHGl1SQ2Mqw",
"name": "unicornio",
"displayName": ""
},
"pubKeyCredParams": [
{ "alg": -8, "type": "public-key" },
{ "alg": -7, "type": "public-key" },
{ "alg": -257, "type": "public-key" }
],
"timeout": 60000,
"attestation": "none",
"excludeCredentials": [],
"authenticatorSelection": {
"residentKey": "discouraged",
"userVerification": "preferred",
"requireResidentKey": false
}
}
O challenge ali já vem em base64url — repare que não tem
= no fim. Os alg negativos são os algoritmos que o
servidor aceita, do mais moderno para o mais compatível.
No segundo passo, o servidor confere a resposta e guarda a chave pública:
// 2) Cadastro, passo 2: confere a resposta do autenticador e guarda a chave publica.
app.post('/cadastro/verificar', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const desafio = tomarDesafio(usuario);
if (!desafio) {
return res.status(400).json({ erro: 'Desafio expirado ou ja usado. Comece de novo.' });
}
const resultado = await verifyRegistrationResponse({
response: req.body.resposta,
expectedChallenge: desafio,
expectedOrigin: ORIGEM,
expectedRPID: RP_ID,
requireUserVerification: false,
});
if (!resultado.verified) {
return res.status(400).json({ erro: 'Cadastro recusado.' });
}
const credencial = resultado.registrationInfo.credential;
usuario.passkeys.push({
id: credencial.id, // ja vem em base64url
chavePublica: paraBase64url(credencial.publicKey),
contador: credencial.counter,
});
res.json({ ok: true, total: usuario.passkeys.length });
} catch (erro) {
res.status(400).json({ erro: 'Cadastro recusado: ' + erro.message });
}
});
🚪 Entrando com a passkey
O login é a mesma dança, com os verbos trocados: novo desafio, o dispositivo assina, o servidor confere com a chave pública que já tinha.
// 3) Login, passo 1: novo desafio, e a lista de passkeys que servem.
app.post('/login/opcoes', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
if (usuario.passkeys.length === 0) {
return res.status(400).json({ erro: 'Este usuario nao tem passkey cadastrada.' });
}
const opcoes = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: usuario.passkeys.map((p) => ({ id: p.id })),
});
guardarDesafio(usuario, opcoes.challenge);
res.json(opcoes);
} catch (erro) {
res.status(500).json({ erro: 'Nao foi possivel montar o login: ' + erro.message });
}
});
E a verificação:
// 4) Login, passo 2: confere a assinatura com a chave publica guardada.
app.post('/login/verificar', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const desafio = tomarDesafio(usuario);
if (!desafio) {
return res.status(400).json({ erro: 'Desafio expirado ou ja usado. Comece de novo.' });
}
const passkey = usuario.passkeys.find((p) => p.id === req.body.resposta.id);
if (!passkey) {
return res.status(400).json({ erro: 'Passkey desconhecida.' });
}
const resultado = await verifyAuthenticationResponse({
response: req.body.resposta,
expectedChallenge: desafio,
expectedOrigin: ORIGEM,
expectedRPID: RP_ID,
requireUserVerification: false,
credential: {
id: passkey.id,
publicKey: deBase64url(passkey.chavePublica), // volta a virar bytes aqui
counter: passkey.contador,
},
});
if (!resultado.verified) {
return res.status(400).json({ erro: 'Assinatura invalida.' });
}
// Guardar o contador novo e o que denuncia uma passkey clonada.
passkey.contador = resultado.authenticationInfo.newCounter;
res.json({ ok: true, mensagem: 'Bem-vindo, ' + usuario.nome + '!' });
} catch (erro) {
res.status(400).json({ erro: 'Login recusado: ' + erro.message });
}
});
Duas linhas dessa função merecem atenção. A primeira é o
deBase64url(passkey.chavePublica): guardei texto no "banco" e a
biblioteca quer bytes, então a volta acontece exatamente ali — é o ponto onde
a armadilha da seção anterior morde de verdade.
A segunda é o contador. Todo autenticador mantém um número que só cresce a
cada uso. Se um dia chegar uma assinatura com contador menor que o
guardado, é sinal de que alguém clonou a credencial. Por isso o
passkey.contador é atualizado no fim: sem salvar o valor novo, a
detecção simplesmente não existe.
🖥️ A metade que roda no navegador
O servidor sozinho não faz nada — quem conversa com a digital do usuário é o navegador. A página fica curta porque a biblioteca cuida das conversões:
document.getElementById('botaoCadastrar').onclick = async function () {
try {
const nome = document.getElementById('campoNome').value;
const opcoes = await pedir('/cadastro/opcoes', { nome });
// Aqui o navegador abre o prompt do sistema (digital, PIN, rosto).
const resposta = await startRegistration({ optionsJSON: opcoes });
const fim = await pedir('/cadastro/verificar', { nome, resposta });
saida.textContent = 'Passkey cadastrada. Total: ' + fim.total;
} catch (erro) {
saida.textContent = 'Erro: ' + erro.message;
}
};
Aquele startRegistration é a linha em que o prompt do sistema
operacional aparece pedindo a digital. Tudo antes dela é preparação; tudo
depois é conferência.
🧪 As regras acontecendo
Subindo o servidor e batendo nas rotas, dá para ver as regras funcionando. A rota de login recusa quem ainda não cadastrou nada:
$ curl -s -X POST localhost:3010/login/opcoes -d '{"nome":"unicornio"}'
{"erro":"Este usuario nao tem passkey cadastrada."}
E o desafio de uso único fica visível quando você manda a mesma coisa duas vezes seguidas. Prepare-se para a diferença sutil entre as duas respostas:
$ 1a tentativa de verificar (o desafio existe, a resposta e falsa)
{"erro":"Cadastro recusado: Credential ID was not base64url-encoded"}
$ 2a tentativa, pedido identico (o desafio ja foi consumido)
{"erro":"Desafio expirado ou ja usado. Comece de novo."}
Isso mesmo, duas mensagens diferentes para o mesmo pedido! 🤯
Na primeira, o desafio estava lá e a requisição chegou até a biblioteca, que
olhou o ID falso que eu inventei e reclamou — repare que ela reclamou
justamente do base64url, a armadilha de duas seções atrás. Na
segunda, o pedido nem passou da porta: o desafio tinha sido apagado no uso
anterior. É a regra do uso único funcionando à vista.
📄 O arquivo inteiro
São 172 linhas, duas dependências e nenhuma classe — para ler de cima a baixo:
// Passkeys em Node.js — cadastro e login com WebAuthn.
// Rode com: node servidor.js e abra http://localhost:3010
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// O rpId e o DOMINIO (sem esquema, sem porta) dono das passkeys.
// Tem de casar com o host que o navegador esta exibindo, senao o navegador
// recusa a cerimonia antes mesmo de o servidor ver alguma coisa.
const RP_ID = 'localhost';
const RP_NOME = 'Cofre de Exemplo';
// A origem e o endereco COMPLETO, com esquema e porta. rpId e origem sao
// duas coisas diferentes e as duas sao conferidas.
const ORIGEM = 'http://localhost:3010';
// Num sistema de verdade isto vai para o banco. Aqui e um objeto na memoria
// para o exemplo caber em um arquivo so.
const usuarios = {};
function pegarUsuario(nome) {
if (!usuarios[nome]) {
usuarios[nome] = { nome, passkeys: [], desafio: null };
}
return usuarios[nome];
}
// Guarda o desafio NO SERVIDOR. Quem confia no desafio que o navegador
// devolve nao tem seguranca nenhuma: o atacante manda o desafio que quiser.
function guardarDesafio(usuario, desafio) {
usuario.desafio = desafio;
}
// Devolve o desafio e o apaga na mesma passada: uso unico.
// Sem essa limpeza, uma resposta capturada uma vez vale para sempre.
function tomarDesafio(usuario) {
const desafio = usuario.desafio;
usuario.desafio = null;
return desafio;
}
// base64url NAO e o base64 comum: troca "+" por "-", "/" por "_" e some com
// o "=" do fim. Trocar um pelo outro faz a verificacao falhar em silencio.
function paraBase64url(buffer) {
return Buffer.from(buffer).toString('base64url');
}
function deBase64url(texto) {
return Buffer.from(texto, 'base64url');
}
const app = express();
app.use(express.json());
app.use(express.static('publico'));
// 1) Cadastro, passo 1: o servidor monta o desafio e as opcoes.
app.post('/cadastro/opcoes', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const opcoes = await generateRegistrationOptions({
rpName: RP_NOME,
rpID: RP_ID,
userName: usuario.nome,
attestationType: 'none',
// Impede cadastrar duas vezes a mesma chave no mesmo usuario.
excludeCredentials: usuario.passkeys.map((p) => ({ id: p.id })),
authenticatorSelection: { residentKey: 'discouraged', userVerification: 'preferred' },
});
guardarDesafio(usuario, opcoes.challenge);
res.json(opcoes);
} catch (erro) {
res.status(500).json({ erro: 'Nao foi possivel montar o cadastro: ' + erro.message });
}
});
// 2) Cadastro, passo 2: confere a resposta do autenticador e guarda a chave publica.
app.post('/cadastro/verificar', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const desafio = tomarDesafio(usuario);
if (!desafio) {
return res.status(400).json({ erro: 'Desafio expirado ou ja usado. Comece de novo.' });
}
const resultado = await verifyRegistrationResponse({
response: req.body.resposta,
expectedChallenge: desafio,
expectedOrigin: ORIGEM,
expectedRPID: RP_ID,
requireUserVerification: false,
});
if (!resultado.verified) {
return res.status(400).json({ erro: 'Cadastro recusado.' });
}
const credencial = resultado.registrationInfo.credential;
usuario.passkeys.push({
id: credencial.id, // ja vem em base64url
chavePublica: paraBase64url(credencial.publicKey),
contador: credencial.counter,
});
res.json({ ok: true, total: usuario.passkeys.length });
} catch (erro) {
res.status(400).json({ erro: 'Cadastro recusado: ' + erro.message });
}
});
// 3) Login, passo 1: novo desafio, e a lista de passkeys que servem.
app.post('/login/opcoes', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
if (usuario.passkeys.length === 0) {
return res.status(400).json({ erro: 'Este usuario nao tem passkey cadastrada.' });
}
const opcoes = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: usuario.passkeys.map((p) => ({ id: p.id })),
});
guardarDesafio(usuario, opcoes.challenge);
res.json(opcoes);
} catch (erro) {
res.status(500).json({ erro: 'Nao foi possivel montar o login: ' + erro.message });
}
});
// 4) Login, passo 2: confere a assinatura com a chave publica guardada.
app.post('/login/verificar', async (req, res) => {
try {
const usuario = pegarUsuario(req.body.nome);
const desafio = tomarDesafio(usuario);
if (!desafio) {
return res.status(400).json({ erro: 'Desafio expirado ou ja usado. Comece de novo.' });
}
const passkey = usuario.passkeys.find((p) => p.id === req.body.resposta.id);
if (!passkey) {
return res.status(400).json({ erro: 'Passkey desconhecida.' });
}
const resultado = await verifyAuthenticationResponse({
response: req.body.resposta,
expectedChallenge: desafio,
expectedOrigin: ORIGEM,
expectedRPID: RP_ID,
requireUserVerification: false,
credential: {
id: passkey.id,
publicKey: deBase64url(passkey.chavePublica), // volta a virar bytes aqui
counter: passkey.contador,
},
});
if (!resultado.verified) {
return res.status(400).json({ erro: 'Assinatura invalida.' });
}
// Guardar o contador novo e o que denuncia uma passkey clonada.
passkey.contador = resultado.authenticationInfo.newCounter;
res.json({ ok: true, mensagem: 'Bem-vindo, ' + usuario.nome + '!' });
} catch (erro) {
res.status(400).json({ erro: 'Login recusado: ' + erro.message });
}
});
app.listen(3010, () => {
console.log('Servidor no ar em ' + ORIGEM);
});
Passkey parece um assunto gigante de criptografia, e no fundo o que você escreve é isto: gera desafio, guarda desafio, confere desafio, salva chave pública. As três armadilhas — o domínio que precisa casar, o desafio que precisa ser seu, e o base64url que não é base64 — são chatas justamente porque falham caladas. Sabendo delas de antemão, o resto é uma tarde de trabalho. 💜
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.