Como adicionar passkeys a um app iOS e Android
Adicione chaves de acesso a apps iOS e Android com Swift e Kotlin. Configure associações de domínio, cadastro e login, e valide credenciais com segurança no servidor.
Uma passkey é um par de chaves pública/privada vinculado a um único domínio. Seu app a cria por meio da API de credenciais da plataforma, o servidor armazena apenas a chave pública, e o Face ID ou a impressão digital servem apenas para permitir que o dispositivo use a chave privada.
Um cenário comum de falha: o botão está conectado, o backend funciona na web e, em um celular real, nada aparece. Não há painel do sistema, nenhuma linha de log e, muitas vezes, apenas um erro genérico de cancelamento.
A causa quase sempre é uma destas três: a associação entre o app e o domínio, uma divergência entre o ID da relying party do servidor e esse domínio, ou uma lógica de verificação que o servidor nunca concluiu. Este guia aborda cada uma dessas peças no iOS (Swift) e no Android (Kotlin), incluindo o trabalho do lado do servidor em cada etapa. Ele parte do princípio de que você já optou pelas passkeys (o trade-off entre senhas e passkeys é discutido em outro artigo).
Principais conclusões
- Um app só pode criar ou usar uma passkey para um domínio quando o app declara esse domínio e o domínio publica um arquivo que identifica o app:
apple-app-site-associationcomwebcredentialsno iOS eassetlinks.jsoncomdelegate_permission/common.get_login_credsno Android. - O ID da relying party enviado pelo servidor deve ser o domínio que hospeda os dois arquivos de associação, e alterá-lo depois torna inutilizáveis todas as passkeys existentes.
- Apps distribuídos com a Assinatura de apps do Google Play precisam listar a impressão digital da chave de assinatura de apps do Google no
assetlinks.json. Se você listar apenas a chave de upload, as passkeys funcionam em builds locais e falham em instalações feitas pela loja. - Quem decide se uma passkey é válida é o servidor. Ele verifica um desafio de uso único, a origem (incluindo a origem
android:apk-key-hash:do Android), o hash do rpId e a assinatura antes de criar uma sessão. - Passkeys sincronizadas acompanham a conta Apple ou Google do usuário, então a segurança das contas do seu serviço passa a depender, em parte, da segurança dessas contas. Mantenha um caminho de recuperação que não dependa da passkey.
Passkey é a mesma coisa que desbloqueio biométrico?
Uma passkey não é desbloqueio por impressão digital. O Face ID ou a impressão digital apenas permitem que o dispositivo use uma chave privada criada para um único domínio e que nunca sai do dispositivo ou do chaveiro sincronizado do usuário. O servidor guarda a chave pública correspondente e verifica uma assinatura. Ele nunca tem acesso a nenhum dado biométrico.
A proteção biométrica local é um mecanismo diferente. Nela, o app desbloqueia um token armazenado anteriormente, e o servidor não tem nenhuma prova criptográfica de nada. Se é disso que você precisa, o artigo sobre autenticação biométrica no React Native cobre a configuração. Este guia não repete esse conteúdo.
Associação com a plataforma: a etapa que falha silenciosamente
O ID da relying party (rpId) enviado pelo seu servidor deve ser o mesmo domínio que hospeda os dois arquivos de associação. O sistema operacional verifica os dois lados de forma independente. O app lista o domínio, e o domínio publica um arquivo que lista o app. Se qualquer um dos lados estiver errado, o painel de passkey nunca aparece, e nenhuma das plataformas informa ao app o motivo.
iOS: entitlement webcredentials e apple-app-site-association
Adicione o domínio ao entitlement Associated Domains com o serviço webcredentials:
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:example.com</string>
</array>
Disponibilize este arquivo, sem extensão, em https://example.com/.well-known/apple-app-site-association. O ID do app é o seu Team ID e o bundle ID unidos por um ponto:
{
"webcredentials": {
"apps": ["ABCDE12345.com.example.shop"]
}
}
O guia da Apple sobre suporte a domínios associados determina que o arquivo seja servido via HTTPS com um certificado confiável e que a requisição não seja redirecionada. Como o arquivo não tem extensão, muitos hosts estáticos e object stores o servem com um content type binário genérico, então defina o tipo explicitamente:
location = /.well-known/apple-app-site-association {
default_type application/json;
}
curl -sI https://example.com/.well-known/apple-app-site-association | grep -i content-type
# expect: content-type: application/json
Os dispositivos não buscam esse arquivo no seu servidor. Eles leem uma cópia que a CDN da Apple busca e armazena em cache. Segundo a documentação da Apple, a CDN requisita o arquivo em cerca de 24 horas, e os dispositivos com o app instalado verificam se há uma cópia atualizada aproximadamente uma vez por semana. Por isso, um arquivo corrigido leva tempo para chegar aos apps já instalados: primeiro a CDN precisa buscá-lo novamente e, depois, cada dispositivo o recebe na instalação, na atualização ou na próxima verificação semanal. Se o arquivo estava quebrado quando a CDN o buscou, a CDN também armazena em cache esse resultado quebrado. Durante o desenvolvimento, use webcredentials:example.com?mode=developer e ative Associated Domains Development em Ajustes › Desenvolvedor no dispositivo. Assim, o dispositivo busca o arquivo diretamente do seu domínio. Remova o sufixo antes do lançamento, porque os builds de release dependem da cópia da CDN.
Android: assetlinks.json com get_login_creds
No Android, as passkeys do Credential Manager exigem Digital Asset Links no domínio do rpId, disponibilizado em https://example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": [
"<PLAY_APP_SIGNING_KEY_SHA256>",
"<UPLOAD_KEY_SHA256>"
]
}
}
]
Liste a impressão digital de todo keystore que você usa para assinar:
keytool -list -v -keystore release.keystore -alias release | grep SHA256
Se o app for distribuído com a Assinatura de apps do Google Play, o Google o assina novamente antes da entrega. A impressão digital que vale para instalações feitas pela loja é a da chave de assinatura de apps exibida na página Assinatura de apps do Play Console. Listar apenas a sua chave de upload local é o motivo pelo qual as passkeys funcionam em builds de debug e falham para os usuários que instalaram pela loja. Apps inscritos na assinatura híbrida preparada para a era quântica do Play têm mais de uma chave de assinatura de apps, então adicione todas as impressões digitais SHA-256 exibidas nessa página. Listar impressões digitais extras não causa nenhum problema.
Verificações preliminares
| Verificação | Como verificar | Sintoma se estiver errado |
|---|---|---|
| Content-Type | curl -sI na URL do AASA | Nenhum painel no iOS |
| Sem redirecionamentos | curl -sI retorna 200, não 301/302 | Nenhum painel em nenhuma das plataformas |
| Correspondência de subdomínio | Arquivos servidos exatamente no host do rpId | Funciona na web, falha no app |
| Chave de assinatura do Play | Compare com a página Assinatura de apps do Play Console | Funciona em debug, falha na versão da loja |
| Cópia na CDN da Apple | Consulte a URL da CDN abaixo | Arquivo antigo ou quebrado ainda em uso |
Subdomínios não herdam a associação. A especificação do Digital Asset Links identifica um site pelo esquema, host exato e porta, então arquivos em www.example.com não têm efeito algum para um rpId example.com. A regra da Apple é a mesma: cada subdomínio precisa do seu próprio arquivo apple-app-site-association.
Os dois arquivos podem ser verificados sem instalar um build. A primeira URL retorna o que a CDN da Apple tem armazenado para o seu domínio (engenheiros da Apple a utilizam para diagnóstico; não é uma API documentada). A segunda chama o endpoint statements.list do Google:
curl https://app-site-association.cdn-apple.com/a/v1/example.com
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.get_login_creds"
Uma associação quebrada não gera nenhuma exceção que sua ferramenta de crash reporting consiga capturar. O session replay torna o padrão visível: um toque em “Entrar com passkey”, nenhum painel do sistema e, em seguida, o usuário mudando para o campo de senha. Depois, você pode filtrar essas sessões por build e variante de assinatura.
Como implementar passkeys: registro
O registro de passkeys em um app nativo tem três partes. O servidor cria as opções com um desafio novo e rp.id definido como o domínio associado. A API nativa cria a credencial. O servidor verifica o resultado e armazena a chave pública e o ID da credencial. As opções do servidor têm esta aparência:
{
"challenge": "<base64url, random, single-use>",
"rp": { "id": "example.com", "name": "Example Shop" },
"user": { "id": "<base64url user handle>", "name": "ada@example.com", "displayName": "Ada" },
"pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
"authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
"attestation": "none",
"excludeCredentials": []
}
No iOS, o ASAuthorizationPlatformPublicKeyCredentialProvider monta a requisição:
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let request = provider.createCredentialRegistrationRequest(
challenge: challenge, name: "ada@example.com", userID: userHandle)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
// In the delegate:
if let reg = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
api.finishRegistration(id: reg.credentialID, clientData: reg.rawClientDataJSON,
attestation: reg.rawAttestationObject)
}
No Android, adicione androidx.credentials:credentials e androidx.credentials:credentials-play-services-auth. A segunda é obrigatória no Android 13 e versões anteriores, como explica o guia de migração do FIDO2. Consulte a versão atual nas notas de versão. Em seguida, passe o JSON do servidor diretamente para CreatePublicKeyCredentialRequest:
val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
context = activity,
request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)
O servidor então segue a cerimônia de registro do WebAuthn. Ele verifica o desafio, a origem e o hash do rpId e, em seguida, armazena o ID da credencial, a chave pública, o contador de assinaturas e o user handle. Apps Android enviam uma origem no formato android:apk-key-hash:<encoded SHA 256 fingerprint>, e o seu servidor precisa aceitar exatamente esse valor como uma das origens permitidas. Adicione uma origem para cada certificado de assinatura.
Login: asserção e verificação no servidor
O login com passkey segue o mesmo padrão do registro. O servidor emite um desafio, o dispositivo o assina com a chave privada e o servidor verifica a assinatura usando a chave pública armazenada. O app nunca decide se uma passkey é válida.
let request = provider.createCredentialAssertionRequest(challenge: challenge)
// Delegate receives ASAuthorizationPlatformPublicKeyCredentialAssertion:
// credentialID, rawClientDataJSON, rawAuthenticatorData, signature, userID
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = optionsJson)))
val result = credentialManager.getCredential(context = activity, request = request)
api.finishSignIn((result.credential as PublicKeyCredential).authenticationResponseJson)
Seguindo as etapas de verificação de asserção, o servidor:
- Confirma que o desafio corresponde a um que ele emitiu e que ainda não foi usado.
- Verifica a origem em relação à allow-list, incluindo todas as origens
apk-key-hashdo Android. - Verifica se o
rpIdHashé igual ao hash SHA-256 do seu rpId. - Exige a flag de presença do usuário (user present) e também a de usuário verificado (user verified), se a sua política exigir.
- Verifica a assinatura com a chave pública armazenada.
- Aplica a regra do contador de assinaturas. A especificação só compara os contadores quando o valor armazenado ou o recebido é diferente de zero, então não rejeite um contador igual a zero.
A sincronização transforma as passkeys em uma questão de recuperação de conta
Passkeys sincronizadas aparecem em dispositivos que nunca fizeram login no seu app. Passkeys salvas no Gerenciador de senhas do Google acompanham a conta Google do usuário para os outros dispositivos Android conectados a ela, e o Chaves do iCloud as sincroniza entre os dispositivos Apple do usuário. Um usuário pode comprar um celular novo, abrir seu app e fazer login imediatamente.
A sincronização de passkeys tem três consequências para a recuperação de conta:
- Perder o celular não significa perder a conta. Quando um dispositivo é perdido, a passkey continua disponível nos outros dispositivos do usuário.
- A conta da plataforma agora faz parte do seu perímetro de segurança. A segurança da passkey do seu domínio depende da conta Apple ou Google do usuário, incluindo a autenticação de dois fatores e as verificações de recuperação dessa conta.
- Nem todo mundo sincroniza. Usuários com a sincronização do chaveiro desativada, com um provedor de terceiros ou sem conta Google no dispositivo ainda podem perder sua única credencial.
Permita várias passkeys por conta, adicione uma tela em que os usuários possam visualizá-las e revogá-las e mantenha uma rota de recuperação por e-mail ou OTP. Em um dispositivo novo, um login que recorre à senha mesmo havendo uma passkey sincronizada geralmente indica um bug no rpId ou na allow-list, e não uma recusa do usuário.
Fallback para dispositivos antigos e usuários que recusam
Mantenha o login por senha ou OTP junto com as passkeys no lançamento. As passkeys nativas no Android exigem Android 9 (API nível 28) ou superior, e o uso de um provedor de passkeys de terceiros exige Android 14. Alguns usuários vão recusar o prompt independentemente do dispositivo que tiverem. Trate NoCredentialException e ASAuthorizationError.canceled exibindo o formulário de login existente, e não uma mensagem de erro.
Ofereça a criação de passkey após um login bem-sucedido com senha, em vez de forçá-la no cadastro. Trate o caminho antigo como permanente até que seus dados mostrem que ele pode ser rebaixado. O artigo Como funciona o login sem senha explica por que as passkeys são mais robustas do que magic links e OTPs. Apps em React Native e Flutter seguem as mesmas regras: qualquer biblioteca de bridge que você escolher chama essas mesmas APIs nativas, então os arquivos de associação e as verificações no servidor não mudam.
Conclusão
A maioria dos bugs de passkeys é de configuração, não de código. Antes de escrever qualquer código nativo, escolha o rpId e hospede os dois arquivos de associação exatamente nesse domínio. Verifique-os com os dois validadores via curl e adicione a impressão digital da Assinatura de apps do Google Play e a origem do Android no servidor. Assim que os dois validadores retornarem o seu app, as chamadas em Swift e Kotlin mostradas acima são todo o código de cliente de que você precisa.
Perguntas frequentes
Qual é a versão mínima do iOS necessária para usar passkeys em um app nativo?
Passkeys sincronizadas exigem iOS 16 ou posterior, a versão em que a Apple introduziu as passkeys armazenadas no Chaves do iCloud. Credenciais criadas com o autenticador de plataforma integrado no iOS 15 ou anterior nunca se tornam passkeys e permanecem no dispositivo que as criou. Para fazer o upgrade de uma delas, registre uma nova credencial com o mesmo user handle, e o iOS substituirá a credencial antiga por uma passkey sincronizada. No Android, o mínimo é o Android 9 (API nível 28).
Os usuários podem fazer login no app com uma passkey criada no meu site?
Sim, desde que o site e o app usem o mesmo rpId. Uma passkey é vinculada ao rpId, não ao cliente, então uma única credencial funciona no navegador e nos dois apps nativos. Apps nativos do iOS informam uma origem composta por https:// mais o rpId, o mesmo valor enviado pelo site. Apps Android informam uma origem android:apk-key-hash, que o servidor precisa adicionar separadamente à sua allow-list.
Posso testar passkeys no emulador do Android e no Simulador do iOS?
No Android, sim. Use uma imagem de emulador com Google Play (e não o AOSP puro) com API nível 28 ou superior, faça login com uma conta Google e configure um bloqueio de tela, porque é o Gerenciador de senhas do Google que armazena a passkey. Sem isso, a criação da credencial falha com uma exceção que parece um bug do app. O Simulador do iOS pode se comportar de forma diferente do hardware real, então faça os testes finais de passkey em um iPhone físico.
Quais erros indicam uma associação quebrada, e não um bug de código?
No Android, uma CreatePublicKeyCredentialDomException durante o registro geralmente significa que o assetlinks.json está ausente ou inacessível, lista o nome de pacote ou a impressão digital errados, ou está em um host que não corresponde ao rpId. No iOS, uma associação quebrada costuma aparecer como ASAuthorizationError com código 1001, o mesmo código gerado quando o usuário cancela. Uma GetCredentialCancellationException no Android significa que o usuário fechou o painel e não exige nenhuma mensagem de erro.