May 29, 2025 - 8 min

Your Guide on How to Implement Passkeys in Flutter


				LukaZanki
				

Luka Zanki

Mobile Engineer

hero image for the blog post titled 'Your Guide to Implementing Passkeys in Flutter' showing a graphic of a fingerprint

A practical guide to implementing passkeys in Flutter apps using the passkeys library and following WebAuthn standards for secure, passwordless logins.


Passkeys are an innovative approach to authentication, replacing traditional passwords with public-private key cryptography. These keys are often generated and managed using biometrics, such as fingerprint or facial recognition, making the process both secure and seamless.


This approach not only simplifies the registration and login process but also enhances overall security. However, from a development perspective, implementing passkeys comes with its own set of challenges and considerations. Some aspects can be unclear or tricky, which we aim to address in this article.


Implementing Passkeys in Flutter


To simplify the implementation of passkeys in Flutter we can use the passkeys library. The library will help with the platform specific code that is required to use passkeys. It offers support for Android, iOS and Web. The library can be used together with the Corbado server (an already implemented relying party server), however, there might be reasons you want to build your own relying party server.


Advantages of having your own relying party server are reduced pricing, users are stored on your server, no need for user migration if you already have existing users, and more control over the flow. There are two choices here for implementing the relying party server: WebAuthn or FIDO2 standard.


Registration and Authentication can be exposed with the endpoints, which we will cover later. These endpoints should provide values that the passkeys library can use for native calls (creating and registering passkeys). It can be a little challenging syncing the two parts, and that is exactly the motivation for this article.


Note that the library doesn’t require min SDK versions, but the passkey support starts in: Android 28 and iOS 16. To keep the flow user friendly the passkey UI and feature should be hidden on unsupported SDKs. The library also offers canAuthenticate method for easier implementation of the said flow. The web is a bit different, and not all devices there support biometrics, however there are other options of keeping a cross-platform passkey like the USB security key.


Let’s jump to the basics now. Usually on Frontend the passkey creation and login with passkey is done via three steps:



  1. Begin – server call

  2. Registration/Login – native call – in our case: library call

  3. Finish – server call


We will cover all three steps in more detail. An important note is that passkeys use cryptography to enable login with passkeys.


A private key is created and stored on the device, and it can be synced across different devices. Deleting the keys cannot be done within an app, it is managed by credential managers. The user has a choice to pick this manager when creating the passkey.


On the other hand, a public key is stored on the server. Also the server is the one that should create challenges for the frontend with which it can create private keys. This is done with the first step.


How to start creating passkeys in Flutter? Step 1: Begin


This is the first step for creating a passkey or login with a passkey. Following WebAuthn standard this step is a simple GET request to the server with the url something like: https://base_url/passkeys/reg/begin. The result of this method is used to create a passkey. The response can be customized but the most important part of the response is the challenge. There should also be a Relying party id. This will protect your app from phishing attacks.


Relying party response should contain the name and id. Name is the name of the app and isn’t all that important. However the id is the domain of your app. It is important to set this up correctly. You can choose the domain or the subdomain but the /.well-known/ folder with android and apple files must be stored at the exact url in the id.


So, for example if you have dev.example.com and example.com, it is much better to make the relying party id as domain, example.com, and host files at “example.com/.well-known/assetlinks.json” and “example.com/.well-known/apple-app-site-association”. This way there is no need to change the relying party id based on the environment, and no need to host the/.well-known folder on subdomains. The well-known folder works for mobile apps, but for the web app it is important that the relying party id is a domain or subdomain of the web app.


To summarize, the relying party id should be the domain of your frontend url, and that is where the/.well-known folder should be hosted. This works only if your dev and staging environments are subdomains of your production environment of course.


You should also provide Authenticator Selection Type. These values can be provided from the server as well. It is recommended to include all values, because otherwise it might not work on all platforms.


