Security
View as MarkdownPasskeys
On this page
Introduction
A passkey is a key pair that lives on the user's device or in their password manager. The private key never leaves it; your server keeps the public key. To sign in, the device signs a challenge from your server, after the user has proved themselves to the device with a fingerprint, a face or a PIN. There is no password to phish, reuse or leak.
@marmeon/auth brings the checks, the storage and the sign-in steps. A passkey does two jobs:
- A second factor. After the password, a passkey of the account counts like the code of an authenticator app (see two-factor authentication).
- A sign-in on its own. "Sign in with a passkey" needs no password and no second step: the passkey, unlocked on the device, is both.
Passkeys are optional per user and per app. An app that does not register the provider (Setting up) has no passkey
configuration and no routes, and the WebAuthn library behind them is loaded only by code that imports @marmeon/auth/passkeys: as long as
none of yours does, nothing loads it. The example app in apps/web-app shows the pages: the
login page, the passkey step of the sign-in and a settings page; the starter kit brings them to a new app.
Requirements
WebAuthn works on secure origins only: https://, or http://localhost while you develop. The relying party, the identity a passkey
belongs to, comes from your configuration and never from the request, because a Host header is the client's to forge:
| Variable | Default | Effect |
|---|---|---|
APP_URL | required | Its host is the RP ID, its origin the one allowed origin. |
AUTH_PASSKEY_RP_ID | host of APP_URL | Set it to a parent domain (example.com) to let passkeys work on all of its subdomains. |
AUTH_PASSKEY_ORIGINS | origin of APP_URL | Comma-separated, exact origins (https://app.example.com,https://admin.example.com). |
The RP ID must be the host of every origin or a parent domain of it, on a label boundary: example.com suits app.example.com, ample.com
does not. Every origin must be https: or http://localhost, and an IP address is refused, since passkeys are bound to names.
Browsers also refuse an RP ID that is a public suffix such as co.uk, so a parent domain is meant to be your own registrable domain,
not the suffix under it. For development, set APP_URL=http://localhost:3000 and open the app as localhost: 127.0.0.1 is refused
with a message that names the address to use.
These are checked the first time passkeys are used, not at the start: a wrong AUTH_PASSKEY_ORIGINS stops no app that has no passkeys.
Setting up
Add the table, register the provider and tell the framework that passkeys are factors. The table is yours, like the tables of two-factor authentication:
import { defineMigration } from '@marmeon/database';
export default defineMigration({
up: (schema) =>
schema.create('passkeys', (table) => {
table.id();
table.foreignId('user_id').constrained().cascadeOnDelete();
table.string('user_handle', 64); // the account's random handle, the same in each of its rows
table.string('credential_id', 1400).unique();
table.text('public_key'); // public: not .secret()
table.integer('algorithm');
table.bigInteger('sign_count');
table.json('transports');
table.string('aaguid', 36);
table.boolean('backup_eligible');
table.boolean('backed_up');
table.string('name', 100);
table.timestamp('last_used_at').nullable();
table.timestamps();
}),
down: (schema) => schema.drop('passkeys'),
});import { PasskeyServiceProvider } from '@marmeon/auth/passkeys';
import { defineModule } from '@marmeon/core';
import { AuthServiceProvider } from './AuthServiceProvider.ts';
export default defineModule({
name: 'auth',
providers: [AuthServiceProvider, PasskeyServiceProvider],
// …
});import { combineFactors, TwoFactorState, TwoFactorStore, UserProvider } from '@marmeon/auth';
import { PasskeyStore } from '@marmeon/auth/passkeys';
import type { Container, ServiceProvider } from '@marmeon/core';
import { UserRepository } from './UserRepository.ts';
export class AuthServiceProvider implements ServiceProvider {
register(container: Container): void {
container.singleton(UserRepository);
container.bind(UserProvider, (c) => c.make(UserRepository));
// Both sources: the authenticator app and the passkeys.
container.bind(TwoFactorState, (c) => combineFactors(c.make(TwoFactorStore), c.make(PasskeyStore)));
}
}PasskeyServiceProvider registers PasskeyStore and the request-scoped Passkeys service, and lets TwoFactor.challenge() accept a
passkey. The binding of TwoFactorState is what makes a registered passkey a factor: combineFactors() joins the authenticator app and
the passkeys into one answer. Without it registrationOptions() throws, because a passkey that is never asked for would be no protection.
PASSKEYS_TABLE and the row type PasskeyTable (@marmeon/auth/passkeys) name the table and its columns.
To switch passkeys off again, remove the provider, PasskeyStore from combineFactors() and both imports of @marmeon/auth/passkeys.
Because a registered passkey is a factor, an account that has one needs a second step after its password too. The first passkey of an account turns two-factor authentication on for it: it makes the recovery codes and signs out the other devices, as the first authenticator app does.
Passwordless sign-in
The login page asks for the options, lets the browser sign the challenge, and posts the answer:
const done = await this.#passkeys.signIn(ctx.body.response, { remember: ctx.body.remember });
if (done.ok) return this.redirect().intended().route('user-profile.me');signInOptions() has no list of credentials, so nothing is learnt about which accounts exist; the browser offers the passkeys it holds.
signIn() finds the passkey by its credential id, checks the response against the stored key, and requires the response to carry the
account's user handle. The user is the passkey's own account. It returns the user, or an err:
kind | Meaning |
|---|---|
no-challenge, expired | No challenge is open in this session, or it is older than five minutes. |
invalid | Anything else: an unknown passkey, a wrong signature or origin, a missing user verification. The client learns nothing more. |
clone-suspected | The passkey's sign count did not move forward (below). |
throttled | Sixty failed tries a minute from one address. retryAfterSeconds is the wait. Only failures count, so many people may sign in from behind one address, and garbage sent from an address cannot lock the owner out. |
The SignedIn event says via: 'passkey'. A listener that switches over event.via exhaustively must handle the new value. The new-sign-in notice of the example app (apps/web-app) covers passkeys too: with no password typed, it is the owner's only hint. The session counts as
having passed a second factor and as freshly confirmed for sudo mode: the passkey, proven with user verification just now, is what confirmedPassword() asks for.
On the login page, show the passkey button after hydration and only where PublicKeyCredential exists, so that without JavaScript there is
just the password form. Put autocomplete="username webauthn" on the e-mail field and start the request with mediation: 'conditional'
to offer the passkeys inside the browser's autofill. The browser code comes with the package, from @marmeon/auth/passkeys/browser
(see The browser side): it uses PublicKeyCredential.parseRequestOptionsFromJSON() and credential.toJSON() where
they exist and a base64url fallback elsewhere.
As a second factor
After a correct password the account's passkeys appear next to the code. challengeOptions() allows only the account's own passkeys,
and TwoFactor.challenge() takes the answer:
const done = await this.#twoFactor.challenge({ passkey: ctx.body.response });It returns the same errors as with a code, invalid-code for a passkey that did not count. The checks run in the order of the
sign-in: the waiting sign-in and its account, the throttle, then the passkey. The passkey is
looked up in the store at that moment and must belong to the account that typed the password; one deleted in between is refused. A wrong
passkey counts against the five tries of the waiting sign-in and dispatches TwoFactorChallengeFailed.
Passkeys cannot be guessed, so they share the generous counter of the recovery codes, ten a minute, not the budget of the code attempts.
As a step-up
An account that has only passkeys must also get through a page behind twoFactor(). On the
verify page the signed-in user proves a passkey again: stepUpOptions() opens a challenge bound to the signed-in account and allows
only its passkeys that are not suspended, and TwoFactor.stepUp() takes the answer:
const done = await this.#twoFactor.stepUp({ passkey: ctx.body.response });
if (done.ok) return this.redirect().intended().route('auth.sensitive');The challenge has its own purpose, step-up: one issued for a waiting sign-in does not serve a step-up, and the reverse. It leaves the
session before anything is checked and is used up atomically, so a response works once. The passkey is looked up at that moment, must
belong to the signed-in account and must not be suspended; user verification is required, and the sign count follows the rule above. Every
failed check is invalid-code, whatever the reason, so the client learns nothing. TwoFactorChallengeFailed with during: 'step-up' fires only for a
wrong answer (a bad signature, an unknown, foreign or suspended passkey), not for a challenge that is missing, used or expired. A wrong response does not end the session.
throttled and no-challenge are the only other answers: the latter means the account has no factor at all. A success marks the session as proven under a new session id, as with a code.
The check is throttled per account by the generous counter of the recovery codes, before the response is looked at. stepUpOptions()
only opens a challenge and has no throttle of its own: give its route a limit, as the example app does (ten a minute per user).
Confirming sudo mode
A user with a passkey confirms sudo mode with it instead of typing the password. confirmOptions() opens a
challenge bound to the signed-in account and to this purpose, and allows only the account's passkeys that are not suspended;
confirm() takes the answer:
const done = await this.#passkeys.confirm(ctx.body.response);
if (done.ok) return this.redirect().intended().route('auth.security');
if (done.error.kind === 'throttled') return this.json({ error: this.#translator.get('auth.two_factor.throttled', { seconds: done.error.retryAfterSeconds }) }, { status: 429 });
return this.json({ error: this.#translator.get('auth.passkeys.failed') }, { status: 422 });The challenge has its own purpose, confirm: one issued for a sign-in or a step-up does not serve here, and the reverse. It leaves the
session before the response is looked at, so a response works once. The passkey must belong to the signed-in account and not be suspended, and
user verification is required. On success the session gets a new id, confirmedPassword() lets it through for AUTH_PASSWORD_TIMEOUT_SECONDS,
15 minutes by default, and it counts as having proven a second factor. confirm() returns err for a response that fails, with the kinds of
a sign-in and throttled (ten a minute for the account, a success clears the count); a wrong answer to the open challenge fires TwoFactorChallengeFailed with during: 'confirm'. A challenge that is
missing, used, expired or opened for another purpose does not, and neither does an answer signed for a challenge that a newer confirmOptions() replaced (a second tab, a double click): that is expired, and it uses the newer challenge up too, so the user starts again with new options. confirmOptions() answers no-challenge when the account has no usable passkey; it only opens a challenge and has no throttle of its own: give its route a limit.
A passkey that can confirm sudo mode alone must not open every door. Actions that would let a holder of the session stay in the account
ask for the password in the same form, and remove() does so for every passkey, as register() does; so do TwoFactor.regenerateRecoveryCodes(password), the e-mail change and a new API token in the starter kit. An empty password counts as none and uses up no try.
Managing passkeys
Passkeys offers what a settings page needs:
| Method | Does |
|---|---|
list() | The signed-in user's passkeys: id, name, createdAt, lastUsedAt, suspended, synced. |
registrationOptions() | The options for navigator.credentials.create(). Needs a recent confirmation (a password or a passkey, confirmedPassword()) within 15 minutes by default. |
register(response, name, password) | Adds the passkey. Resolves with recoveryCodes (the first factor) or null. |
rename(id, name) | Renames; false for an empty or too long name or one that is not the user's. |
confirmOptions(), confirm(response) | Confirm sudo mode with a passkey. |
remove(id, password) | Removes. Every passkey needs password; the recovery codes go with the last factor. |
release(id, password) | Lets a suspended passkey sign in again, with the password typed now. |
On an account that already has a factor, registrationOptions(), register(), remove() and release() also throw unless this
session proved a second factor within 15 minutes by default (how long), because the password alone must not let a stolen session add a passkey or take one
away; put those routes behind confirmedSecondFactor() (see Changing the factors).
rename() and list() stay free of it.
register() checks the session against the account in the transaction that stores the passkey: when the session ended meanwhile (another
device changed the password, AccountSessions.end() ran), nothing is stored and it returns { kind: 'signed-out' }; send the user to the sign-in.
Both the registration and the list are behind confirmedPassword() ("sudo mode"), and register() asks for the password again in the
same form for every passkey, not only the first. A passkey outlives a password change and a password reset, and it works without the password.
Someone who held a stolen session inside the sudo window could otherwise add one and keep a way in that survives the owner's recovery.
Typing the password costs the owner a second; the check is counted per sign-in (five a minute and twenty an hour) and, for a sign-in that did not begin with a typed
password, per account (twenty an hour), in the same limiter as remove(), so it is no oracle for guessing the password and a passkey-only session cannot lock out the owner who signed in with it (how). The password forms of your own use auth.checkPassword(), which counts the same way;
Passkeys calls it itself.
The first passkey also signs out every other device (sessions and remember-me tokens, a second time after the insert), shows the
recovery codes once in the answer of the registration, and dispatches TwoFactorEnabled with factor: 'passkey'. A later passkey makes no
new codes. remove() of the last factor takes the recovery codes in the same transaction, and the store decides inside it, so two
removals at once cannot both pass without the password.
remove() ends the account's other sessions and their remember-me sign-ins itself, with auth.updateSessionKey() once the passkey is gone, and keeps the
session that removed it: the sessions of someone who holds a cloned or stolen passkey must not outlive its removal, and an app cannot forget the call. The removal
is not undone when this session ended meanwhile; the result is ok all the same. A wrong or missing password, an unknown id and another account's passkey end nothing.
An account holds at most 20 passkeys; the 21st answers limit. Names lose control and invisible format characters (a bidi override would otherwise
reorder the text of the list). Registration accepts ES256 and EdDSA keys, and RSA keys only with the exponent 65537 and at least 2048 and at most 4096 bits. A stored RSA passkey under 2048 bits, or one with an algorithm outside that list, does not sign in. The account then signs in with its password and another second factor or a recovery code, and removes the passkey.
An account without a password cannot register its first factor: turning it on asks for the password, and signs out the other devices
with a new session key, so register() throws for it.
Counter and cloned keys
An authenticator counts its uses. A sign-in whose count is not above the stored one, while either is above zero, may come from a copied key
and is refused with clone-suspected: the stored count stays as it was, the passkey is suspended, a PasskeyCloneSuspected event is dispatched
and a warning is logged. Every session of the account ends, the session that met the suspicion included, with its remember-me sign-ins
(AccountSessions.end()), before the event is dispatched: whatever the copy started must not outlive the suspicion, and the owner signs in again with
the password and another factor. Its API tokens stay, so an integration does not stop for a suspicion; but whoever holds the copy may have made a token before the
counter gave it away, so the owner's mail tells them to check and revoke their API tokens. Inside your own database transaction the event comes first and the sessions end once the transaction is over,
whether it committed or rolled back. A rollback (a returned err rolls back too) also takes the suspension back, so it is written again
before the sessions end: the copy was seen all the same. A response that fails for any other reason, such as a bad signature, ends nothing. A suspended passkey signs nobody in, neither on its own nor as a second factor, and the list shows it as suspended.
Only its owner can lift that: release() and remove() of a suspended passkey need the password typed now, so someone who holds a stolen
session cannot tidy the evidence away. Synchronised passkeys report 0 every time, which is allowed. The new count is written with a conditional UPDATE, so two
sign-ins with one count cannot both succeed. The challenge makes a replay of a response impossible in the first place.
The rule also suspends the legitimate device when a real signature carries a count that went backwards, for example when two ceremonies of one
authenticator finish in the other order, or when an authenticator counts wrongly. That is intended: the server cannot tell a copy from a faulty
counter, so it fails closed. If it is the account's only factor, the owner signs in with a recovery code and lifts the suspension with release(). The honest owner is signed out everywhere once and signs in again; an account whose only way in was this passkey comes back through the password reset. Ending the sessions needs
a UserProvider that stores a session key, and the sign-in must not run inside a database transaction, as AccountSessions.end() requires; if ending fails, the passkey stays suspended, the event is dispatched and the error is logged.
What it protects against, and what not
A passkey is bound to your origin by the browser, so a copy of your login page cannot use it. Every challenge works once and only for its
purpose, user verification is required (the device checks the user), your origin and RP ID are fixed in configuration, and a passkey
signs in only its own account. The attestation is not trusted: it is reduced to none before anything looks at it, so its statement is never parsed and no certificate
or revocation list is fetched for bytes the client chose. Only the key is kept, and no authenticator models are filtered. A challenge is also
spent across processes at once, so parallel requests with one cookie cannot replay a response of a synchronised passkey.
User verification is required everywhere. A security key without a PIN or a biometric therefore does not work, and neither does an authenticator that cannot verify the user. That keeps the passwordless sign-in a real second factor and the rule easy to check.
It does not protect against someone who controls the user's unlocked device or the account's recovery path, or against a server whose code or
APP_URL an attacker changes. A user who lost every passkey and the recovery codes needs the operator's reset (PasskeyStore.removeAll(userId)
plus TwoFactorStore.removeAll(userId), and the events dispatched by the command).
In the starter kit
The routes that ask for registration options and store a new passkey sit behind verified() in every registration mode: an account whose
address nobody confirmed cannot add a passkey, and the password reset of such an account removes the ones it has. See
security.
make:auth writes the passkeys into a new app (the starter kit): the migration of the passkeys table
(foreignId('user_id').constrained().cascadeOnDelete(), with suspended_at), the binding
combineFactors(TwoFactorStore, PasskeyStore) and PasskeyServiceProvider in the module, the controllers and routes, the limits of the
options requests (auth.passkey-sign-in-options, 30 a minute for each client; auth.passkey-challenge-options, 10), and of the two requests that check an answer (auth.passkey-sign-in, auth.passkey-challenge, 10), the mails and the pages, in both languages:
| Page | What it does |
|---|---|
auth/Login | A button "Sign in with a passkey" after the page has loaded, and the browser's autofill in the e-mail field (autocomplete="username webauthn"). The password form works without JavaScript. |
auth/TwoFactorChallenge | A button for a passkey when the account has one, the code form when it has an authenticator app, the recovery code always. |
auth/Passkeys | In the settings: the list with the dates, rename, release of a suspended passkey, removal, and the form that adds one with a name and the password. The first passkey shows the recovery codes once. |
The conditional sign-in in the e-mail field ends with an AbortController when the button is pressed or the page closes, and does not
start again after a sign-in. The pages send no inline script and no style, so a strict content security policy accepts them.
The browser side
@marmeon/auth/passkeys/browser is the code the pages run, so a fix reaches an app with an update of the package. It imports nothing,
neither the server side of @marmeon/auth nor a Node module, and so belongs in a client bundle:
import { createPasskey, getPasskey, passkeysAvailable, postJson } from '@marmeon/auth/passkeys/browser';| Export | What it does |
|---|---|
passkeysAvailable() | Whether this browser can make and use passkeys at all. Show the button only where it is true, after hydration. |
conditionalAvailable() | Whether the browser can offer passkeys in the e-mail field's autofill. |
createPasskey(options) | Asks the authenticator for a new passkey and returns the JSON the server takes. Throws when the user cancels. |
getPasskey(options, { conditional, signal }) | Asks the authenticator to sign in; conditional waits in the autofill until signal ends it. |
postJson(url, body, signal) | A same-origin JSON POST; a redirect (a finished sign-in) is followed by the page. |
bufferFromBase64Url(), base64UrlFromBuffer() | The converters between the server's base64url text and the bytes WebAuthn uses. |
Apps made before the package shipped it have modules/auth/passkeys.ts; it keeps working. Import from @marmeon/auth/passkeys/browser in the five views instead, then delete the file.
An account with a passkey has a second factor, so twoFactor() applies to it. The kit's /settings/two-factor/verify page, where
twoFactor() sends a session, offers a passkey, the code of an authenticator app and a recovery code, as the sign-in does (its options
request has its own limit, auth.passkey-step-up-options, 10 a minute for each user). On /settings/two-factor an account with passkeys only
shows "Protected by passkeys", the recovery codes left and "Make new recovery codes".
The kit's listeners mail the owner on PasskeyRegistered, PasskeyRemoved, PasskeyCloneSuspected and PasskeySignInFailed (the
last at most once an hour for each account). Passkeys outlive a password reset, so NotifyAboutPasskeysAfterReset also listens to
PasswordReset and mails the passkeys the account still has; it is no queued listener, because that event is not queueable, and it logs
rather than throws if the mail cannot be queued, so a reset that happened is never undone. The operator's marmeon auth:reset-two-factor
removes the passkeys together with the authenticator and the recovery codes.
Events
| Event | When | Fields |
|---|---|---|
PasskeyRegistered | register() added a passkey | user, name |
PasskeyRemoved | remove() removed one, or the password reset of an account with an unconfirmed address removed them | user, via (user or reset) |
PasskeyCloneSuspected | A sign-in was refused for its sign count, the passkey suspended and every session of the account ended | user, passkeyId |
PasskeySignInFailed | A passwordless sign-in failed against a passkey that exists: a wrong signature or user handle, or a stored RSA key under 2048 bits or with an algorithm outside the accepted ones. Nothing for an unknown passkey | user, at, ip, userAgent |
SignedIn | A passwordless sign-in | via: 'passkey' |
TwoFactorChallengeFailed | A wrong passkey, or one with a stored RSA key under 2048 bits or an algorithm outside the accepted ones, after the password, at a step-up, or when confirming sudo mode | user, at, ip, userAgent, during (sign-in, step-up or confirm) |
The first passkey also dispatches TwoFactorEnabled and RecoveryCodesGenerated, and removing the last factor TwoFactorDisabled. All of
them can be queued. Send the owner a mail on PasskeyRegistered, PasskeyRemoved,
PasskeyCloneSuspected and PasskeySignInFailed.
Anyone who knows a credential id can cause up to 60 PasskeySignInFailed events a minute per address, so a listener that turns them into mails
limits them per account. Count with the rate limiter, as the example app does for its other notices:
import { RateLimiter } from '@marmeon/cache';
import type { PasskeySignInFailed } from '@marmeon/auth';
export class MailAboutFailedPasskeySignIn {
constructor(private readonly limiter: RateLimiter) {}
async handle(event: PasskeySignInFailed): Promise<void> {
// At most one mail an hour and account, however many attempts there were.
if (!(await this.limiter.attempt(`passkey-failed-mail:${event.userId}`, { maxAttempts: 1, decay: 3600 }))) return;
// … queue the mail to event.user
}
}Testing
softAuthenticator() is an authenticator in software: it registers credentials and signs in with them, and produces the WebAuthn JSON a
browser would send, with ES256, EdDSA or RS256 keys. Give it your relying party, hand it the options the server returned, and post the result back:
import { cookieNames, softAuthenticator, type SoftOptions } from '@marmeon/auth/testing';
import { createTestApp } from '@marmeon/testing';
import application from '../../bootstrap/app.ts';
import { UserFactory } from './factories/UserFactory.ts';
const app = await createTestApp(application, { database: 'refresh' });
const ada = await app.factory(UserFactory).create();
// The relying party is the APP_URL of the test environment.
const authenticator = softAuthenticator({ origin: 'http://localhost', rpId: 'localhost' });
const login = await app.post('/login', { form: { email: ada.email, password: 'password' } });
const { session } = cookieNames(app);
const cookie = login.cookie(session)!;
const options = await app.withCookie(session, cookie).post('/settings/passkeys/options', { json: {} });
await app.withCookie(session, cookie).post('/settings/passkeys', {
json: { response: authenticator.register(options.json<SoftOptions>()), name: 'Laptop', password: 'password' },
});authenticate(options) signs in. A second argument bends one response to test the refusals: origin, rpId, challenge,
userVerified: false, userPresent: false, crossOrigin: true, signCount, backupEligible, badSignature and, for a registration, attestation: 'packed'. synced: true in the
configuration reports a count of 0 every time, as synchronised passkeys do. A test that acts as a user with actingAs() skips the second
factor, as for two-factor authentication.