Passkey em lugar de senha: como funciona e integração

1. Visão geral

Senhas têm três grandes problemas: são fáceis de adivinhar, são reutilizadas em vários lugares e são facilmente inseridas em páginas falsas. Passkey resolve todos os três usando criptografia de chaves públicas. O servidor não guarda mais nenhum segredo do usuário.

Este artigo explica o que é passkey e como funcionam os fluxos de registro e login. Depois você ativará passkey em uma aplicação Spring Boot 3.x usando Spring Security. Ao final, você verá erros comuns e casos em que ainda não deve usar passkey.

2. O que é passkey?

Passkey é uma credencial WebAuthn, ou seja, uma credencial que segue o padrão FIDO2. Em essência, é um par de chaves:

  • Chave privada fica no dispositivo do usuário (smartphone, laptop, chave de segurança USB) ou em um gerenciador de senhas. Esta chave nunca sai de lá.
  • Chave pública é enviada para o servidor durante o registro. O servidor guarda esta chave junto à conta.

Cada par de chaves está vinculado a uma Relying Party (RP), ou seja, seu site ou aplicação, através de um RP ID. O RP ID geralmente é o nome de domínio, por exemplo loja.exemplo.com.br.

Existem dois tipos principais de passkey:

Tipo Características
Passkey sincronizada Sincronizada por meio de conta na nuvem do sistema operacional ou gerenciador de senhas. Mesmo se perder o dispositivo, você recupera a chave.
Passkey vinculada ao dispositivo Existe apenas em um dispositivo, por exemplo uma chave de segurança de hardware. Mais segura, mas perder o dispositivo significa perder a chave.

O usuário desbloqueia a passkey com impressão digital, reconhecimento facial ou PIN do dispositivo. Os dados biométricos são usados apenas para desbloquear localmente. Eles nunca são enviados para o servidor.

3. Como funciona a passkey

3.1. Registro (registration)

  1. O servidor cria um desafio aleatório. O servidor envia o desafio junto com informações sobre a RP e o usuário para o navegador.
  2. O navegador chama navigator.credentials.create(). O autenticador gera um novo par de chaves para aquele RP ID específico.
  3. O navegador envia a chave pública, o ID da credencial e os dados assinados de volta para o servidor.
  4. O servidor verifica o desafio e a origem, depois salva a chave pública.

3.2. Autenticação (authentication)

  1. O servidor cria um novo desafio.
  2. O navegador chama navigator.credentials.get(). O autenticador assina o desafio com a chave privada.
  3. O servidor usa a chave pública já armazenada para verificar a assinatura.

