Pular para o conteúdo
Node.js

Node.js: autenticação com Passkeys

Paloma Macetko
Um unicórnio de crina colorida ao lado de uma porta de cofre mágica entreaberta, com uma chave brilhante, um castelo, uma coruja e velas flutuantes ao fundo

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