Most Flutter apps we are asked to review still authenticate the same way they did in 2019: an email field, a password field, a "forgot password" link, and an SMS one-time code bolted on as second factor. That stack now costs real money — SMS fees, support tickets for resets, and account takeovers from credential stuffing — and both platforms have spent the last two years making the alternative the default path. Android ships Credential Manager as the only supported sign-in surface for new apps, iOS 16+ exposes passkeys through AuthenticationServices, and password managers on every major platform now create passkeys silently when a site or app asks for one.
This tutorial builds passwordless sign-in in a Flutter app end to end: server ceremonies, associated domains, the Dart side, the migration path for existing password users, and the failure modes that only show up on real devices.
What a passkey actually is
A passkey is a WebAuthn credential: a public/private keypair bound to your app's domain, with the private key held by the platform keychain (iCloud Keychain, Google Password Manager, 1Password, Bitwarden) and released only after a local biometric or device-PIN check.
Two consequences matter for architecture:
- The server never stores a secret. It stores a public key and a credential ID. A database leak yields nothing replayable.
- The credential is bound to your domain. Phishing stops working, but it also means your app cannot authenticate until
apple-app-site-associationandassetlinks.jsonare correct. Domain configuration is not a deployment detail here; it is part of the auth system.
There are two ceremonies. Registration (attestation) creates a keypair and sends the public key to your server. Authentication (assertion) signs a server challenge with the private key. Both are challenge-response, so both require a server round trip before the platform UI opens.
Package choice
dependencies:
passkeys: ^2.4.0 # Corbado's plugin: Credential Manager + ASAuthorization
flutter_secure_storage: ^9.2.2
http: ^1.2.2
passkeys wraps Android Credential Manager and iOS ASAuthorizationPlatformPublicKeyCredentialProvider behind one Dart API, which is why we reach for it over hand-written platform channels on most engagements. If you are already on Firebase Auth, Supabase, Auth0, or Clerk, check their passkey support first — a hosted relying party saves you the trickiest server code. The Dart flow below is the same either way.
Android needs a minimum SDK of 21 for Credential Manager and 28+ for a good experience; iOS needs 16.0. Set them before you start or the build fails in confusing ways.
Step 1: associated domains (do this first)
iOS, in Runner/Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:auth.example.com</string>
</array>
Serve https://auth.example.com/.well-known/apple-app-site-association with Content-Type: application/json, no redirects, no auth:
{ "webcredentials": { "apps": ["TEAMID.com.example.app"] } }
Android, https://auth.example.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}]
The fingerprint trap that burns a day on nearly every project: list the SHA-256 of every signing key that reaches a device — debug keystore, each developer's local keystore, the CI upload key, and the Play App Signing key that Google generates for you. A build signed by a key missing from assetlinks.json throws a generic "no credentials available" error with no hint about why.
Cache these files aggressively at the CDN but verify with curl from outside your VPN after each deploy. Both platforms fetch them through their own CDNs, so a file that is only reachable internally looks absent.
Step 2: server ceremonies
Use a maintained WebAuthn library — SimpleWebAuthn (Node), webauthn4j (Java), py_webauthn (Python), go-webauthn. Do not implement CBOR parsing yourself.
Four endpoints:
POST /passkeys/register/begin -> creation options + challenge
POST /passkeys/register/finish -> verify attestation, store public key
POST /passkeys/login/begin -> request options + challenge
POST /passkeys/login/finish -> verify assertion, issue session tokens
Registration options, with the settings that matter:
{
"rp": { "id": "auth.example.com", "name": "Example" },
"user": { "id": "<base64url random, not the email>", "name": "ada@example.com", "displayName": "Ada" },
"challenge": "<base64url 32 random bytes>",
"pubKeyCredParams": [{ "alg": -7, "type": "public-key" }, { "alg": -257, "type": "public-key" }],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "required"
},
"excludeCredentials": [{ "id": "<existing cred id>", "type": "public-key" }],
"timeout": 60000
}
rp.idmust match the associated domain exactly.auth.example.comandexample.comare different relying parties and credentials do not transfer between them. Pick one at the start of the project and treat it as permanent — changing it invalidates every credential your users have created.residentKey: requiredgives you discoverable credentials, which is what makes "sign in" work without the user typing an identifier first.excludeCredentialsprevents a user from stacking three passkeys on the same device by tapping the button three times.- Store the challenge server-side against a short TTL (60s) and delete it on use. A challenge that can be replayed is the whole vulnerability.
On finish, verify origin, RP ID hash, user verification flag, and signature; then persist credential ID, public key, transports, AAGUID, and the signature counter.
Step 3: the Dart side
// lib/auth/passkey_service.dart
import 'package:passkeys/authenticator.dart';
import 'package:passkeys/types.dart';
class PasskeyService {
PasskeyService(this._api);
final AuthApi _api;
final _authenticator = PasskeyAuthenticator();
Future<void> register({required String email}) async {
final begin = await _api.registerBegin(email); // server challenge
final res = await _authenticator.register(
RegisterRequestType(
challenge: begin.challenge,
relyingParty: RelyingPartyType(id: begin.rpId, name: begin.rpName),
user: UserType(
displayName: begin.displayName,
name: email,
id: begin.userHandle,
),
authSelectionType: AuthenticatorSelectionType(
requireResidentKey: true,
residentKey: 'required',
userVerification: 'required',
),
pubKeyCredParams: begin.pubKeyCredParams,
excludeCredentials: begin.excludeCredentials,
timeout: 60000,
),
);
await _api.registerFinish(
id: res.id,
rawId: res.rawId,
clientDataJSON: res.clientDataJSON,
attestationObject: res.attestationObject,
);
}
Future<Session> signIn() async {
final begin = await _api.loginBegin(); // no identifier needed
final res = await _authenticator.authenticate(
AuthenticateRequestType(
relyingPartyId: begin.rpId,
challenge: begin.challenge,
timeout: 60000,
userVerification: 'required',
mediation: MediationType.Optional,
),
);
return _api.loginFinish(
id: res.id,
rawId: res.rawId,
clientDataJSON: res.clientDataJSON,
authenticatorData: res.authenticatorData,
signature: res.signature,
userHandle: res.userHandle,
);
}
}
Three notes from production:
Never pre-warm the ceremony. The challenge has a TTL of seconds and the platform sheet must open in response to a user gesture. Fetching options on screen build and calling the authenticator later produces expired-challenge errors that look random.
Handle cancellation as a normal outcome, not an error. Users dismiss the sheet constantly. Catch it, log it as a funnel metric, leave the screen exactly as it was:
try {
await service.signIn();
} on PasskeyAuthCancelledException {
// user dismissed: no snackbar, no error state
} on PasskeyAuthNoCredentialException {
setState(() => _showFallback = true); // nothing on this device yet
} on PasskeyAuthUnsupportedException {
setState(() => _showFallback = true); // OS too old, or no screen lock set
}
mediation: MediationType.Conditional enables autofill-style sign-in: the passkey appears above the keyboard when the user focuses the email field. It is a measurable conversion win on Android 14+ and iOS 17+, but it must be started when the field mounts and cancelled when the screen disposes, or you leave a dangling request that blocks the next explicit call.
Step 4: migration, not big bang
Nobody flips an existing user base to passkeys in one release. The sequence that works:
- Ship silently. Passkey login enabled, password login untouched, no prompts. Watch error rates by OS version for a week.
- Upgrade after success. Immediately after a successful password login, offer "Use Face ID next time?" once. Register a passkey in the background and remember the dismissal so you never ask twice.
- Promote at the top. Once adoption clears roughly a third of active users, make the passkey button primary and collapse password login under "Other ways to sign in".
- Stop creating passwords. New sign-ups get a passkey plus an email-link fallback. The password column stops growing.
- Retire. Only after a long tail — usually a year — and never without a working account-recovery route.
Keep an explicit credential_type on the session so you can answer "how did this user authenticate?" in support tooling and step up for sensitive actions.
Recovery is the hard part
A passkey lives in a platform keychain. Users switch from iPhone to Android, lose a device, or use a managed device where sync is disabled by policy. If your only credential is a passkey and it is gone, you have locked a paying customer out.
What we recommend on client projects:
- Two credentials minimum. Prompt for a second passkey on a second device, or keep an email magic link as a permanent secondary path.
- Email/OTP recovery that can also mint a new passkey, so recovery ends in a stronger state rather than a fallback the user keeps re-using.
- Cross-device authentication (the QR/hybrid flow) works out of the box on both platforms and covers "new laptop, phone in pocket". Test it — it is the single most-forgotten path.
- Rate-limit and alert on recovery. Recovery is now the weakest link, so instrument it like one: velocity limits, device-change notification emails, and a step-up for changing the registered email.
Testing
Emulators can do passkeys, but only with setup. Android emulators need Play Store images, a screen lock, and a Google account signed in; iOS simulators need a passkey created in Settings first. Both will happily report "no credentials" for environment reasons and send you hunting a code bug that isn't there.
- Unit-test the server ceremonies with fixture attestation and assertion blobs from your WebAuthn library's test suite: challenge reuse rejected, wrong origin rejected, wrong RP ID hash rejected, counter regression flagged.
- Widget-test the Dart layer behind an interface so cancel / no-credential / unsupported / timeout each have a test.
- Keep a device matrix for the manual pass: an iPhone on iOS 16 and current iOS, an Android 13 device, an Android 15 device, one device with a third-party password manager as credential provider, and one with no screen lock at all.
- Add an
assetlinks.json/ AASA smoke check to CI that fetches both files from the production host and asserts the current signing fingerprints are present. This catches the most common production outage in this whole feature.
Metrics that tell you whether it worked
Instrument the funnel, not just the outcome: sheet opened, credential returned, server verification succeeded, session issued — plus cancellation rate, and time from tap to session. The numbers to expect from a healthy rollout are a sign-in time under three seconds, a cancellation rate that falls after the first week as users learn the sheet, and a password-reset volume that drops as adoption climbs. If reset volume doesn't move, your upgrade prompt is in the wrong place.
When not to do this
Passkeys are the right default for consumer apps with a real sign-in funnel. They are a poor fit when your app authenticates against an enterprise IdP that owns the session anyway (use OIDC and let the IdP handle passkeys), when your user base is on shared or kiosk devices with no per-user keychain, or when your security model genuinely requires hardware attestation of a specific enrolled key — that is a different, stricter build.
Planning a passwordless rollout, or stuck debugging "no credentials available" on a Flutter app? Get in touch — AviaryApps supplies senior Flutter consultants who have shipped passkey authentication, associated-domain configuration, and password migrations for production apps, as a delivery team or alongside your own developers.