Passkey management
This guide covers the withPasskeyManagement mixin from @adonisplus/persona/passkeys. You will learn how to:
- Apply the mixin and create the tables it needs
- Register passkeys for a signed-in user
- Sign users in with a passkey
- List, rename, and remove passkeys
- Keep password managers in sync using the Signal API
- Customize passkey names and account details
Overview
A passkey is a key pair created by the user's device (Touch ID, Windows Hello, a YubiKey) or their password manager (iCloud Keychain, Google Password Manager, 1Password). The private key never leaves the device. Your application stores only the public key and verifies signatures made with it. There is nothing to phish, reuse, or leak from your database.
Passkeys use the WebAuthn standard. Both registering a passkey and signing in with one follow the same three steps:
- The server issues a random challenge along with options for the browser.
- The browser asks the device to sign the challenge. The user confirms with a fingerprint, face scan, or device PIN.
- The server verifies the signed response against the challenge it issued.
Registration creates a new passkey. Login proves possession of an existing one. The withPasskeyManagement mixin implements both flows on top of
SimpleWebAuthn, and stores passkeys and challenges in two dedicated tables. The key invariants are:
- Challenges are single-use. Each challenge is stored in the database with an expiry and deleted in the same query that looks it up. Replaying a response, or submitting the same response twice in parallel, fails.
- Challenges are bound to a purpose and a user. A login challenge cannot complete a registration, and a registration challenge issued to one user cannot be used by another.
- User verification is required. Registration and login both require the device to verify the user (biometrics or PIN), not just their presence. A passkey therefore counts as a complete sign-in on its own.
- Usernameless sign-in. Passkeys are created as discoverable credentials, so the user clicks "Sign in with a passkey" without typing an email. The device lists the accounts it holds for your site.
The mixin covers passkeys as a sign-in alternative to passwords. Using passkeys as a second factor after a password is not covered by this module.
Relying party
WebAuthn calls your application the relying party. Passkeys are bound to its id (rpId), which is your domain. A passkey created for example.com works on example.com and its subdomains, and nowhere else. This is what makes passkeys phishing resistant, since a lookalike domain cannot use them.
Choose the rpId carefully. Changing it later makes every registered passkey unusable, and users must register them again.
Setup
The withPasskeyManagement mixin needs a column on the users table, two new tables, and must be applied to your User model.
-
Install peer dependencies
The
withPasskeyManagementmixin requires@simplewebauthn/serverto generate and verify WebAuthn registration and login responses. Your frontend needs@simplewebauthn/browserto talk to the device.npm install @simplewebauthn/server @simplewebauthn/browser -
Add the user handle column to users
Every user with passkeys gets a user handle, a random identifier the device stores inside each passkey. During login, the device sends it back so the server can confirm the passkey belongs to the right account. The handle is created on the first passkey registration and never changes.
Generate a migration to add a
webauthn_user_handlecolumn to your existinguserstable. The handle is 64 random bytes encoded as base64url, which is 86 characters long.node ace make:migration add_webauthn_user_handle_to_users --alter=usersdatabase/migrations/xxxx_add_webauthn_user_handle_to_users.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'users' async up() { this.schema.alterTable(this.tableName, (table) => { table.string('webauthn_user_handle', 86).nullable().unique() }) } async down() { this.schema.alterTable(this.tableName, (table) => { table.dropUnique(['webauthn_user_handle']) table.dropColumn('webauthn_user_handle') }) } } -
Create the passkeys table
Generate a migration for the
passkeystable. Each row stores the public key of a passkey along with metadata reported by the device.node ace make:migration create_passkeysdatabase/migrations/xxxx_create_passkeys.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'passkeys' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') table .integer('user_id') .unsigned() .notNullable() .references('id') .inTable('users') .onDelete('CASCADE') .index() table.text('credential_id').notNullable() table.string('credential_id_hash', 64).notNullable().unique() table.text('public_key').notNullable() table.bigInteger('counter').notNullable().defaultTo(0) table.text('transports').nullable() table.string('device_type', 16).notNullable() table.boolean('backed_up').notNullable() table.string('aaguid', 36).nullable() table.string('name', 100).notNullable() table.timestamp('last_used_at', { precision: 6, useTz: true }).nullable() table.timestamp('created_at', { precision: 6, useTz: true }).notNullable() table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable() }) } async down() { this.schema.dropTable(this.tableName) } }A few columns are worth explaining.
- credential_id and credential_id_hash: The credential id is generated by the device and can be up to 1023 bytes long, which is too long to index on every database. The mixin stores its sha256 hash alongside and uses the hash for lookups and the unique constraint.
- counter: The signature counter reported by the device. It is checked on every login to detect cloned authenticators. Synced passkeys always report
0. - device_type and backed_up: Whether the passkey can be synced across devices, and whether it currently is.
backed_upis refreshed on every login. - aaguid: Identifies the authenticator model (e.g. Google Password Manager). The mixin uses it to name new passkeys.
-
Create the passkey challenges table
Generate a migration for the
passkey_challengestable. It stores issued challenges until they are used or expire.node ace make:migration create_passkey_challengesdatabase/migrations/xxxx_create_passkey_challenges.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'passkey_challenges' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') table.string('challenge', 128).notNullable().unique() table.string('purpose', 16).notNullable() table .integer('user_id') .unsigned() .nullable() .references('id') .inTable('users') .onDelete('CASCADE') table.timestamp('expires_at', { precision: 6, useTz: true }).notNullable().index() table.timestamp('created_at', { precision: 6, useTz: true }).notNullable() }) } async down() { this.schema.dropTable(this.tableName) } }The
user_idcolumn is set for registration challenges and leftnullfor login challenges, since the user is not known until the device responds. -
Apply the mixin
Apply the
withPasskeyManagementmixin to your User model usingcompose, and define thepasskeysprovider as a static property. The mixin accepts the relying party details as a config object.app/models/user.tsimport { UserSchema } from '#database/schema' import { compose } from '@adonisjs/core/helpers' import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' import { DbPasskeysProvider, withPasskeyManagement } from '@adonisplus/persona/passkeys' export default class User extends compose( UserSchema, withAuthFinder(), withPasskeyManagement({ rpName: 'My App', rpId: 'example.com', origins: ['https://example.com'], }) ) { static passkeys = DbPasskeysProvider.forModel(User) }rpNamerequired stringThe name of your application. Shown by the browser and the password manager when creating a passkey.
rpIdrequired stringThe domain passkeys are bound to. A passkey created for
example.comworks onexample.comand its subdomains. Must be the domain of the page, or a parent of it. Uselocalhostduring development.originsrequired string[]The origins allowed to register passkeys and sign in with them, e.g.
https://example.com. The browser writes the page origin into the signed response, and the mixin compares it against this list. It never trusts the requestHostheader.The provider queries the database using your model's connection and primary key, so it must be bound to the final
Userclass, which a mixin cannot reference.You can customize the provider by passing a config object as the second argument.
app/models/user.tsexport default class User extends compose( UserSchema, withPasskeyManagement({ rpName: 'My App', rpId: 'example.com', origins: ['https://example.com'], }) ) { static passkeys = DbPasskeysProvider.forModel(User, { passkeysTable: 'user_passkeys', challengeTtl: '2 minutes', }) }passkeysTablestringThe database table used to store passkeys.
Defaults to
"passkeys".challengesTablestringThe database table used to store challenges.
Defaults to
"passkey_challenges".challengeTtlstring | numberHow long an issued challenge remains valid. Accepts a number in seconds or a time expression string like
'5 minutes'. The user must confirm the browser prompt within this window.Defaults to
'5 minutes'.
Registering a passkey
Users add passkeys from their account settings while signed in. Registration takes two requests. The first creates a challenge and returns the options for the browser as JSON. The second submits the device's response for verification.
Creating the challenge is its own resource, so it lives in a dedicated controller. Every request stores a challenge row, so the controller throttles requests per user using @adonisjs/limiter.
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import limiter from '@adonisjs/limiter/services/main'
export default class PasskeyRegistrationChallengesController {
async store({ auth }: HttpContext) {
const user = auth.getUserOrFail()
await limiter
.use({ requests: 10, duration: '1 minute' })
.consume(`passkey_registration_challenges_${user.id}`)
await User.passkeys.clearExpiredChallenges()
return user.createChallengeForRegistration()
}
}
import type { HttpContext } from '@adonisjs/core/http'
import { passkeyRegistrationValidator } from '#validators/passkey'
export default class PasskeysController {
async store({ auth, request, response, session }: HttpContext) {
const user = auth.getUserOrFail()
const payload = await request.validateUsing(passkeyRegistrationValidator)
await user.registerPasskey(payload.response)
session.flash('success', 'Passkey added')
return response.redirect().back()
}
}
The createChallengeForRegistration method creates the user handle on first use, issues a challenge bound to the user, and returns the options for navigator.credentials.create. The options list the user's existing passkeys under excludeCredentials, so a device refuses to create a second passkey for the same account.
The registerPasskey method verifies the response, consumes the challenge, and stores the passkey. Nothing is persisted unless verification passes. It returns the stored
Passkey, which you can use to send a notification email or show the default name.
The limiter's consume method throws a ThrottleException once the limit is reached, which converts itself to a 429 response.
On the frontend, pass the options returned by the challenge endpoint to startRegistration from @simplewebauthn/browser, and submit its result as response to the store endpoint. startRegistration throws a NotAllowedError when the user dismisses the browser prompt. Treat it as a cancellation rather than an error.
Validating the response
The browser response is verified by SimpleWebAuthn, so your validator only needs to check its shape. Browsers add new fields over time, and VineJS strips unknown properties instead of rejecting them. Verification only reads the fields listed below.
import vine from '@vinejs/vine'
export const passkeyRegistrationValidator = vine.create({
response: vine.object({
id: vine.string().maxLength(1364),
rawId: vine.string().maxLength(1364),
type: vine.literal('public-key'),
response: vine.object({
clientDataJSON: vine.string(),
attestationObject: vine.string(),
transports: vine.array(vine.string()).optional(),
}),
clientExtensionResults: vine.object({}),
}),
})
The 1364 limit is the maximum credential id length of 1023 bytes, encoded as base64url. Do not restrict transports to known values. The WebAuthn spec asks servers to ignore unknown transports rather than reject them.
Signing in with a passkey
Login also takes two requests, and both run for guests. Since the user is not known until the device responds, the challenge is anonymous, and the device lists the accounts it holds for your site. Anyone can request a login challenge, so the controller throttles requests per IP address.
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import limiter from '@adonisjs/limiter/services/main'
export default class PasskeyLoginChallengesController {
async store({ request }: HttpContext) {
await limiter
.use({ requests: 10, duration: '1 minute' })
.consume(`passkey_login_challenges_${request.ip()}`)
await User.passkeys.clearExpiredChallenges()
return User.createChallengeForLogin()
}
}
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import { passkeyAuthenticationValidator } from '#validators/passkey'
export default class PasskeySessionController {
async store({ auth, request, response }: HttpContext) {
const payload = await request.validateUsing(passkeyAuthenticationValidator)
const user = await User.verifyPasskeyLogin(payload.response)
await auth.use('web').login(user)
return response.redirect().toRoute('dashboard')
}
}
The verifyPasskeyLogin method performs the following checks before returning the owner of the passkey.
- Finds the stored passkey by the credential id in the response.
- Confirms the user handle in the response belongs to the owner of that passkey.
- Verifies the signature, the challenge, the origin, the relying party id, and user verification.
- Records the new signature counter and the last used time. The update only applies if the stored counter is unchanged, so two responses verified in parallel cannot both succeed.
Every failure throws E_INVALID_PASSKEY with the same generic message. A passkey that no longer exists on the server throws E_UNKNOWN_PASSKEY, covered in
Removing stale passkeys from devices.
On the frontend, the "Sign in with a passkey" button follows the same steps as registration, using startAuthentication instead.
The authentication validator follows the same approach as the registration one. The userHandle is optional, matching the browser's types. Passkeys created by the mixin always return it, and verifyPasskeyLogin rejects a response without one.
export const passkeyAuthenticationValidator = vine.create({
response: vine.object({
id: vine.string().maxLength(1364),
rawId: vine.string().maxLength(1364),
type: vine.literal('public-key'),
response: vine.object({
clientDataJSON: vine.string(),
authenticatorData: vine.string(),
signature: vine.string(),
userHandle: vine.string().optional(),
}),
clientExtensionResults: vine.object({}),
}),
})
Routes
Register the login routes for guests and the registration routes for signed-in users.
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router
.group(() => {
router.post('login/passkey/challenge', [controllers.PasskeyLoginChallenges, 'store'])
router.post('login/passkey', [controllers.PasskeySession, 'store'])
})
.use(middleware.guest())
router
.group(() => {
router.post('account/passkeys/challenge', [controllers.PasskeyRegistrationChallenges, 'store'])
router.post('account/passkeys', [controllers.Passkeys, 'store'])
router.put('account/passkeys/:id', [controllers.Passkeys, 'update'])
router.delete('account/passkeys/:id', [controllers.Passkeys, 'destroy'])
})
.use(middleware.auth())
Brute forcing the verify endpoints is not a concern. Without the private key, no amount of attempts produces a valid signature.
Clearing expired challenges
When a user closes the browser prompt without responding, their challenge is never consumed. Call clearExpiredChallenges to delete them. The challenge controllers above call it before issuing a new challenge, which keeps the table small without a scheduled job. If you prefer, run it from a scheduled command instead.
const deletedCount = await User.passkeys.clearExpiredChallenges()
Managing passkeys
Use the passkeys provider to list, rename, and remove a user's passkeys. Every method takes the user as the first argument and scopes the query to them, so one user can never read or modify another user's passkeys.
Listing passkeys
The all method returns the user's passkeys, oldest first.
export default class SecurityController {
async show({ auth, inertia }: HttpContext) {
const user = auth.getUserOrFail()
const passkeys = await User.passkeys.all(user)
return inertia.render('account/security', {
passkeys: passkeys.map((passkey) => ({
id: String(passkey.identifier),
name: passkey.name,
backedUp: passkey.backedUp,
createdAt: passkey.createdAt,
lastUsedAt: passkey.lastUsedAt,
})),
})
}
}
The Passkey object includes the public key and credential id. Pick the fields your view needs instead of serializing the whole object.
Renaming a passkey
New passkeys are named after the authenticator model, e.g. "iCloud Keychain" or "Google Password Manager". Let users rename them to tell devices apart. The rename method returns the number of affected rows, which is 0 when the passkey does not exist or belongs to another user.
import { errors } from '@adonisjs/lucid'
export default class PasskeysController {
async update({ auth, params, request, response }: HttpContext) {
const user = auth.getUserOrFail()
const { name } = await request.validateUsing(passkeyNameValidator)
if (!(await User.passkeys.rename(user, params.id, name))) {
throw new errors.E_ROW_NOT_FOUND()
}
return response.redirect().back()
}
}
Removing a passkey
The delete method also returns the number of affected rows. When you send a notification email after removal, check this count. A double click or a second tab can send two requests, and only the one that deleted the row should notify the user.
export default class PasskeysController {
async destroy({ auth, params, response }: HttpContext) {
const user = auth.getUserOrFail()
const passkey = await User.passkeys.findById(user, params.id)
if (!passkey) {
throw new errors.E_ROW_NOT_FOUND()
}
if (!(await User.passkeys.delete(user, passkey.identifier))) {
throw new errors.E_ROW_NOT_FOUND()
}
await mail.sendLater(new PasskeyRemovedNotification(user, passkey.name))
return response.redirect().back()
}
}
Removing a passkey on the server does not remove it from the user's device or password manager. See Keeping password managers in sync to hide removed passkeys from the device.
Adding or removing a passkey changes how someone can sign in to the account. Notify the user by email in both cases, so they can react if the change was not theirs.
Keeping password managers in sync
Passkeys live on the user's devices, and your server cannot delete them. When a user removes a passkey from your app, or changes their email, the password manager still shows the old passkey or the old account name.
The WebAuthn Signal API solves this. The server tells the browser which passkeys are still valid and which account details to display, and the browser forwards this to the password manager. The mixin generates the signals, and your frontend passes them to sendSignal from @simplewebauthn/browser.
Syncing after sign-in
After a successful sign-in (with a passkey or a password), flash the signals returned by getPasskeySignals. They list the user's current passkeys (so removed ones get hidden) and their current account details. The method returns an empty array for users without passkeys.
export default class PasskeySessionController {
async store({ auth, request, response, session }: HttpContext) {
const payload = await request.validateUsing(passkeyAuthenticationValidator)
const user = await User.verifyPasskeyLogin(payload.response)
await auth.use('web').login(user)
session.flash('passkeySignals', await user.getPasskeySignals())
return response.redirect().toRoute('dashboard')
}
}
For API clients, return the signals in the login response instead, and let the client pass them to the browser.
return {
user: UserTransformer.transform(user),
passkeySignals: await user.getPasskeySignals(),
}
Removing stale passkeys from devices
When a user signs in with a passkey that no longer exists on the server, verifyPasskeyLogin throws E_UNKNOWN_PASSKEY. The error carries an unknownCredential signal, so the password manager can remove the passkey from the device. The signal is available on the error.signal property, and the error sends it along with its response.
- HTML requests: The signal is flashed under the
passkeySignalskey. - JSON requests: The response body is
{ errors: [{ message: "..." }], passkeySignals: [...] }. - JSONAPI requests: The response body is
{ errors: [{ code: "...", title: "..." }], meta: { passkeySignals: [...] } }.
Sending signals from the frontend
Pass each signal to sendSignal from @simplewebauthn/browser, reading them from the flash messages on the page that follows, or from the response body for API clients. Signals are best effort. Browsers without the Signal API throw, which is safe to ignore. Use the PasskeySignal type from @adonisplus/persona/passkeys/types to type the signals shared with your frontend.
Customization
You can override several methods on the model to customize the passkey behavior without changing the mixin configuration.
Account details
The device stores an account name and display name inside each passkey and shows them when the user picks a passkey. By default, the mixin uses the user's email for both. Override getPasskeyUserDetails to use different values.
export default class User extends compose(
UserSchema,
withPasskeyManagement({
rpName: 'My App',
rpId: 'example.com',
origins: ['https://example.com'],
})
) {
static passkeys = DbPasskeysProvider.forModel(User)
getPasskeyUserDetails() {
return { name: this.email, displayName: this.fullName ?? this.email }
}
}
The mixin throws a RuntimeException if your model has no email property and you have not overridden this method.
Default passkey names
New passkeys are named after the authenticator model, looked up by its AAGUID. Unknown models, including Apple devices (which do not disclose their AAGUID), are named "Passkey". A number is appended when the user already has a passkey with the same name, e.g. "Passkey 2".
Override getDefaultPasskeyName to change the naming. It receives the AAGUID and the names of the user's existing passkeys.
export default class User extends compose(
UserSchema,
withPasskeyManagement({
rpName: 'My App',
rpId: 'example.com',
origins: ['https://example.com'],
})
) {
static passkeys = DbPasskeysProvider.forModel(User)
getDefaultPasskeyName(aaguid: string, existingNames: string[]) {
return `Passkey ${existingNames.length + 1}`
}
}
Additional verification checks
The verifyPasskeyRegistration instance method and the verifyPasskeyAuthentication static method perform the WebAuthn verification. Override them to add checks, e.g. allowing only specific authenticator models. Call super first, then throw E_INVALID_PASSKEY to reject.
import { errors } from '@adonisplus/persona/passkeys'
import type { RegistrationResponseJSON } from '@simplewebauthn/server'
const ALLOWED_AAGUIDS = ['ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4']
export default class User extends compose(
UserSchema,
withPasskeyManagement({
rpName: 'My App',
rpId: 'example.com',
origins: ['https://example.com'],
})
) {
static passkeys = DbPasskeysProvider.forModel(User)
async verifyPasskeyRegistration(response: RegistrationResponseJSON) {
const info = await super.verifyPasskeyRegistration(response)
if (!ALLOWED_AAGUIDS.includes(info.aaguid)) {
throw new errors.E_INVALID_PASSKEY()
}
return info
}
}
The mixin requests attestation none, so the AAGUID is reported by the device but not cryptographically proven. Use it for convenience features, not as a security boundary.
Error handling
The passkey module throws three exceptions. All use the same content-negotiation pattern as other Persona errors:
- HTML requests: Flash the error message to the session and redirect back.
- JSON requests: Return
{ errors: [{ message: "..." }] }with the error's status code. - JSONAPI requests: Return
{ errors: [{ code: "...", title: "..." }] }. - i18n: Translated using the corresponding
errors.*key if@adonisjs/i18nis configured.
E_INVALID_PASSKEY and E_UNKNOWN_PASSKEY share the same message, so a response never reveals whether a passkey exists. The original verification error is available on error.cause for logging.
E_INVALID_PASSKEY
Thrown by registerPasskey and verifyPasskeyLogin when the response cannot be verified. This covers an expired or already used challenge, a wrong origin or relying party, a bad signature, missing user verification, a user handle that does not match, and a signature counter that did not increase.
E_UNKNOWN_PASSKEY
Thrown by verifyPasskeyLogin when the passkey does not exist on the server, e.g. the user removed it from their account. Carries an unknownCredential signal on error.signal, flashed under the passkeySignals key for HTML requests and included in the response body for JSON and JSONAPI requests.
E_PASSKEY_ALREADY_REGISTERED
Thrown by registerPasskey when the passkey is already registered, for the same or another user.
Instance properties and methods
The withPasskeyManagement mixin adds the following to model instances.
webauthnUserHandle
The WebAuthn user handle shared by all passkeys of the user. Mapped to the webauthn_user_handle database column. Created by the first createChallengeForRegistration call and never rotated. Excluded from serialization.
createChallengeForRegistration
Issues a registration challenge bound to the user and returns the options for navigator.credentials.create. Creates the user handle on first use.
const options = await user.createChallengeForRegistration()
registerPasskey
Verifies the browser's registration response and stores the passkey. Returns the stored Passkey. Throws E_INVALID_PASSKEY when verification fails and E_PASSKEY_ALREADY_REGISTERED for a known passkey.
const passkey = await user.registerPasskey(response)
verifyPasskeyRegistration
Verifies the browser's registration response and returns the verified credential info from SimpleWebAuthn, without storing anything. Called by registerPasskey. Override to add checks.
const info = await user.verifyPasskeyRegistration(response)
getPasskeySignals
Returns the allAcceptedCredentials and currentUserDetails signals for the user. Returns an empty array when the user has never registered a passkey.
const signals = await user.getPasskeySignals()
getPasskeyUserDetails
Returns the { name, displayName } stored inside a passkey. Defaults to this.email for both. Override to customize.
user.getPasskeyUserDetails()
getDefaultPasskeyName
Returns the name for a new passkey from the authenticator AAGUID and the names of existing passkeys. Override to customize.
user.getDefaultPasskeyName(aaguid, ['iCloud Keychain'])
Static properties and methods
passkeys
The provider instance defined on your model. The mixin methods delegate to it, and you use it directly to manage passkeys and clear challenges.
const passkeys = await User.passkeys.all(user)
const passkey = await User.passkeys.findById(user, id)
const renamedCount = await User.passkeys.rename(user, id, 'Work laptop')
const deletedCount = await User.passkeys.delete(user, id)
await User.passkeys.clearExpiredChallenges()
createChallengeForLogin
Issues an anonymous login challenge and returns the options for navigator.credentials.get. The options have an empty allowCredentials list, so the device offers every passkey it holds for your site.
const options = await User.createChallengeForLogin()
verifyPasskeyLogin
Verifies the browser's authentication response and returns the owner of the passkey. Records the new signature counter and last used time. Throws E_UNKNOWN_PASSKEY for a passkey missing on the server and E_INVALID_PASSKEY for any other failure.
const user = await User.verifyPasskeyLogin(response)
verifyPasskeyAuthentication
Verifies the browser's authentication response against a stored passkey and returns the authentication info from SimpleWebAuthn. Called by verifyPasskeyLogin. Override to add checks.
const info = await User.verifyPasskeyAuthentication(passkey, response)
DbPasskeysProvider methods
The following methods are available on the User.passkeys provider. Methods that take a user inherit the user's transaction.
all
Returns all passkeys of a user, oldest first.
const passkeys = await User.passkeys.all(user)
findById
Returns a passkey by its primary key, scoped to the user. Returns null when no row matches.
const passkey = await User.passkeys.findById(user, id)
findByCredentialId
Returns a passkey by its credential id, regardless of the user. Returns null when no row matches.
const passkey = await User.passkeys.findByCredentialId(credentialId)
rename
Renames a passkey owned by the user. Returns the number of affected rows.
const renamedCount = await User.passkeys.rename(user, id, 'Work laptop')
delete
Deletes a passkey owned by the user. Returns the number of affected rows.
const deletedCount = await User.passkeys.delete(user, id)
clearExpiredChallenges
Deletes expired challenges. Returns the number of deleted rows.
const deletedCount = await User.passkeys.clearExpiredChallenges()
The provider also exposes create, touchPasskey, issueChallenge, and consumeChallenge. The mixin calls them during registration and login, and you do not need to call them directly.
Passkey properties
Each Passkey object returned by the provider and by registerPasskey contains the following properties.
identifier
The primary key of the passkey row.
userId
The user this passkey belongs to.
credentialId
The base64url credential id generated by the device.
publicKey
The COSE encoded public key used to verify signatures.
counter
The last recorded signature counter. Always 0 for synced passkeys.
transports
How the browser can reach the authenticator (internal, hybrid, usb, nfc, ble), as reported during registration.
deviceType
Whether the passkey is bound to one device or can be synced across devices.
backedUp
Whether the passkey is currently synced. Refreshed on every sign-in.
aaguid
The authenticator model id. All zeros for devices that do not disclose it.
name
The user-facing name of the passkey.
lastUsedAt
When the passkey was last used to sign in. null if never used.
createdAt
When the passkey was registered.
updatedAt
When the passkey was last updated.