Passkeys in iOS- und Android-Apps integrieren
Passkeys in iOS- und Android-Apps mit Swift und Kotlin einrichten. Konfigurieren Sie die Domain-Zuordnung, Registrierung und Anmeldung sowie die sichere Prüfung auf dem Server.
Ein Passkey ist ein Public/Private-Key-Paar, das an genau eine Domain gebunden ist. Ihre App erzeugt es über die Credential-API der Plattform, der Server speichert ausschließlich den Public Key, und Face ID oder ein Fingerabdruck dienen lediglich dazu, dem Gerät die Nutzung des Private Keys freizugeben.
Ein typisches Fehlerbild: Der Button ist verdrahtet, das Backend funktioniert im Web, doch auf einem echten Smartphone passiert nichts. Kein System-Sheet, kein Logeintrag, oft nur ein generischer Abbruchfehler.
Die Ursache ist fast immer einer von drei Punkten: die Verknüpfung zwischen App und Domain, eine Abweichung zwischen der Relying Party ID des Servers und dieser Domain oder eine Verifizierungslogik, die auf dem Server nie fertiggestellt wurde. Dieser Leitfaden behandelt jeden dieser Bausteine unter iOS (Swift) und Android (Kotlin), jeweils einschließlich der zugehörigen Serverarbeit. Er setzt voraus, dass Sie sich bereits für Passkeys entschieden haben (die Abwägung zwischen Passwörtern und Passkeys wird an anderer Stelle behandelt).
Die wichtigsten Erkenntnisse
- Eine App kann einen Passkey für eine Domain nur dann erstellen oder verwenden, wenn die App diese Domain deklariert und die Domain eine Datei veröffentlicht, die die App benennt:
apple-app-site-associationmitwebcredentialsunter iOS undassetlinks.jsonmitdelegate_permission/common.get_login_credsunter Android. - Die Relying Party ID, die der Server sendet, muss die Domain sein, auf der beide Verknüpfungsdateien liegen. Wird sie später geändert, sind alle bestehenden Passkeys unbrauchbar.
- Apps, die mit der Play App-Signatur ausgeliefert werden, müssen den Fingerprint des App-Signaturschlüssels von Google in
assetlinks.jsonaufführen. Wenn Sie nur den Upload-Schlüssel eintragen, funktionieren Passkeys in lokalen Builds, schlagen aber bei Installationen aus dem Store fehl. - Ob ein Passkey gültig ist, entscheidet der Server. Er prüft eine Einmal-Challenge, den Origin (einschließlich des Android-Origins
android:apk-key-hash:), den rpId-Hash und die Signatur, bevor er eine Session anlegt. - Synchronisierte Passkeys folgen dem Apple- oder Google-Konto der Nutzer, sodass die Sicherheit Ihrer Konten nun teilweise von deren Konten abhängt. Halten Sie einen Wiederherstellungsweg bereit, der nicht auf dem Passkey basiert.
Ist ein Passkey dasselbe wie biometrisches Entsperren?
Ein Passkey ist kein Entsperren per Fingerabdruck. Face ID oder ein Fingerabdruck geben dem Gerät lediglich die Nutzung eines Private Keys frei, der für eine bestimmte Domain erzeugt wurde und das Gerät bzw. den synchronisierten Schlüsselbund des Nutzers nie verlässt. Der Server hält den passenden Public Key und prüft eine Signatur. Biometrische Daten bekommt er nie zu sehen.
Lokales biometrisches Gating ist ein anderer Mechanismus. Dabei entsperrt die App ein zuvor gespeichertes Token, und der Server hat keinerlei kryptografischen Nachweis. Falls Sie genau das benötigen, beschreibt biometrische Authentifizierung in React Native die Einrichtung. Dieser Leitfaden wiederholt das nicht.
Plattformverknüpfung: Der Schritt, der lautlos scheitert
Die Relying Party ID (rpId), die Ihr Server sendet, muss genau die Domain sein, auf der beide Verknüpfungsdateien gehostet werden. Das Betriebssystem prüft beide Seiten unabhängig voneinander: Die App führt die Domain auf, und die Domain veröffentlicht eine Datei, die die App aufführt. Ist eine der beiden Seiten fehlerhaft, erscheint das Passkey-Sheet nie, und keine der beiden Plattformen teilt der App den Grund mit.
iOS: webcredentials-Entitlement und apple-app-site-association
Fügen Sie die Domain dem Associated Domains Entitlement mit dem Dienst webcredentials hinzu:
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:example.com</string>
</array>
Stellen Sie diese Datei ohne Dateiendung unter https://example.com/.well-known/apple-app-site-association bereit. Die App-ID besteht aus Ihrer Team-ID und der Bundle-ID, verbunden durch einen Punkt:
{
"webcredentials": {
"apps": ["ABCDE12345.com.example.shop"]
}
}
Laut Apples Leitfaden Supporting associated domains muss die Datei über HTTPS mit einem vertrauenswürdigen Zertifikat ausgeliefert werden, und die Anfrage darf nicht umgeleitet werden. Da die Datei keine Endung hat, liefern viele Static Hosts und Object Stores sie mit einem generischen Binär-Content-Type aus. Setzen Sie den Typ deshalb explizit:
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
Geräte rufen diese Datei nicht direkt von Ihrem Server ab. Sie lesen eine Kopie, die Apples CDN abruft und zwischenspeichert. Laut Apples Dokumentation fragt das CDN die Datei innerhalb von etwa 24 Stunden ab, und installierte Geräte prüfen ungefähr einmal pro Woche, ob eine aktualisierte Version vorliegt. Eine korrigierte Datei braucht daher Zeit, bis sie bei installierten Apps ankommt: Zuerst muss das CDN sie erneut abrufen, anschließend übernimmt jedes Gerät sie bei der Installation, beim Update oder bei der nächsten wöchentlichen Prüfung. War die Datei zum Zeitpunkt des CDN-Abrufs fehlerhaft, speichert das CDN auch dieses fehlerhafte Ergebnis zwischen. Verwenden Sie für die Entwicklung webcredentials:example.com?mode=developer und aktivieren Sie auf dem Gerät unter Einstellungen › Entwickler die Option „Associated Domains Development“. Das Gerät ruft die Datei dann direkt von Ihrer Domain ab. Entfernen Sie das Suffix vor dem Release, denn Release-Builds verlassen sich auf die CDN-Kopie.
Android: assetlinks.json mit get_login_creds
Unter Android benötigen Passkeys über den Credential Manager Digital Asset Links auf der rpId-Domain, bereitgestellt unter 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>"
]
}
}
]
Tragen Sie den Fingerprint jedes Keystores ein, mit dem Sie signieren:
keytool -list -v -keystore release.keystore -alias release | grep SHA256
Wird die App mit der Play App-Signatur ausgeliefert, signiert Google sie vor der Auslieferung neu. Maßgeblich für Store-Installationen ist dann der App-Signaturschlüssel, der in der Play Console auf der Seite „App-Signatur“ angezeigt wird. Wer nur den lokalen Upload-Schlüssel einträgt, erlebt genau das typische Bild: Passkeys funktionieren in Debug-Builds, schlagen aber bei Nutzern fehl, die die App aus dem Store installiert haben. Apps, die für Plays quantensichere hybride Signatur registriert sind, besitzen mehr als einen App-Signaturschlüssel. Fügen Sie daher jeden SHA-256-Fingerprint hinzu, den diese Seite anzeigt. Zusätzliche Fingerprints schaden nicht.
Vorabprüfungen
| Prüfung | Verifizierung | Symptom bei Fehler |
|---|---|---|
| Content-Type | curl -sI auf die AASA-URL | Kein Sheet unter iOS |
| Keine Weiterleitungen | curl -sI liefert 200, nicht 301/302 | Kein Sheet auf beiden Plattformen |
| Subdomain-Übereinstimmung | Dateien auf exakt dem rpId-Host bereitgestellt | Funktioniert im Web, scheitert in der App |
| Play-Signaturschlüssel | Abgleich mit der Seite „App-Signatur“ in der Play Console | Funktioniert im Debug-Build, scheitert bei Store-Installation |
| Apple-CDN-Kopie | Abfrage der unten genannten CDN-URL | Alte oder fehlerhafte Datei noch in Verwendung |
Subdomains erben keine Verknüpfung. Die Digital-Asset-Links-Spezifikation identifiziert eine Website über Schema, exakten Host und Port. Dateien unter www.example.com sind für eine rpId example.com also wirkungslos. Bei Apple gilt dieselbe Regel: Jede Subdomain benötigt ihre eigene apple-app-site-association-Datei.
Beide Dateien lassen sich prüfen, ohne einen Build zu installieren. Die erste URL liefert, was Apples CDN für Ihre Domain vorhält (Apple-Ingenieure nutzen sie zur Diagnose; es handelt sich nicht um eine dokumentierte API). Die zweite ruft Googles statements.list-Endpunkt auf:
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"
Eine fehlerhafte Verknüpfung löst keine Exception aus, die Ihr Crash-Reporter abfangen könnte. Session Replay macht das Muster sichtbar: ein Tippen auf „Mit Passkey anmelden“, kein System-Sheet, danach wechselt der Nutzer ins Passwortfeld. Diese Sessions können Sie anschließend nach Build und Signaturvariante filtern.
Passkeys implementieren: Registrierung
Die Passkey-Registrierung in einer nativen App besteht aus drei Teilen. Der Server erzeugt Optionen mit einer frischen Challenge und setzt rp.id auf die verknüpfte Domain. Die native API erstellt das Credential. Der Server verifiziert das Ergebnis und speichert Public Key und Credential-ID. Die Optionen des Servers sehen so aus:
{
"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": []
}
Unter iOS erstellt ASAuthorizationPlatformPublicKeyCredentialProvider die Anfrage:
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)
}
Fügen Sie unter Android androidx.credentials:credentials und androidx.credentials:credentials-play-services-auth hinzu. Letzteres ist unter Android 13 und älter erforderlich, wie der FIDO2-Migrationsleitfaden erläutert. Die aktuelle Version entnehmen Sie den Release Notes. Übergeben Sie das JSON des Servers anschließend direkt an CreatePublicKeyCredentialRequest:
val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
context = activity,
request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)
Der Server folgt dann der WebAuthn-Registrierungszeremonie. Er prüft Challenge, Origin und rpId-Hash und speichert anschließend Credential-ID, Public Key, Sign Count und User Handle. Android-Apps senden einen Origin der Form android:apk-key-hash:<encoded SHA 256 fingerprint>, und Ihr Server muss genau diesen Wert als zulässigen Origin akzeptieren. Hinterlegen Sie einen Origin pro Signaturzertifikat.
Anmeldung: Assertion und Serververifizierung
Die Passkey-Anmeldung folgt demselben Muster wie die Registrierung. Der Server stellt eine Challenge aus, das Gerät signiert sie mit dem Private Key, und der Server verifiziert die Signatur anhand des gespeicherten Public Keys. Die App entscheidet nie selbst, ob ein Passkey gültig ist.
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)
Gemäß den Schritten zur Assertion-Verifizierung führt der Server folgende Prüfungen durch:
- Er bestätigt, dass die Challenge einer von ihm ausgestellten entspricht und noch nicht verwendet wurde.
- Er prüft den Origin gegen die Allowlist, einschließlich aller Android-
apk-key-hash-Origins. - Er prüft, ob
rpIdHashdem SHA-256-Hash Ihrer rpId entspricht. - Er verlangt das User-Present-Flag und zusätzlich User-Verified, sofern Ihre Richtlinie dies vorschreibt.
- Er verifiziert die Signatur mit dem gespeicherten Public Key.
- Er wendet die Sign-Count-Regel an. Die Spezifikation vergleicht Zähler nur, wenn der gespeicherte oder empfangene Wert ungleich null ist – lehnen Sie einen Zählerstand von null also nicht ab.
Synchronisierung macht Passkeys zur Frage der Kontowiederherstellung
Synchronisierte Passkeys erscheinen auch auf Geräten, die sich nie bei Ihrer App angemeldet haben. Im Google Passwortmanager gespeicherte Passkeys folgen dem Google-Konto des Nutzers auf seine anderen angemeldeten Android-Geräte, und der iCloud-Schlüsselbund synchronisiert sie über die Apple-Geräte des Nutzers hinweg. Ein Nutzer kann sich ein neues Smartphone kaufen, Ihre App öffnen und sich sofort anmelden.
Die Passkey-Synchronisierung hat drei Konsequenzen für die Kontowiederherstellung:
- Telefon verloren, Konto nicht verloren. Geht ein Gerät verloren, steht der Passkey auf den anderen Geräten des Nutzers weiterhin zur Verfügung.
- Das Plattformkonto ist jetzt Teil Ihrer Sicherheitsgrenze. Wie sicher der Passkey für Ihre Domain ist, hängt vom Apple- oder Google-Konto des Nutzers ab, einschließlich dessen Zwei-Faktor- und Wiederherstellungsprüfungen.
- Nicht alle synchronisieren. Nutzer mit deaktivierter Schlüsselbund-Synchronisierung, einem Drittanbieter-Provider oder ohne Google-Konto auf dem Gerät können ihr einziges Credential trotzdem verlieren.
Erlauben Sie mehrere Passkeys pro Konto, bieten Sie eine Ansicht, in der Nutzer ihre Passkeys einsehen und widerrufen können, und behalten Sie einen Wiederherstellungsweg per E-Mail oder OTP bei. Fällt eine Anmeldung auf einem neuen Gerät trotz synchronisiertem Passkey auf das Passwort zurück, deutet das meist auf einen Fehler bei der rpId oder der Allowlist hin – nicht darauf, dass der Nutzer abgelehnt hat.
Fallback für ältere Geräte und Verweigerer
Behalten Sie zum Launch die Anmeldung per Passwort oder OTP parallel zu Passkeys bei. Native Passkeys unter Android erfordern Android 9 (API-Level 28) oder höher, und für die Nutzung eines Passkey-Providers von Drittanbietern ist Android 14 nötig. Manche Nutzer lehnen den Dialog ab, ganz gleich, welches Gerät sie verwenden. Behandeln Sie NoCredentialException und ASAuthorizationError.canceled, indem Sie das bestehende Login-Formular anzeigen – nicht eine Fehlermeldung.
Bieten Sie die Passkey-Erstellung nach einer erfolgreichen Passwortanmeldung an, statt sie bei der Registrierung zu erzwingen. Betrachten Sie den älteren Anmeldeweg als dauerhaft, bis Ihre Daten zeigen, dass er zurückgestuft werden kann. So funktioniert passwortloses Login erklärt, warum Passkeys robuster sind als Magic Links und OTPs. Für React-Native- und Flutter-Apps gelten dieselben Regeln: Jede Bridge-Bibliothek, für die Sie sich entscheiden, ruft genau diese nativen APIs auf, sodass sich an den Verknüpfungsdateien und Serverprüfungen nichts ändert.
Fazit
Die meisten Passkey-Bugs sind Konfigurationsfehler, keine Codefehler. Legen Sie vor jeglichem nativen Code die rpId fest und hosten Sie beide Verknüpfungsdateien auf exakt dieser Domain. Prüfen Sie sie mit den beiden curl-Validatoren und hinterlegen Sie den Fingerprint der Play App-Signatur sowie den Android-Origin auf dem Server. Sobald beide Validatoren Ihre App zurückliefern, sind die oben gezeigten Swift- und Kotlin-Aufrufe der gesamte Client-Code, den Sie benötigen.
FAQs
Welche iOS-Mindestversion benötigen Passkeys in einer nativen App?
Synchronisierte Passkeys erfordern iOS 16 oder neuer – die Version, mit der Apple im iCloud-Schlüsselbund gespeicherte Passkeys eingeführt hat. Credentials, die unter iOS 15 oder älter mit dem integrierten Plattform-Authenticator erstellt wurden, werden nie zu Passkeys und verbleiben auf dem Gerät, auf dem sie erzeugt wurden. Um ein solches Credential zu aktualisieren, registrieren Sie ein neues Credential mit demselben User Handle; iOS ersetzt das alte dann durch einen synchronisierten Passkey. Unter Android liegt die Untergrenze bei Android 9 (API-Level 28).
Können sich Nutzer in der App mit einem Passkey anmelden, den sie auf meiner Website erstellt haben?
Ja, sofern Website und App dieselbe rpId verwenden. Ein Passkey ist an die rpId gebunden, nicht an den Client, sodass ein einziges Credential im Browser und in beiden nativen Apps funktioniert. Native iOS-Apps melden als Origin https:// plus rpId – denselben Wert, den auch die Website sendet. Android-Apps melden einen android:apk-key-hash-Origin, den der Server separat in seine Allowlist aufnehmen muss.
Kann ich Passkeys im Android-Emulator und im iOS-Simulator testen?
Unter Android ja. Verwenden Sie ein Emulator-Image mit Google Play (nicht reines AOSP) ab API-Level 28, melden Sie sich mit einem Google-Konto an und richten Sie eine Displaysperre ein, da der Google Passwortmanager den Passkey speichert. Fehlen diese Voraussetzungen, schlägt die Credential-Erstellung mit einer Exception fehl, die wie ein App-Bug aussieht. Der iOS-Simulator kann sich anders verhalten als echte Hardware, führen Sie abschließende Passkey-Tests daher auf einem physischen iPhone durch.
Welche Fehler deuten auf eine fehlerhafte Verknüpfung statt auf einen Codefehler hin?
Unter Android bedeutet eine CreatePublicKeyCredentialDomException während der Registrierung meist, dass assetlinks.json fehlt oder nicht erreichbar ist, den falschen Paketnamen oder Fingerprint enthält oder auf einem Host liegt, der nicht zur rpId passt. Unter iOS äußert sich eine fehlerhafte Verknüpfung häufig als ASAuthorizationError mit Code 1001 – demselben Code, den auch ein Abbruch durch den Nutzer erzeugt. Eine GetCredentialCancellationException unter Android bedeutet, dass der Nutzer das Sheet geschlossen hat; eine Fehlermeldung ist dann nicht nötig.