Passkeys in .NET MAUI 10: iOS AuthenticationServices and Android Credential Manager (2026)
Add passkeys to .NET MAUI 10 using iOS AuthenticationServices and Android Credential Manager. Working C# handlers, association files, and a Fido2NetLib backend.
To add passkeys to a .NET MAUI 10 app you call ASAuthorizationPlatformPublicKeyCredentialProvider on iOS and androidx.credentials.CredentialManager on Android through platform-specific handlers, sign a server-issued challenge with the device's Secure Enclave or StrongBox, and verify the assertion server-side against a WebAuthn relying party. There is no cross-platform NuGet that hides both stacks yet, so you write a thin Xamarin.iOS binding and an AndroidX Credentials binding, then expose one IPasskeyService to your ViewModels.
Passkeys in .NET MAUI 10 are wrappers around two native APIs: Apple's AuthenticationServices (iOS 16+) and Google's Jetpack androidx.credentials (Android 9+). It isn't a MAUI feature.
You must host an apple-app-site-association file with the webcredentials service and an assetlinks.json with the get_login_creds relation before either platform will store a credential.
iOS 26 adds ASAuthorizationAccountCreationProvider and a new error 1010 ASAuthorizationErrorDeviceNotConfiguredForPasskeyCreation. Worth handling explicitly in your ViewModel state machine.
Android's Credential Manager bottom sheet is a single UI for passkeys, saved passwords, and Sign in with Google, which is why Google recommends adopting it even if you only need one method.
Passkeys still need a backend. Use Fido2NetLib or Duende's WebAuthn extension for the relying-party side; MAUI never sees the private key.
The private key never leaves the Secure Enclave or Android hardware-backed keystore, so passkeys survive phishing, replay, and server-side password dumps.
What passkeys actually are (and what they aren't)
A passkey is a discoverable WebAuthn credential. It's a public/private key pair scoped to a relying party ID (usually a domain like app.example.com), where the private half is generated and stored inside a hardware-backed authenticator and the public half is sent to your server on registration. When the user signs in later, the server sends a challenge, the authenticator asks the OS to unlock (Face ID, Touch ID, fingerprint, PIN), the OS signs the challenge with the private key, and the server verifies the signature against the stored public key. The user does not know a secret. There is no secret to phish.
What passkeys are not: they aren't "Face ID login". Biometric unlock protects the on-device credential; it is not the credential. And they aren't a MAUI feature. Microsoft has not shipped a Microsoft.Maui.Authentication.Passkey API and, based on the roadmap in dotnet/maui, has no plans to for .NET 10. So you're working directly against the platform. Honestly, that's fine. Both platform APIs (ASAuthorizationPlatformPublicKeyCredentialProvider and androidx.credentials.CredentialManager) have stabilised and have decent C# binding stories in 2026.
If you already ship OAuth via Sign in with Apple, Google, and Microsoft in .NET MAUI 10, passkeys sit next to those providers, not instead of them. Most teams add a passkey as a second, phishing-resistant option and keep social login as the primary onboarding path. In my last project, passkeys shone on return sign-ins, not first-time sign-ups.
How do you enable passkeys in a .NET MAUI app?
The end-to-end enablement is five moving parts, and skipping any one of them will surface as a confusing generic error like iOS's ASAuthorizationError.canceled (code 1001) when the real cause is a missing associated domain. In order:
Register a relying party ID on your backend and pick the domain. Usually this is the same hostname your API runs on (e.g. api.example.com or a dedicated auth.example.com).
Host the two association files, /.well-known/apple-app-site-association and /.well-known/assetlinks.json, at that domain over HTTPS with a trusted certificate.
Add the platform capabilities: Associated Domains entitlement with webcredentials:auth.example.com on iOS, and asset statements metadata in the Android manifest.
Call the platform passkey APIs from a MAUI service (IPasskeyService.RegisterAsync and SignInAsync) that internally dispatches to the iOS or Android implementation.
Verify the resulting attestation object and authenticator assertion on your backend using a WebAuthn library, then issue your normal session token.
Note that step 4 is the only piece that involves MAUI at all. Steps 1, 2, 3, and 5 are ordinary WebAuthn setup and would look identical if you were shipping native SwiftUI and Jetpack Compose apps. That's the mental model I want you leaving with: MAUI is transport. The interesting work happens on either side of it.
iOS: AuthenticationServices and the Secure Enclave
On iOS, everything routes through ASAuthorizationController. You build a request from ASAuthorizationPlatformPublicKeyCredentialProvider for a specific relying party, wrap it in a controller, set yourself as the delegate, and call performRequests(). The system takes over from there. It decides whether to show the Face ID sheet, the hybrid QR code for a nearby device, or the iCloud Keychain picker. You never touch the biometric prompt yourself.
Because Microsoft.iOS ships the AuthenticationServices bindings in the box, you don't need a custom binding project. You need a platform-specific handler in your Platforms/iOS folder. Here's the shape of a passkey registration on iOS 16+ (fully compatible with iOS 26's new APIs, which are additive):
// Platforms/iOS/Services/IosPasskeyService.cs
using AuthenticationServices;
using Foundation;
public sealed class IosPasskeyService : NSObject, IPasskeyService,
IASAuthorizationControllerDelegate,
IASAuthorizationControllerPresentationContextProviding
{
TaskCompletionSource<PasskeyResult>? _tcs;
public Task<PasskeyResult> RegisterAsync(PasskeyChallenge challenge)
{
_tcs = new TaskCompletionSource<PasskeyResult>();
var provider = new ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: challenge.RpId);
var request = provider.CreateCredentialRegistrationRequest(
challenge: NSData.FromArray(challenge.ChallengeBytes),
name: challenge.UserName,
userID: NSData.FromArray(challenge.UserIdBytes));
var controller = new ASAuthorizationController(new[] { request });
controller.Delegate = this;
controller.PresentationContextProvider = this;
controller.PerformRequests();
return _tcs.Task;
}
[Export("authorizationController:didCompleteWithAuthorization:")]
public void DidComplete(ASAuthorizationController controller,
ASAuthorization authorization)
{
if (authorization.GetCredential<ASAuthorizationPlatformPublicKeyCredentialRegistration>()
is { } reg)
{
_tcs?.TrySetResult(new PasskeyResult(
CredentialId: reg.CredentialID.ToArray(),
AttestationCbor: reg.RawAttestationObject!.ToArray(),
ClientDataJson: reg.RawClientDataJson.ToArray()));
}
}
[Export("authorizationController:didCompleteWithError:")]
public void DidComplete(ASAuthorizationController controller, NSError error)
{
// iOS 26 added 1010 = ASAuthorizationErrorDeviceNotConfiguredForPasskeyCreation
_tcs?.TrySetException(new PasskeyException((int)error.Code, error.LocalizedDescription));
}
// Presentation context: return the current active window.
public UIWindow GetPresentationAnchor(ASAuthorizationController controller) =>
UIApplication.SharedApplication.ConnectedScenes
.OfType<UIWindowScene>().First().KeyWindow!;
}
Two platform details worth calling out. First, the userID is opaque bytes, not a UTF-8 string. Apple recommends 64 random bytes generated server-side, not your email address or user GUID as text. This is how the same person can hold two independent passkeys on the same account across different environments. Second, on iOS 26 you should catch error code 1010 distinctly. It means the device has no iCloud account signed in or has Screen Time restrictions blocking passkey creation, and you should show a "Sign into iCloud in Settings" call to action rather than a generic retry.
Android: Jetpack Credential Manager and Play services
Android is the messier half. The public API is androidx.credentials.CredentialManager, a Jetpack library that runs on Android 4.4+ (with passkey support gated to Android 9+ and hardware-backed keys typically starting on Pixel and modern Samsung devices). It's not in the platform SDK, so you must bind it. As of .NET 10 the AndroidX Credentials NuGet ships as Xamarin.AndroidX.Credentials, updated to library version 1.5.x. Check dotnet/android-libraries for the current version before you pin it.
The Credential Manager API takes a JSON string in the WebAuthn format and returns another JSON string. Google chose this over strongly-typed classes so third-party credential providers could opt in without rewriting the whole SDK. That means the C# side is mostly plumbing:
// Platforms/Android/Services/AndroidPasskeyService.cs
using Android.Content;
using AndroidX.Credentials;
using AndroidX.Credentials.Exceptions;
using Newtonsoft.Json;
public sealed class AndroidPasskeyService(Context context) : IPasskeyService
{
readonly CredentialManager _cm = CredentialManager.Create(context);
public async Task<PasskeyResult> RegisterAsync(PasskeyChallenge challenge)
{
// Build the WebAuthn PublicKeyCredentialCreationOptions payload.
// Anything the server sent verbatim goes in: rp, user, pubKeyCredParams,
// challenge (base64url), authenticatorSelection, timeout, attestation.
var requestJson = JsonConvert.SerializeObject(new
{
challenge = Base64Url(challenge.ChallengeBytes),
rp = new { id = challenge.RpId, name = challenge.RpName },
user = new {
id = Base64Url(challenge.UserIdBytes),
name = challenge.UserName,
displayName = challenge.UserDisplayName
},
pubKeyCredParams = new[] { new { type = "public-key", alg = -7 } },
authenticatorSelection = new { residentKey = "required",
userVerification = "required" },
attestation = "none"
});
var request = new CreatePublicKeyCredentialRequest(requestJson);
var activity = Platform.CurrentActivity!;
try
{
var response = (CreatePublicKeyCredentialResponse)
await _cm.CreateCredentialAsync(activity, request);
// response.RegistrationResponseJson is a full WebAuthn attestation
// response, so hand it straight to your backend without repacking.
return PasskeyResult.FromJson(response.RegistrationResponseJson);
}
catch (CreateCredentialException ex)
{
throw new PasskeyException(ex.Type, ex.Message);
}
}
}
The exception hierarchy is where you spend the most time. CreateCredentialCancellationException means the user dismissed the sheet. CreateCredentialNoCreateOptionException means no credential provider on the device claimed the request (usually because your assetlinks.json is missing, or the SHA-256 fingerprint doesn't match your signing key). CreatePublicKeyCredentialDomException wraps a WebAuthn protocol error, and you should log the inner DOMException type. Don't collapse all three into "sign-in failed". The recovery paths are completely different.
How do you host apple-app-site-association and assetlinks.json for passkeys?
This is the step nobody documents well, and it's where most first-time passkey integrations die. Both files live under /.well-known/ on the relying-party domain, must be served over HTTPS with a valid certificate (self-signed will fail on Android), and must be reachable without any redirects. Google's Digital Asset Links crawler follows exactly zero hops. Content type also matters: Apple requires application/json, and Android doesn't care but expects UTF-8.
The iOS file at https://auth.example.com/.well-known/apple-app-site-association looks like this. Note that the webcredentials service is the passkey-relevant one; applinks is for Universal Links and can coexist:
Grab the SHA-256 fingerprint from Play Console under Release → Setup → App signing if you use Play App Signing (which you almost certainly do), not from your local upload keystore. Those are different keys, and the upload key hash will make debug builds work while production silently breaks. On the iOS side, add com.apple.developer.associated-domains to your entitlements file with the value webcredentials:auth.example.com, then rebuild. Apple caches AASA aggressively, so you can force a re-fetch on a device by deleting and re-installing the app, or by toggling airplane mode and back.
The relying party: Fido2NetLib on your backend
Your MAUI app can't verify passkeys itself, and it shouldn't. The whole security model rests on a server that stores the public key, generates challenges, and validates signatures. In .NET the mature option is Fido2NetLib, an open-source FIDO2/WebAuthn server that ships as a NuGet you add to your ASP.NET Core API. It handles the CBOR parsing, the attestation format zoo (packed, tpm, android-key, none), and the origin/rpId/challenge verification that you don't want to hand-roll.
The registration endpoint has two steps (one to hand out options, one to verify the client response), and the same for sign-in. Rough shape:
// Program.cs
builder.Services.AddFido2(o =>
{
o.ServerDomain = "auth.example.com";
o.ServerName = "Example";
o.Origins = new HashSet<string> { "https://auth.example.com" };
});
// Registration: step 1
app.MapPost("/passkey/register/options", async (HttpContext ctx, IFido2 fido2) =>
{
var user = new Fido2User {
Id = RandomNumberGenerator.GetBytes(64),
Name = "[email protected]",
DisplayName = "Ada Lovelace"
};
var opts = fido2.RequestNewCredential(user, new List<PublicKeyCredentialDescriptor>(),
new AuthenticatorSelection { UserVerification = UserVerificationRequirement.Required,
ResidentKey = ResidentKeyRequirement.Required },
AttestationConveyancePreference.None);
ctx.Session.SetString("passkey.opts", opts.ToJson());
return Results.Ok(opts);
});
// Registration: step 2
app.MapPost("/passkey/register/verify",
async (AuthenticatorAttestationRawResponse raw, HttpContext ctx, IFido2 fido2) =>
{
var opts = CredentialCreateOptions.FromJson(ctx.Session.GetString("passkey.opts"));
var result = await fido2.MakeNewCredentialAsync(raw, opts, IsUniqueCredentialId);
// persist result.Result.CredentialId + result.Result.PublicKey
return Results.Ok();
});
Store the credential ID and public key against your user record. On sign-in you swap RequestNewCredential for GetAssertionOptions and MakeNewCredentialAsync for MakeAssertionAsync. Signature counters, if the authenticator returns them, tell you about cloned credentials. But iCloud Keychain and Google Password Manager both zero them out for privacy, so treat a static counter as normal, not as an attack signal.
Wiring one IPasskeyService into your ViewModels
With both platform services written, expose them through DI so your MVVM code stays platform-clean. The pattern is the same one you probably use for biometrics in the token management guide:
// MauiProgram.cs
#if IOS
builder.Services.AddSingleton<IPasskeyService, IosPasskeyService>();
#elif ANDROID
builder.Services.AddSingleton<IPasskeyService>(sp =>
new AndroidPasskeyService(Android.App.Application.Context));
#endif
// SignInViewModel.cs
[ObservableProperty] partial string Status { get; set; } = "";
[RelayCommand]
async Task SignInWithPasskey()
{
try
{
var opts = await api.GetAssertionOptionsAsync();
var result = await passkeys.SignInAsync(opts);
var token = await api.VerifyAssertionAsync(result);
await tokens.SaveAsync(token);
await Shell.Current.GoToAsync("//home");
}
catch (PasskeyException ex) when (ex.Code == 1010)
{
Status = "Sign into iCloud in Settings to use passkeys on this device.";
}
catch (PasskeyException) { Status = "Sign-in was cancelled."; }
}
Consider also protecting the endpoint that hands out challenges with App Attest and Play Integrity so a scraper can't pull unlimited options and try to enumerate usernames.
Passkeys vs biometric login: what's the actual difference?
Dimension
Biometric unlock (Face ID / fingerprint)
Passkey (WebAuthn)
What it protects
An on-device secret (password, refresh token)
A hardware-bound private key that never leaves the device
Phishing-resistant
No; user can still enter the underlying secret on a fake site
Yes; bound to the relying party domain, cannot be replayed
Server-side breach exposure
Password hashes leak on breach
Only public keys leak; useless to attackers
Cross-device sync
Local only
Optional via iCloud Keychain / Google Password Manager
Cross-platform recovery
Manual re-enrolment
Hybrid QR flow to any other passkey device
Requires backend changes
No
Yes, a WebAuthn relying party
People blur the two because both flows show a Face ID or fingerprint sheet. The important difference is what the sheet is authorizing. In a biometric-protected password flow, the sheet unlocks a password you still send to the server. In a passkey flow, the sheet unlocks a private key that signs a server challenge and sends only a signature, a signature that the server can only accept if it was generated for that exact origin and challenge. You can't phish a signature that's never been produced.
Do passkeys work offline?
Passkey creation and usage on the device happen entirely offline. The biometric unlock, the key generation in the Secure Enclave or StrongBox, and the signature all execute without a network. Any online requirement is on your side of the flow. The device needs to reach your server to fetch the challenge before signing it, and to submit the signed assertion afterwards. If your MAUI app supports an offline mode via SQLite and sync, passkey sign-in isn't part of what you can do offline. But the on-device credential is preserved and works the moment connectivity comes back.
One important nuance: the first passkey use after install may require the associated-domains files to be re-fetched by the OS, and on iOS this fetch happens via Apple's CDN, not directly from your server. So a customer on captive-portal Wi-Fi may fail their first passkey attempt with a generic error. Warn the user, retry, and consider caching the "passkey available" state after first success.
Frequently Asked Questions
Do passkeys replace passwords entirely in a .NET MAUI app?
They can, but most teams ship them as an additive option next to existing password or social sign-in. The friction of the initial passkey enrolment, and the small population still on Android 8 or iOS 15, means a pure-passkey app will lock some users out. Add passkeys as the preferred second factor first, then move them to primary once adoption is above ~60%.
Can I use passkeys with Azure AD or Entra ID from a MAUI app?
Yes, via MSAL's broker on Android and iOS, which supports FIDO2 security keys and platform authenticators against Entra ID as of the 2025 releases. The user experience differs slightly (the passkey sheet is presented inside the broker's web view rather than the native Credential Manager UI), but no relying-party server work is needed on your side.
What Android version is required for the Credential Manager passkey flow?
The Jetpack androidx.credentials library targets Android 4.4 and higher, but the passkey path specifically requires Android 9 (API level 28) or higher and Google Play services 23.30 or later. Users on older devices will get a NoCreateOptionException, which you should fall back from to your password or social sign-in path.
Where is a passkey actually stored on the device?
On iOS, in iCloud Keychain by default, with the private key material generated and used inside the Secure Enclave. On Android, in Google Password Manager by default (or a third-party manager the user has enabled), with private keys inside the hardware-backed keystore where the device supports StrongBox. In both cases your app never has access to the private key; it only receives signed assertions.
Do I need attestation for consumer apps?
Almost never. Apple's iCloud-synced passkeys ship a zeroed-out AAGUID and no useful attestation statement by design, and Google follows a similar model for cross-device credentials. Set attestation = "none" on your registration options unless you have a regulatory requirement (banking, government) that specifically mandates hardware attestation, in which case you're also probably not shipping through the consumer app stores.
A working developer guide to CommunityToolkit MediaElement in .NET MAUI 10: install, XAML setup, HLS/DASH streaming, background audio, PiP, and fixes for the errors you will actually hit.
A practical .NET MAUI 10 OAuth walkthrough for Sign in with Apple, Google, and Microsoft, covering WebAuthenticator, MSAL.NET, PKCE, secure token storage, and the App Store review rules that block first submissions.
Wire in-app updates into .NET MAUI 10 with the Google Play In-App Update API on Android and iTunes Lookup on iOS. Includes flexible vs immediate flows, force-update via Firebase Remote Config, and Play internal-sharing tests.