Destino Dev

Blog

WebAuthn sem senha no Next.js (App Router) + Supabase: passo a passo

Por John Pires · 19 de março de 2026

O que é e por que importa

WebAuthn é um padrão W3C que permite autenticação sem senha usando chaves públicas geradas no dispositivo do usuário (por exemplo, biometria, PIN do dispositivo ou chaves de segurança FIDO). Em vez de enviar senhas, o cliente cria chaves públicas/privadas. O servidor guarda a chave pública e valida assinaturas produzidas pela chave privada no dispositivo do usuário.

Isso importa por três razões práticas: segurança (resistente a phishing), UX (login com biometria ou chave física) e redução de risco operacional (sem necessidade de reset/armazenamento de senhas). Para iniciantes em desenvolvimento web, aprender WebAuthn abre portas para sistemas de autenticação modernos e mais seguros.

Resumo técnico: o fluxo básico envolve gerar opções de registro no servidor (challenge), o navegador chama navigator.credentials.create com essas opções, o cliente retorna uma attestation ao servidor e o servidor verifica e persiste os dados. Para autenticação, é similar: servidor gera opções de autenticação (challenge), navegador chama navigator.credentials.get, retorna uma assertion, servidor verifica utilizando a chave pública previamente registrada.

Por que Next.js (App Router) e Supabase? Next.js App Router organiza rotas API como funções serverless modernas e Supabase oferece banco de dados PostgreSQL + autenticação e cliente para armazenar credenciais e desafios. Juntos, aceleram a implementação prática do fluxo WebAuthn.

Conceitos fundamentais com analogias

Chave pública/privada (a analogia do cofre)

Imagine um cofre com duas chaves: a chave pública é a etiqueta do cofre (pode ser compartilhada) e a chave privada é a combinação secreta guardada no bolso do usuário. O servidor confia na etiqueta (chave pública) para verificar que a combinação veio do dono legítimo.

Quando o usuário faz login, o servidor cria um desafio (um número aleatório). O dispositivo assina esse desafio com a chave privada. O servidor usa a chave pública para verificar a assinatura. Se bater, o usuário prova posse da chave privada sem revelar a chave.

Attestation vs Assertion

Attestation é o ato de registrar uma nova credencial: o dispositivo entrega informações sobre a chave pública e possivelmente metadados do autenticador. Assertion é a ação de provar posse da chave existente para autenticar.

Analogia: attestation é quando o usuário registra um novo cofre em um banco; assertion é usar esse cofre para abrir uma caixa segura depois.

Risco de replay e uso do challenge

O challenge é um nonce único por operação. Sem ele, um atacante poderia reproduzir uma assinatura antiga. Sempre gere e persista o challenge no servidor (associado ao usuário e à operação) para comparar depois da resposta.

Dica: armazene o challenge por um curto período (p.ex. 1-5 minutos) e limpe após uso. Isso evita ataques replay e uso indevido de desafios antigos.

Passo a passo com código comentado

Pontos gerais do projeto

Vamos supor um projeto Next.js com App Router. Instale dependências no backend: @simplewebauthn/server e @supabase/supabase-js. No cliente, o navegador já expõe navigator.credentials (WebAuthn API).

Arquivos principais que vamos criar/exemplificar:

  • /app/api/webauthn/register/route.ts — gera opções de registro (server)
  • /app/api/webauthn/register/verify/route.ts — verifica attestation e persiste credencial
  • /app/components/WeAuthnRegisterClient.tsx — componente cliente que chama navigator.credentials.create
  • Banco de dados Supabase com tabela webauthn_credentials

Configuração mínima do Supabase

Crie uma tabela PostgreSQL para armazenar credenciais. Exemplo SQL:

-- Cria tabela para credenciais WebAuthn
CREATE TABLE webauthn_credentials (
  id serial PRIMARY KEY,
  user_id uuid NOT NULL,
  credential_id text NOT NULL UNIQUE,
  public_key text NOT NULL,
  sign_count bigint DEFAULT 0,
  transports text[],
  created_at timestamptz DEFAULT now()
);

-- Tabela simples para desafios temporários
CREATE TABLE webauthn_challenges (
  id serial PRIMARY KEY,
  user_id uuid NOT NULL,
  challenge text NOT NULL,
  type text NOT NULL, -- 'registration' ou 'authentication'
  created_at timestamptz DEFAULT now()
);

Após criar as tabelas, configure suas variáveis de ambiente no Next.js: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY (usado no servidor) e ORIGIN para base URL do site.

