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)
- 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.
- O navegador chama
navigator.credentials.create(). O autenticador gera um novo par de chaves para aquele RP ID específico. - O navegador envia a chave pública, o ID da credencial e os dados assinados de volta para o servidor.
- O servidor verifica o desafio e a origem, depois salva a chave pública.
3.2. Autenticação (authentication)
- O servidor cria um novo desafio.
- O navegador chama
navigator.credentials.get(). O autenticador assina o desafio com a chave privada. - 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:
- Fazer login com
minh/matkhau-tam. - Abra
/webauthn/registere crie um passkey. - Fazer logout.
- 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. parseRequestOptionsFromJSONconverte campos base64url emArrayBuffer. 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://emrpId, ou esquecer a porta na origem. Isso fará o navegador ou o servidor rejeitar. Você deve separar esses dois valores emapplication.yamlpor 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.