// Set values for all fields, or errors may be encountered on some platforms
final authenticatorSelectionType = AuthenticatorSelectionType(
requireResidentKey: false, // Example setting
residentKey: ResidentKeyRequirement.required,
authenticatorAttachment: AuthenticatorAttachment.platform,
userVerification: UserVerificationRequirement.preferred,
);

How you set these settings defines how the authenticator behaves. For example if we set authenticator attachment to ‘platform’ it means we are using the device specific authenticators, like fingerprint or face ID. This also makes the created passkey bound to the device, and cannot be used on other platforms. There is also a ‘cross-platform’ option here for using the key across different devices, but we will not focus on this here.


The resident key is about creating a resident or a non-resident key. We leave this as ‘preferred’ and this means if possible it will create a resident key and store it in the authenticator, but if it fails it won’t be stored in the authenticator and in turn will create a non-resident key.


If you want only resident keys then ‘required’ might be a better option. The boolean value about requiring a resident key is just backwards compatibility with ‘WebAuthn’ level 1.


We can keep the user verification to the standard ‘preferred’ meaning the user should authenticate but can proceed without it. Having the user authenticated before the passkey creation is a good way to enhance security and protect from unauthorised access.


Source and more information for AuthenticatorSelectionType can be found here.


Step 2.1: Register


Using the values from Step 1 we can now create the passkey in Flutter. A passkey can be created for the existing user that maybe uses a different sign in method, or a new user can be registered directly with the passkey. The latter means that the user is only using passkey, and this is preferred for all users, as this makes the safest method of sign in.


Regardless, the creation process is the same. In the following code snippet the Flutter passkey is created for the new user. Most of the random values can be retrieved from the server after the initial /begin call.


final user = UserType(
id: "uuid" // Example user ID
name: "john_doe", // Example username
displayName: "John Doe", // Example display name
);

final rp = RelyingPartyType(
name: "Example App Name", // Example Relying Party name
id: "example.com", // Example domain
);

// Set values for all fields, or errors may be encountered on some platforms
final authenticatorSelectionType = AuthenticatorSelectionType(
requireResidentKey: false, // Example setting
residentKey: ResidentKeyRequirement.required,
authenticatorAttachment: AuthenticatorAttachment.platform,
userVerification: UserVerificationRequirement.preferred,
);

// Supported public key algorithms for credential creation
final pubKeyCred = [
PubKeyCredParamType(type: "public-key", alg: -7), // ES256 Algorithm
PubKeyCredParamType(type: "public-key", alg: -257), // RS256 Algorithm
];

final challenge = "dGhpcy1pcy1hbi1leGFtcGxlLWNoYWxsZW5nZQ"; // Example challenge

final registerRequestType = RegisterRequestType(
challenge: challenge,
relyingParty: rp,
user: user,
authSelectionType: authenticatorSelectionType,
excludeCredentials: [],
timeout: 60000, // 60 seconds timeout
pubKeyCredParams: pubKeyCred,
);

final RegisterResponseType platformRes =
await passkeyAuthenticator.register(registerRequestType);

There is quite a lot of setup here, however most of the data can be retrieved in the response from the server /begin call.


The server provides Relying party data and challenge, but note that challenge needs to be base64url encoded before passing it to the library. It is ok if the server already sends it encoded but this should be checked to avoid unnecessary errors.


It is also recommended that the server provides the following values as well: User, authentication selection, public key credentials and exclude credentials. Otherwise these values can be provided in the frontend.


Frontend specifies timeout and some parts of authenticator selection if they are not present in the server. This was already explained in detail in the previous step, just make sure to include all values here to avoid issues on different platforms.


Step 2.2: Login


Similarly to registration, we can simply replace this step for authentication. We call /begin again, and the data we need here is relying party id and challenge. For login the begin call can start with auth/begin to separate it from the registration. With that information from the server you can proceed to authenticate with the native call to the library by creating AuthenticateRequestType and providing it to the PasskeyAuthenticator authenticate method. If this completes successfully the response can be used to send to the server to login successfully. Here is an example with random values:


final rp = RelyingPartyType(
name: "Example App Name", // Example Relying Party name
id: "example.com", // Example domain
);

final challenge = "dGhpcy1pcy1hbi1leGFtcGxlLWNoYWxsZW5nZQ"; // Example challenge

final authenticateRequestType = AuthenticateRequestType(
relyingPartyId: rp.id,
challenge: challenge,
userVerification: 'preferred',
mediation: MediationType.Optional,
preferImmediatelyAvailableCredentials: false,
);

final platformRes =
await passkeyAuthenticator.authenticate(authenticateRequestType);

Step 3: Finish


Finally we finish the process with a POST /finish call to the server where we send signed challenge and other metadata. This can also be a separate call for login and registration but the data is very similar<. The server uses this information to verify the authenticity of the Flutter passkey process. If everything checks out, the server either registers the user’s public key (in case of registration), or logs the user in (in case of login). Regardless you should be able to get a verification token from the response of the finish call. This way you can automatically authenticate the user. Here is the example request:


// Example of a finish contract to send to the server
final contract = PasskeyFinishContract(
id: platformRes.id,
rawId: platformRes.rawId,
authenticatorAttachment: 'platform',
attestationObject: platformRes.attestationObject,
clientDataJSON: platformRes.clientDataJSON,
// This part below is used for authentication only and can be omitted when registering a passkey
signature: platformRes.signature,
userHandle: platformRes.userHandle,
);

The Attestation and clientDataJson contain the most important information for both authentication and registration. The server will need the full values contained there, so make sure to send it. More on this topic can be found at the following links: attestation

and clientDataJson.


To identify the passkey both the id and raw id are provided as well to the server. This helps to match the server public key with the device’s private key. Since ‘WebAuthn standard’ uses both keys it’s best to send both of them to the server.


Additional details


To make the package work on the web application, a file must be included alongside the index.html. This file, bundle.js, is a JavaScript file that is used to integrate with WebAuth API on the Web Application. There are two ways of including this file, referencing it via the url as it is hosted on the package site, or self-hosting the file. I believe self-hosting is a better option, as you can stricten the safety rules regarding JavaScripts with CSP (content security policy).


Managing the Passkeys


Managing passkeys mostly happens on the native side and the keys are stored by the Credential Manager. When a user creates a passkey, the native system usually lets them pick which manager or account (like Google Password Manager or iCloud Keychain) will store the private key.


Important thing to know: once the passkey is created, your app won’t have direct access to it anymore. You can’t delete or manage the private key from inside the app — it has to be done manually by the user through the credential manager.


There are ways to rename Flutter passkeys from the manager side (this can also be done by labeling it on the server), but deleting is trickier. To properly remove a passkey, you should clean it up both in the manager and the server.


To delete it from the manager, you can only do this directly in the manager for safety reasons, and this requires user manual action. Deleting it on the server can be done through an endpoint call.


Conclusion


Passkeys are quickly becoming the new norm — some apps already made them the first thing you see when logging in, pushing passwords a click away. It’s a much smoother experience for users, and a lot more secure.


That said, setting it all up isn’t exactly simple. You need proper support both on the server and on the frontend side, and there are a lot of details you need to get right for it to work across platforms. Also the devices need to support some form of biometric authentication.


Passkeys definitely feel like the future, but we’re probably not making a complete switch from passwords just yet. For now, it’s best to offer both and start getting comfortable with the flow.


 


Give Kudos by sharing the post!

Share:

ABOUT AUTHOR
LukaZanki

Luka Zanki

Mobile Engineer

Luka is a Mobile Engineer who started his career as an Android Developer, but later also started working with Flutter and Kotlin Multiplatform technologies. In his spare time he likes to spend time with his wife and pets. He also likes movies, restaurants, video and board games, and short trips around the countryside.