O cerne é “assinar com a chave privada, verificar com a chave pública”. O trecho Java puro abaixo simula exatamente este passo:

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // O dispositivo gera um par de chaves no registro (EC P-256, correspondente ao ES256 comum em WebAuthn)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // O servidor cria um desafio aleatório para o login
        byte[] challenge = randomBytes();

        // O dispositivo assina o desafio com a chave privada
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // O servidor verifica usando a chave pública armazenada
        System.out.println("Desafio correto: " + verify(storedOnServer, challenge, signature));
        System.out.println("Desafio diferente: " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

Resultado:

Desafio correto: true
Desafio diferente: false

Deste resultado você tira duas conclusões:

  • O servidor guarda apenas storedOnServer, a chave pública. Se um atacante roubar o banco de dados, não conseguirá fazer login mesmo assim.
  • A assinatura só é válida para o desafio daquele momento. Se um atacante interceptar uma assinatura antiga e tentar reutilizá-la (replay attack), a verificação falhará.

No WebAuthn real, os dados assinados também contêm a origem da página. O navegador preenche a origem automaticamente, e a página não consegue falsificá-la. Portanto, se o usuário acessar acidentalmente examp1e.com.br, o navegador não usará a passkey de exemplo.com.br. O phishing é bloqueado no nível do protocolo, não depende da atenção do usuário.

4. Por que passkey está chamando atenção

  • Proteção contra phishing desde o design. Senhas e OTP podem ser enganadas e inseridas em páginas falsas. Passkey não.
  • Nenhum segredo no servidor. Vazamento de banco de dados não expõe informações de login.
  • Melhor experiência do usuário. Os usuários só precisam tocar na impressão digital, não precisa lembrar senha ou esperar SMS.
  • O ecossistema está pronto. Sistemas operacionais, navegadores e gerenciadores de senhas populares já sincronizam passkeys. Com isso, perder um dispositivo não é mais uma grande barreira.
  • Suporte nativo de framework. Você não precisa escrever verificação criptográfica, que é muito propensa a erros.

5. Integrando passkey com Spring Security

Do Spring Security 6.4 em diante (que acompanha Spring Boot 3.4+), o framework já possui um DSL webAuthn(). Internamente, a verificação usa a biblioteca WebAuthn4J.

5.1. Dependência

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

Você declara a propriedade webauthn4j.version com uma versão compatível com o Spring Security que está usando. A documentação do Spring Security indica qual versão foi testada.

5.2. Configurando SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

Significado de cada parte:

  • rpName: nome exibido na caixa de diálogo passkey do sistema operacional.
  • rpId: nome de domínio associado ao passkey. Este valor não possui scheme ou porta. Em produção, será loja.exemplo.com.br.
  • allowedOrigins: origens válidas. A origem deve corresponder exatamente, incluindo scheme e porta.
  • formLogin: mantido também. O usuário precisa fazer login com senha primeiro para registrar um passkey. Passkey é uma credencial adicionada a uma conta existente.

Quando a aplicação executa, Spring Security cria automaticamente os seguintes endpoints:

GET  /webauthn/register              página de gerenciamento/registro de passkey (padrão)
POST /webauthn/register/options      criar desafio de registro
POST /webauthn/register              salvar nova chave pública
POST /webauthn/authenticate/options  criar desafio de login
POST /login/webauthn                 verificar assinatura e fazer login

O fluxo de teste tem quatro etapas:

  1. Fazer login com minh / matkhau-tam.
  2. Abra /webauthn/register e crie um passkey.
  3. Fazer logout.
  4. Fazer login novamente com passkey.

5.3. Armazenando credenciais

Por padrão, Spring Security armazena credenciais na memória. Reiniciar a aplicação causa perda de todos os passkeys. Em produção, você precisa fornecer beans que implementem duas interfaces:

  • UserCredentialRepository: armazenar chave pública, ID da credencial e contagem de assinatura.
  • PublicKeyCredentialUserEntityRepository: mapear seus usuários para o user handle do WebAuthn.

Versões recentes do Spring Security já têm implementações JDBC para essas interfaces. Você também pode escrever suas próprias implementações com JPA. Cada credencial deve ser um registro separado, porque um usuário geralmente tem vários passkeys em vários dispositivos.

6. Lado do navegador

Se usar a página de login padrão, Spring Security já trata do JavaScript para você. Para uma interface personalizada, o fluxo de login fica assim:

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // Etapa 1: obter o desafio do servidor
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // Etapa 2: o navegador exibe caixa de diálogo, o autenticador assina o desafio
  const credential = await navigator.credentials.get({ publicKey: options });

  // Etapa 3: enviar assinatura para o servidor para verificação
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

As etapas do código:

  • A etapa 1 obtém o desafio. A solicitação deve incluir token CSRF, pois é um POST.
  • parseRequestOptionsFromJSON converte campos base64url em ArrayBuffer. Navegadores antigos podem não suportar essa função. Neste caso, você precisa de uma biblioteca helper ou decodificar manualmente.
  • A etapa 3 envia o resultado para o servidor. O formato JSON deve corresponder ao que o servidor espera. O script na página de login padrão do Spring Security é a melhor referência para comparação.

7. Testes

Você não pode simular um autenticador real em testes unitários de forma simples. Porém, você ainda pode testar a parte importante do servidor: o endpoint de criação de desafio funciona e retorna um desafio diferente a cada chamada.

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester (Spring Framework 6.2+) permite escrever assertions no estilo AssertJ. csrf() vem de SecurityMockMvcRequestPostProcessors. O primeiro teste confirma que o desafio não é reutilizado, este é exatamente o mecanismo anti-replay. O segundo teste garante que rpId está configurado corretamente. Este é o erro de configuração mais comum ao fazer deploy.

Para testes end-to-end, você usa o autenticador virtual do navegador. Chrome DevTools tem uma guia WebAuthn, e WebDriver/Playwright também suportam esse recurso.

8. Erros comuns e quando não usar

8.1. Erros comuns

  • Escrever https:// em rpId, ou esquecer a porta na origem. Isso fará o navegador ou o servidor rejeitar. Você deve separar esses dois valores em application.yaml por ambiente.
  • Não usar HTTPS. WebAuthn só funciona em contextos seguros. localhost é a única exceção.
  • Trocar o nome de domínio. Passkeys estão vinculados ao RP ID. Se você trocar o domínio principal, todos os passkeys antigos ficarão inúteis.
  • Armazenamento em memória em produção. Reiniciar faz os usuários perderem seu passkey.
  • Permitir apenas um passkey por usuário. Os usuários precisam de vários passkeys para vários dispositivos. Tenha uma página que liste e permita deletar cada passkey.

Atenção: Passkey é forte apenas quando o fluxo de recuperação de conta também é forte. Se “esqueci meu passkey” apenas envia um link por email, os atacantes visarão o email em vez do passkey.

8.2. Quando não usar (ou não usar sozinho)

  • Usuários em dispositivos antigos ou compartilhados, como computadores em balcão ou quiosque. Neste caso, você deve manter um método alternativo.
  • Sistemas que não são baseados em navegador ou app, por exemplo, API entre serviços, CLI ou trabalhos em segundo plano. Nesses casos, use OAuth2 client credentials, mTLS ou chave de API.
  • Requisitos de conformidade que aceitam apenas chaves de hardware. Passkeys sincronizados podem não atender aos requisitos. Você precisa de passkeys vinculados ao dispositivo e verificação de atestado.
  • A equipe ainda não tem um processo de suporte para recuperação de conta. Você deve implementar passkey como uma opção adicional primeiro, e depois considerar remover senhas.

9. Conclusão

Passkey substitui o segredo compartilhado (senha) por um par de chaves públicas vinculado a um nome de domínio. Dessa forma, passkey elimina phishing, credential stuffing e risco de vazamento de banco de dados com apenas um mecanismo. Com Spring Security, você só precisa adicionar webAuthn() a SecurityFilterChain, configurar corretamente rpId e allowedOrigins, e depois substituir o armazenamento em memória por um armazenamento permanente.

A forma mais segura de implementar é passo a passo: permitir que os usuários adicionem passkey a contas existentes, design de fluxo de recuperação robusto, e depois considerar desabilitar o login com senha.

passkeywebauthnspring-securitysegurancatech-trends