Passkeys
This pack adds passkeys to your app as a way to sign in without a password. Signed-in users add passkeys from their security settings, and the login page gains a "Sign in with a passkey" button.
- The device picks the account at sign-in, so there is no email to type.
- A passkey sign-in completes the login on its own, since the device already checked the user's fingerprint, face, or screen lock.
- New passkeys are named after the password manager or device that holds them, such as iCloud Keychain, and users can rename or remove them.
- The account owner gets an email whenever a passkey is added or removed.
- Every sign-in keeps the user's password manager in sync, so a passkey removed in your app disappears from their devices.
Apply the passkeys pack with Flow. Start a new session in your selected coding agent and execute the following slash command inside it.
After applying, Flow will make the following changes to your app.
-
Ask you for your app name and the passkey domain and origins for each environment, offering defaults from your config.
-
Establish a Security page to host the passkeys section, wherever your app is missing one.
-
Land the backend as shipped, from the migrations and model mixin through the validators, controllers, routes, and the change-notification mailer.
-
Add the passkey signals to your login and every other action that completes a login, and share them with the frontend through your Inertia middleware.
-
Restyle the passkeys section and the sign-in button to your design system.
-
Run the tests, then walk adding, renaming, and removing a passkey and signing in with it, and show you the result.
Apply the passkeys pack to your app by hand by working through the steps below in order. Each one builds on the last, taking your app from password-only sign-in to passkey registration, management, and sign-in.
-
Confirm your auth foundation
This pack extends the auth pack. Confirm all of the following before continuing, and apply the auth pack first if any is missing.
- Your User model composes
withAuthFinder(...)andwithManagedEmail(), so@adonisplus/personais installed. SessionController.storeverifies credentials and logs the user in withauth.use('web').- A web session guard and the
guestandauthmiddleware are registered, and thesession.createroute resolves. resources/views/components/email_layout.edgeexists.database/factories/user_factory.tsexportsUserFactorywith averifiedstate.
- Your User model composes
Flow adapts the pack to your app, so the exact set of created and edited files depends on what you already have. This is the shape of an apply onto the Vue starter kit with the auth pack in place.
Configuration
The withPasskeyManagement() mixin on the User model backs the whole feature. All three options are required.
rpName
The name the browser and password manager show when the user creates a passkey, normally your application or company name.
rpId
The domain passkeys are bound to. A passkey created for example.com works on example.com and all its subdomains. Read from the PASSKEY_RP_ID environment variable.
origins
The origins (scheme, host, and port) allowed to create passkeys and sign in with them. Each one must be the rpId domain or a subdomain of it. Read from the comma-separated PASSKEY_ORIGINS environment variable.
withPasskeyManagement({
rpName: 'ACME',
rpId: env.get('PASSKEY_RP_ID'),
origins: env.get('PASSKEY_ORIGINS').split(','),
})
The DbPasskeysProvider stores passkeys and challenges. Pass an options object as the second argument of forModel to change its defaults.
passkeysTable
The table passkeys are stored in. Defaults to passkeys.
challengesTable
The table challenges are stored in. Defaults to passkey_challenges.
challengeTtl
How long a challenge stays valid, in seconds or as a time expression. Defaults to 5 minutes.
static passkeys = DbPasskeysProvider.forModel(User, { challengeTtl: '2 minutes' })
Every passkey is bound to the PASSKEY_RP_ID it was created under. Changing it later makes every registered passkey stop working. Pick the final domain before users add passkeys, and use the parent domain (example.com rather than app.example.com) if passkeys must work across subdomains.
Passkeys are named after their authenticator
A new passkey takes the name of the password manager or device that created it, such as iCloud Keychain or Google Password Manager, and falls back to Passkey for an unknown one. When the user already has a passkey with that name, a number is appended (Passkey 2). Override getDefaultPasskeyName on the User model to name them differently.
getDefaultPasskeyName(aaguid: string, existingNames: string[]) {
return `Passkey ${existingNames.length + 1}`
}
The account picker shows the user's email and name
When the user creates or picks a passkey, the device shows the account name and display name stored inside it. getPasskeyUserDetails on the User model sets both. Change it to show a username or another field.
getPasskeyUserDetails() {
return { name: this.email, displayName: this.fullName ?? this.email }
}
A passkey sign-in completes the login on its own
A passkey already combines the device with the user's fingerprint, face, or screen lock, so it counts as a full login. It does not go through a second step, even when the account has two-factor authentication turned on.
One passkey per device
Adding a passkey from a device that already holds one for the account shows This device already has a passkey for your account. A passkey already registered to another account is refused too.
Challenges are single-use
Every passkey request starts with a challenge the device signs. A challenge works once and expires after five minutes, so a captured response cannot be replayed. A registration challenge is bound to the signed-in user, and a sign-in challenge cannot complete a registration.
Challenge requests are rate limited
Each challenge is a database row, so both challenge endpoints allow 10 requests a minute and respond with a 429 after that. The registration limit is per user and the sign-in limit is per IP.
await limiter
.use({ requests: 10, duration: '1 minute' })
.consume(`passkey_login_challenges_${request.ip()}`)
Failed sign-ins share one message
Every failed passkey sign-in shows the same Unable to verify the passkey. Please try again message, whatever the cause. A cancelled device prompt shows nothing.
Every sign-in re-syncs the password manager
After any sign-in, with a passkey or a password, the page tells the browser which of the user's passkeys your app still accepts and their current name and email. Password managers that support these signals remove passkeys the user deleted in your app and update the account details they show.
Signing in with a passkey your app no longer has fails, and also tells the password manager to delete that passkey from the device.
Adding or removing a passkey emails the owner
A passkey keeps working after a password reset, so this email is often the only way an owner notices a passkey added by someone who took over their session. The email says so and asks them to remove any passkey they do not recognize. Renaming a passkey sends nothing.
1.0.0
Initial release.