Exemplo prático completo

1) Gerar opções de registro no servidor

Este endpoint cria um challenge e retorna as PublicKeyCredentialCreationOptions para o cliente. Usa @simplewebauthn/server para construir as opções.

// /app/api/webauthn/register/route.ts
import { NextResponse } from 'next/server';
import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs';
import { generateRegistrationOptions } from '@simplewebauthn/server';

// Gera as opções de registro e grava um challenge na tabela webauthn_challenges
export async function POST(req: Request) {
  const body = await req.json();
  const { userId, userName } = body; // recebido do cliente

  // generateRegistrationOptions monta o objeto que o cliente precisa
  const options = generateRegistrationOptions({
    rpName: 'MeuApp',
    rpID: new URL(process.env.ORIGIN!).hostname,
    userID: userId,
    userName: userName,
    attestationType: 'none',
    timeout: 60000,
    authenticatorSelection: { userVerification: 'preferred' }
  });

  // Persista o challenge no Supabase
  const supabase = createServerSupabaseClient({ req, /* res se necessário */ });
  await supabase.from('webauthn_challenges').insert([{ user_id: userId, challenge: options.challenge, type: 'registration' }]);

  return NextResponse.json(options);
}

O que o código faz: cria opções de registro contendo um challenge único. Depois grava o challenge no banco para validação posterior. O cliente receberá o objeto completo para chamar navigator.credentials.create.

O que acontece: usuário no navegador usará essas opções para criar uma credencial no dispositivo (biometria, PIN, ou chave FIDO).

2) Componente cliente de registro

Com as opções recebidas do servidor, o cliente chama navigator.credentials.create e envia a resposta (attestation) de volta ao servidor para verificação.

// app/components/WeAuthnRegisterClient.tsx (React Client Component)
'use client';
import { useState } from 'react';

export default function WeAuthnRegisterClient({ userId, userName }: { userId: string; userName: string }) {
  const [status, setStatus] = useState('pronto');

  async function startRegister() {
    setStatus('pedindo opções');
    // 1) pede opções ao servidor
    const res = await fetch('/api/webauthn/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, userName })
    });
    const options = await res.json();

    // 2) navigator.credentials.create espera ArrayBuffers para alguns campos
    // converter campos base64url se necessário (bibliotecas comuns fazem isso).
    setStatus('criando credencial no navegador');
    const credential = await navigator.credentials.create({ publicKey: options });

    // 3) enviar attestation ao servidor
    setStatus('enviando attestation ao servidor');
    await fetch('/api/webauthn/register/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, attestationResponse: credential })
    });

    setStatus('registrado');
  }

  return (
    <div>
      <p>Status: {status}</p>
      <button onClick={startRegister}>Registrar autenticação sem senha</button>
    </div>
  );
}

O que o código faz: solicita opções, cria a credencial via API WebAuthn e manda a resposta ao backend. Observação: navigator.credentials.create retorna objetos que contêm ArrayBuffers; você precisará serializar para enviar por JSON (por exemplo, base64url).

O que acontece: após sucesso, o servidor terá dados suficientes para verificar e persistir a chave pública associada ao usuário.

3) Verificar attestation e persistir credencial

No servidor, verifique a attestation com @simplewebauthn/server e salve a credential_id, public_key e sign_count no banco.

// /app/api/webauthn/register/verify/route.ts
import { NextResponse } from 'next/server';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs';

export async function POST(req: Request) {
  const body = await req.json();
  const { userId, attestationResponse } = body;

  // Busque o challenge gerado antes
  const supabase = createServerSupabaseClient({ req });
  const { data } = await supabase.from('webauthn_challenges').select('challenge').eq('user_id', userId).eq('type', 'registration').order('created_at', { ascending: false }).limit(1).single();
  const expectedChallenge = data.challenge;

  // verifyRegistrationResponse valida a attestation usando o challenge
  const verification = await verifyRegistrationResponse({
    response: attestationResponse,
    expectedChallenge,
    expectedOrigin: process.env.ORIGIN!,
    expectedRPID: new URL(process.env.ORIGIN!).hostname
  });

  if (!verification.verified) return NextResponse.json({ ok: false, error: 'verificacao falhou' }, { status: 400 });

  const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;

  // Persista a credencial no Supabase
  await supabase.from('webauthn_credentials').insert([{ user_id: userId, credential_id: Buffer.from(credentialID).toString('base64url'), public_key: Buffer.from(credentialPublicKey).toString('base64url'), sign_count: counter }]);

  return NextResponse.json({ ok: true });
}

