Passkeys
This pack adds passkeys to your API as JSON endpoints, as a way to sign in without a password. It fits the guard your app authenticates with, access tokens or session, and hands you a brief for the frontend half.
- A passkey sign-in returns the same credential as your password login, and the device picks the account, 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 login response carries the signals your frontend passes to the browser to keep the user's password manager in sync.
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.
-
Discover your default guard, access tokens or session, so a passkey sign-in finishes on the credential you already issue.
-
Ask you for your app name and your frontend's passkey domain and origins for each environment, offering defaults from your config.
-
Land the backend as shipped, from the migrations and model mixin through the transformer, validators, controllers, routes, and the change-notification mailer.
-
Add the passkey signals to your login response, and to every other action that completes a login.
-
Run the tests, drive the endpoints, and hand you a brief for the frontend to build against.
Apply the passkeys pack to your API by hand by working through the steps below in order. It builds on the auth pack, adding passkey registration, management, and sign-in as JSON endpoints that fit the guard your app already authenticates with, and hands off a brief for your frontend at the end.
-
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
withManagedEmail(), so@adonisplus/personais installed. - You have a login controller that verifies credentials and issues your guard's credential with a
status: 'authenticated'body. - The authenticated
accountgroup (undermiddleware.auth()) and the publicauthgroup with login and signup are registered. 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 and which guard you authenticate with. This is the shape of an apply onto the API 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
Your frontend's domain, which 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 frontend 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 and returns the same credential as the password login. It does not go through a second step, even when the account has two-factor authentication turned on.
One passkey per device
The registration options list the user's existing passkeys, so a device that already holds one for the account refuses to create another. A passkey already registered to any account answers 422.
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 answers 400 with the same Unable to verify the passkey. Please try again message, whatever the cause.
Every sign-in returns passkey signals
Every login response, with a passkey or a password, carries passkeySignals: which of the user's passkeys your app still accepts, and their current name and email. Your frontend passes each one to the browser, and password managers that support these signals remove passkeys the user deleted and update the account details they show. The array is empty for a user without passkeys.
Signing in with a passkey your app no longer has fails with a 400 whose body also carries passkeySignals, which 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.