O que o código faz: recupera o challenge, chama verifyRegistrationResponse para validar a attestation e grava a credential pública no banco. Atenção para conversões de ArrayBuffer/Uint8Array para string base64url.

O que acontece: após sucesso, o usuário terá uma credencial vinculada ao seu user_id e poderá autenticar sem senha.

4) Fluxo de autenticação (assertion)

O processo é análogo: gerar opções de autenticação com generateAuthenticationOptions, enviar ao cliente, cliente chama navigator.credentials.get, enviar resposta ao servidor e verificar com verifyAuthenticationResponse. Atualize o sign_count na base para mitigar clonagem de credenciais.

// trecho simplificado: gerar opções de autenticação
import { generateAuthenticationOptions } from '@simplewebauthn/server';

const options = generateAuthenticationOptions({
  rpID: new URL(process.env.ORIGIN!).hostname,
  allowCredentials: [ { id: Buffer.from(credentialIdBase64url, 'base64url'), type: 'public-key' } ],
  userVerification: 'preferred'
});
// persista options.challenge e envie ao cliente

O que o código faz: limita as credenciais permitidas ao usuário (se você quiser) e gera um challenge para a request de autenticação. Quando a assertion for verificada, atualize sign_count na tabela para prevenir replay.

Erros comuns, armadilhas e dicas práticas

1) Problemas de origem (origin) e RPID

Um dos erros mais frequentes é mismatch entre o origin no cliente (por exemplo, http://localhost:3000) e o expectedOrigin configurado no servidor, ou RPID incorreto. Verifique process.env.ORIGIN e use o hostname correto como RP ID (sem http/https).

Exemplo: se você estiver rodando em http://localhost:3000, expectedOrigin deve ser exatamente isso no servidor, e expectedRPID deve ser 'localhost'.

2) Serialização de ArrayBuffers

navigator.credentials retorna objetos com ArrayBuffers. Não envie isso diretamente em JSON. Converta para base64url no cliente antes de postar e decodifique no servidor. Libraries helpers do @simplewebauthn/server e @simplewebauthn/browser podem ajudar.

3) Uso de chave de serviço Supabase

Para operações server-side (gravar credenciais), use a chave service_role segura (no ambiente do servidor). Não exponha essa chave no cliente. Use o helper createServerSupabaseClient nos handlers do App Router.

Dica: mantenha o serviço Supabase isolado em rotas server-only. Evite lógica WebAuthn no client além do necessário (navigator.credentials).

Próximos passos para aprofundar

Aumentar compatibilidade

Testar em vários navegadores e dispositivos. WebAuthn tem diferenças sutis entre implementações de autenticadores (platform vs roaming). Use adapter libraries e siga as recomendações do FIDO Alliance para metadata se precisar de suporte avançado.

Melhorar segurança operacional

Considere impor user-verification: 'required' para garantir biometria/PIN, implemente rate limiting nos endpoints e monitore sign_count para detectar tentativas de clonagem de credenciais.

Se for um produto em produção, avalie usar o Metadata Service (MDS) da FIDO para atestar a confiança em autenticadores roaming.

Conclusão prática

Implementar WebAuthn com Next.js App Router e Supabase é extremamente factível mesmo para iniciantes se você entender os blocos: gerar challenge, criar/verificar credenciais e persistir/atualizar dados no banco. Use bibliotecas como @simplewebauthn/server para não reinventar checagens criptográficas.

Comece pequeno: registre uma credencial em localhost, verifique attestation e depois implemente login (assertion). Teste com diferentes autenticadores e cuide de detalhes de serialização e origin/RPID.

Checklist prático:

  1. Configurar Supabase e tabelas para credenciais e desafios
  2. Gerar e persistir challenges no servidor
  3. Converter ArrayBuffers <-> base64url no cliente/servidor
  4. Verificar attestation e assertion com biblioteca confiável
  5. Atualizar sign_count e limpar desafios usados

Com isso você terá um sistema de autenticação sem senha mais seguro e moderno. Implementações posteriores podem incluir fallback (senha ou magic link), políticas de recuperação e UI de gerenciamento de chaves do usuário.

Se quiser, posso gerar um repositório mínimo com esses arquivos já formatados para Next.js App Router e um script de migração SQL para Supabase. Informe seu nível de conforto com terminal e banco e eu monto os arquivos prontos para rodar.

Assuntos: webauthn, Next.js, supabase, app-router, authentication

Leia também