Passkeys and WebAuthn
This page is the guide to passkeys in iya-sts: what a passkey can be used for here, how to set up each use, what the person sees, and how to check that it worked. iya-sts is a WebAuthn Level 3 relying party. It accepts passkeys stored on a device, in a password manager, on a phone, or on a hardware security key (FIDO CTAP2).
Each recipe gives the admin console steps, the Management API calls, or both. The longer explanations of why the service behaves as it does are on Authentication, and this page links to them rather than repeating them. Every setting in full is in Configuration → WebAuthn.
- Passkeys and WebAuthn
- Before you start
- How a passkey is used
- Recipes
- A passkey as a second factor after a password
- Signing in with a passkey instead of a password
- Signing in with no username
- A passwordless account from the start
- An administrator enrols a passkey
- Requiring a passkey
- Administrators
- Device-bound passkeys only, or one kind of authenticator
- Only approved authenticator models
- RFC 4998 evidence for classical attestations
- Enterprise attestation
- Other passkey policy rows
- Different rules for different applications or groups
- Several host names
- Algorithms: classical and post-quantum
- A classical and a post-quantum passkey side by side
- Where ML-DSA passkeys are expected to work
- Requiring a post-quantum passkey
- Requiring a post-quantum attestation
- Post-login activities: moving people to post-quantum passkeys
- Upgrading a classical passkey
- Measuring the move to post-quantum passkeys
- Watching the move to post-quantum passkeys: the ceremony audit rows
- Losing a passkey, and account recovery
- Realms
- Development and product mode
- Settings
- Management API operations
- Error codes you will meet
- Limits, and what is not supported
- Related
Before you start
Words used on this page
| Word | Meaning here |
|---|---|
| passkey | Any WebAuthn credential, wherever it is kept. The service and its pages use passkey for every kind. |
| security key | A passkey on a separate hardware device, reached over USB, NFC or Bluetooth (a cross-platform authenticator). |
| platform authenticator | The authenticator built into the device: Touch ID, Windows Hello, an Android screen lock. |
| synced / device-bound | A synced passkey is copied by its provider to the person’s other devices. A device-bound one never leaves the authenticator that made it. The authenticator reports which with the backup eligible (BE) flag. |
| use | How a passkey answers a sign-in: first factor (passwordless, no password read) or second factor (a step after a password). A passkey has no fixed purpose: the person is asked nothing about it when they create it, and every passkey they hold may be used either way, as the realm allows. Until #1135 each passkey had a role chosen at its creation. |
| usernameless | Signing in with a passkey and no username typed. The passkey tells the service who the person is. This needs a discoverable (resident) credential. |
| RP ID | The domain a passkey is bound to. By default it is the host the service was reached on. |
| user handle | The random identifier a passkey is created under (64 bytes on the person’s entry). It is never the username. |
| attestation | A statement from the authenticator, at registration, about what model it is (its AAGUID) and who made it. |
| lane | classical or pqc. A person has two lanes only where a classical and a post-quantum passkey side by side is on for them; otherwise every passkey is in the one (classical) lane, whatever its algorithm. |
| classical / post-quantum | A passkey’s kind, from its signature algorithm alone: an ML-DSA passkey (ML-DSA-44, -65 or -87) is post-quantum, any other is classical. The lane does not decide it. |
| post-login activity | Something a realm asks a person to do after they sign in, before they return to the application: today, registering an ML-DSA passkey. See Post-login activities. |
Where things are
| What | Console | API |
|---|---|---|
Every webauthn.* setting |
Protocols → WebAuthn (/admin/webauthn), Settings tab |
GET /admin-api/webauthn; change one with POST /admin-api/config/set |
| What a registration requests right now: the algorithms in order, and why each other one is not offered | Protocols → WebAuthn, Metadata tab | GET /admin-api/webauthn (status.algorithms, status.algorithmsSource) |
| The passkey policy | Directory → Policies (/admin/policies), Passkey policy tab, with the sub-tabs Default, Named policies and New policy |
GET /admin-api/policies (the passkey member); POST /admin-api/policies/save-passkey-policy and reset-passkey-policy |
| The authentication policy (which sign-in methods are accepted, and whether a second factor is required) | Directory → Policies, Authentication policy tab | GET /admin-api/policies (the authn member); POST /admin-api/policies/save-authn-policy and reset-authn-policy |
| A person’s passkeys | Directory → Users (/admin/users), the person’s page, Passkeys (WebAuthn) |
GET /admin-api/users?user=<username>; POST /admin-api/users/{action} |
| Who holds which factor, across the realm | Directory → Users | GET /admin-api/mfa |
| Who holds which passkey algorithm, across the realm | Monitoring → Passkeys (/admin/passkey-report) |
GET /admin-api/passkey-report, /export, POST /admin-api/passkey-report/recount |
| The post-login activity policy (who is asked to register an ML-DSA passkey after signing in) | Directory → Policies, Post-login activity policy tab | GET /admin-api/policies (the postLogin member); POST /admin-api/policies/save-postLogin-policy and reset-postLogin-policy |
| Mail reminders to sign in and register one | Directory → Post-login activities (/admin/post-login-activities) |
GET /admin-api/post-login-activities; POST /admin-api/post-login-activities/{start,pause,resume,end} |
| The move over time, ceremonies by client, each person’s progress | the reports application, /report: the sources passkeyHistory, passkeyCeremonies and postLoginProgress |
the report API, /report-api |
| A passkey’s long-term attestation evidence | Directory → Users, the person’s page, Passkeys (WebAuthn) | GET /admin-api/passkey-report/evidence?username=<name>&credentialId=<id> |
| A new person with an activation link | Directory → Users → New user (/admin/users/new) |
POST /admin-api/users/create |
| Which methods one application accepts | Directory → Applications, the application’s Configuration tab | POST /admin-api/applications/add, /remove, /update-fields |
| Live sessions, and the record of what happened | Monitoring → Sessions, Monitoring → Audit log (/admin/audit) |
GET /admin-api/sessions, GET /admin-api/audit?code=<error code> |
| The person’s own passkeys | the user portal’s Passkeys page, /portal/keys |
the Account API: GET and POST /account-api/keys |
| Signing in | /authn/login, then /authn/webauthn or /authn/mfa-setup |
the sign-in API, /authn-api |
A token and the api helper. The API examples below use the variables
and the api shell function set up in
Configuring OAuth 2.0 grants → Before you start.
The token needs the admin:write scope.
Everything is per trust realm. The settings, the
passkey policy, the authentication policy and the people all belong to a
realm. A console page saves to the realm it is shown in. For the API, use
$BASE/realm/<id>/admin-api/.... A person in a realm uses
/realm/<id>/portal/keys and signs in at /realm/<id>/authn/login. See
Realms below.
A save of a policy replaces the whole profile. When you call
save-passkey-policy or save-authn-policy, send every field. Read the
current values from GET /admin-api/policies: each kind has a fields list,
and each field has its key, its current value and its default. The
console form does this for you.
How a passkey is used
A passkey is registered, and nothing else is asked. The person does not choose whether it replaces their password or follows it. Whether a passkey is used on its own (passwordless), as the second factor after a password, both or neither is decided at the sign-in, in layers, for every passkey alike:
- The realm. Its authentication policy
chooses which first-factor and second-factor mechanisms the realm
supports: its
passkeyrow as a first factor (passkeyPrimary) and itssecurityKeyrow as a second factor (securityKeySecondFactor), together withwebauthn.primaryAllowedandwebauthn.mfaAllowed. A use the realm does not accept is refused (STS-AUTHN-0045,0253as a first factor;0046,0254as a second). Where it accepts neither, no new passkey can be registered (STS-AUTHN-0531), because it could never be used. - The application, where it overrides the realm, and only at a browser
sign-in to it: the named authentication policy
it names in
appAuthnPolicy. The policy’s first-factor list names the mechanisms its people may sign in with (webauthnamong them); the issuance policy’sauthn-mechanismrule enforces it and re-prompts a session that used another. Its second-factor list names the second factors it allows (securityKeyamong them); it narrows the second step to another factor the person holds, or has them prove one and set up an allowed one, or refuses. It only narrows: what the realm switched off stays off. See Sign-in mechanisms and Second factors. - The passkey itself has no say. A registered passkey is used wherever those layers accept a passkey: passwordless, as the second factor, or both. (Until #1135 each passkey had a role chosen at its registration, and answered only that step.)
With the defaults (every row on, no application list) a passkey answers both steps.
How the person signed in is what the session records:
| How the person signed in | amr |
acr |
|---|---|---|
| a password, then a passkey | ["pwd","hwk"] |
mfa |
| a passkey, with the username typed and no password | ["hwk"] |
1 (one factor) |
| a passkey with no username (usernameless) | ["hwk","user"] |
mfa |
| a password alone | ["pwd"] |
1 |
A passkey with the username typed counts as one factor, even when the authenticator checked a PIN or a fingerprint. The usernameless sign-in always requires that check (user verification), so it records two. Authentication → WebAuthn explains why.
A relying party can also ask for a phishing-resistant sign-in with the
OpenID Connect acr values phr and phrh. A passkey earns phr. A passkey
with a trusted attestation that is not synced also earns phrh. See
Phishing-resistant sign-in.
Every registration and every sign-in is verified in both development and product mode: the challenge, the origin, the RP ID, the user-presence and user-verification flags, the signature, and the signature counter. What development mode relaxes is the password in front of a passkey, and (by default) the check of the attestation statement. See Development and product mode.
Recipes
A passkey as a second factor after a password
Settings. Nothing to change: this is on by default. It needs
webauthn.enabled and webauthn.mfaAllowed on (both default true), and the
authentication policy’s Passkey (second factor) — as a second factor
row on (securityKeySecondFactor, default on).
What the person does. Either of these:
- On the portal. Sign in, open
/portal/keys, and under Add a passkey press Create a passkey (a passkey on this device or a phone) or Use a security key. Nothing else is asked. The browser asks them to create the passkey. The new passkey then appears on its row under a default name, and its row’s Rename gives it a nickname. - At the sign-in screen. Type the username and password and tick Use a passkey as a second step (WebAuthn). If the person holds no second factor yet, this step registers a passkey. If they already hold any second factor, the screen does not register a new one here. (Otherwise anybody who knew the password could add their own authenticator.)
From then on, where the realm (and the application, if it overrides the realm) accepts a passkey as a second factor, the person is asked for the passkey at every sign-in after the password. Where the realm accepts a passkey as a second factor, holding any passkey is holding a second factor, so in product mode the doors that can only take a password (an LDAP simple bind, a WS-Security UsernameToken, HTTP Basic at SCIM, Shared Signals and EST, and the Kerberos KDC) refuse a bare password to that person. Those doors are not browser sign-ins to an application, so only the realm’s layer applies there. See the password-only doors. They do not have to tick anything. What a person holds decides the second factor, not a checkbox. See Which second factor is asked for.
Check it.
- On the person’s page under Directory → Users, the Passkeys (WebAuthn) table lists the passkey with its algorithm and backup state.
- After a sign-in, Monitoring → Sessions shows the session, and the audit
log’s
session.startrow recordsamrpwd, hwk,acrmfa, and the passkey’s algorithm (credentialAlgorithm).
Signing in with a passkey instead of a password
Settings. On by default. It needs webauthn.primaryAllowed on (default
true) and the authentication policy’s Passkey (passwordless) — as a first
factor row on (passkeyPrimary, default on).
What the person does.
- On
/portal/keys, press Create a passkey or Use a security key. It is the same passkey as in the recipe above: any passkey the person holds signs them in here, and the same passkey still answers the step after a password. - At the sign-in screen, they can either:
- press Sign in with a passkey with no username typed (see the next recipe), or
- type the username and tick Sign in with a passkey instead of a password. The password field is then not read.
Product mode never registers a passkey at the sign-in screen’s passwordless box.
A person who ticks the passwordless box and holds no passkey is
refused (STS-AUTHN-0206). In development mode the screen registers one on
the spot, so the first person to claim a username gets it. In product mode a
person with no password registers their passkey on /portal/keys or through an
activation link.
With webauthn.primaryAllowed (or the passkeyPrimary row) off, no passkey
signs anybody in on its own, including a passkey registered while it was on:
the person signs in with a password and the passkey is asked for next. A
person with no password then cannot sign in with their passkey, so turn it
off only where everybody has another way in.
Signing in with no username
Settings. On by default: the passkey policy’s allowUsernameless row
(Directory → Policies → Passkey policy). It also needs webauthn.enabled,
webauthn.primaryAllowed and the passkeyPrimary row of the authentication
policy.
What the person sees. The sign-in screen shows Sign in with a passkey. In browsers that support it, the username field also offers the person’s passkeys as autofill. The browser asks for any passkey this service holds for the person, and the passkey’s user handle names the account.
- User verification is always required here (a PIN or a biometric),
whatever
webauthn.userVerificationsays. The session recordsamr ["hwk","user"]andacr "mfa". - Any passkey the person holds answers, while the realm lets a passkey be a first factor. (Until #1135 only a passkey registered “instead of my password” did.)
- Passkeys must be discoverable. Create a passkey always asks the
authenticator to store a discoverable credential. Use a security key does
too, unless the passkey policy’s
securityKeyResidentKeylowers it while usernameless sign-in is on. A security key registered that way may need the username. - A passkey created before user handles existed was created under the username. It still works where the username is typed. Register it again to use it with no username.
Making it the only way in for one application (the passkey policy’s
requireUsernameless). Point the application’s appAuthnPolicy at a
named authentication policy
whose only first factor is webauthn
(Sign-in mechanisms),
and turn requireUsernameless on. That application’s sign-in screen then has
no username field. A sign-in that names a person anyway is refused
(STS-AUTHN-0333). The default profile never applies this to the admin
console, the user portal or the embedded debugger. To apply it to one of them,
create a named passkey policy
that selects it. The details are in
the passkey policy, under
Requiring it.
A passwordless account from the start
By default an administrator does not enrol a passkey for somebody. The
passkey policy’s adminEnrolment is off, and the ceremony runs in the
person’s own browser against their own authenticator. What the
administrator gives them is a single-use activation link. When
adminEnrolment is on, an administrator with Admin Write can enrol
one in their own browser; that is
below.
Console (a new person).
- Directory → Users → New user (
/admin/users/new). Fill in the username and any attributes. - Under the credential choice, pick No credential, and an activation link. Tick the mail box to mail it to the address on the entry, or leave it unticked to be shown the link once.
- Create the person, and send them the link.
API.
# A new person, with an activation link
api users/create '{"username":"dana","credential":"activation",
"attributes":{"mail":"dana@example.com"}}'
# Somebody who already exists (no console button does this)
api users/issue-activation '{"user":"dana"}'
# Either one, mailed to the address on the entry instead of returned
api users/issue-activation '{"user":"dana","deliver":"mail"}'
The answer carries activationUrl (a path on the service) and expiresAt,
unless the link was mailed. The link lasts security.activationTtlMinutes
(1440, one day), is stored only as a hash, and issuing another one replaces
it.
What the person does. At /portal/activate:
- Leave the two password boxes empty.
- Under 2. A passkey, choose to register a passkey.
- Press Continue, then create the passkey on the next screen.
The account is not set up, and the link is not used up, until the passkey is
created. To have both a password and a passkey, they fill in the password
too and still choose to register a passkey; the page asks nothing about how
the passkey will be used. That passkey signs them in on its own and is the
step after their password, as the realm allows. (An older client that still
posts key_role primary or mfa is understood as “register a passkey”,
and none as “do not”.)
An administrator enrols a passkey
The passkey policy’s adminEnrolment is off by default
(#945). Set it to on on the
profile that applies to the person (the default profile, or a
named one). An
administrator holding Admin Write then sees Enrol on the person’s
page under Directory → Users, Passkeys (WebAuthn), while WebAuthn
is on. The note on the button says to enrol only a key you know is theirs:
whoever holds it signs in as that person.
The ceremony runs in the administrator’s browser, against an authenticator
they hold (a security key to hand over, for example), under the person’s
user handle. The registration is verified exactly as the person’s own is: attestation,
algorithms and that profile’s registration rules. Nothing made elsewhere
can be imported. The key records which administrator enrolled it and when
(enrolledBy), the person is sent the security notice passkey-enrolled,
and the audit row is admin.passkey.enrolled.
While the row is off, Enrol is still on the page and the ceremony is
refused (STS-AUTHN-0440). A name nobody in this realm’s directory holds
is refused in every mode (STS-AUTHN-0441). The caller’s token must be a
person’s, so the key can record who enrolled it (STS-AUTHN-0445). The
enrolment lasts five minutes and is single use (STS-AUTHN-0442).
API. begin-passkey-enrolment, then finish-passkey-enrolment, on
POST /admin-api/users. Begin takes user and, optionally, lane
(classical or pqc, where both lanes are on) and kind (passkey or
security-key). A role member, which the request carried until #1135, is
accepted and ignored. It answers
enrolmentId and ceremony. Finish takes user, enrolmentId and
credential (the browser’s registration as JSON). The request bodies are
in GET /admin-api/openapi.json.
Requiring a passkey
Several controls require a second factor or a passkey. They differ in who they apply to:
| Who | How | Where it is explained |
|---|---|---|
| everybody in a realm must have a second factor | the authentication policy’s requireSecondFactor: always |
Requiring a second factor of everybody |
| one person | Require MFA for this person on their page (POST /admin-api/users/require-mfa) |
Admin console |
| administrators | the authentication policy’s requireSecondFactorForAdministrators |
Administrators below |
| the people of one application, which second factor | the named authentication policy the application names in appAuthnPolicy: tick securityKey alone among its second factors |
Second factors |
| the people of one application, which sign-in | the named authentication policy the application names: tick webauthn alone among its first factors for a passkey with no password |
Sign-in mechanisms |
| one request | acr_values=hwk (a hardware key), or phr / phrh |
Which second factor is asked for, Phishing-resistant sign-in |
| everything an application is issued | the issuance policy’s requirePhishingResistant or requireHardwareProtected option |
Phishing-resistant sign-in |
When a second factor is required and the person holds none, the sign-in
screen sends them to /authn/mfa-setup after the password. It offers Set up
an authenticator app and Set up a passkey. No session starts until they
finish. A passwordless sign-in is refused for such a person
(STS-AUTHN-0171).
To offer only passkeys as the second factor in a realm, turn off the
authentication policy’s TOTP row (Authenticator app (TOTP) — as a second
factor). The set-up step then offers only a passkey. This does not take
an authenticator app away from somebody who already has one: they go on
being asked for it. Remove it on their page (Disable all MFA, or POST
/admin-api/users/clear-totp), or narrow an application to securityKey
with the named authentication policy it names
(Second factors).
A request can demand a passkey. An OpenID Connect client that sends
acr_values=hwk (or a WS-Federation wauth for a hardware token) gets a
sign-in in which only a passkey, alone or after a password, is accepted. A
one-time code or a recovery code is refused (STS-AUTHN-0204).
Administrators
- Require a second factor of every administrator. On the default realm’s
authentication policy, set Second factor for administrators
(
requireSecondFactorForAdministrators) toalways. The default,offer, lets an administrator press Ignore. Every realm inherits the default realm’s policy unless it saves its own. The built-in administrator (admin.bootstrapUsername) is only ever offered one, so the service is never left with nobody who can sign in. See A second factor for administrators. - Hold administrators’ passkeys to stricter rules. Create a
named passkey policy
that selects the group your administrators are in, or the application
sts-admin-console. In it, set for example backupEligibility todisallow(device-bound passkeys only) or postQuantumRequired tosign-in. Give it a low precedence number, such as5. - Require a passkey, rather than any second factor, for the console.
Create a named authentication policy
with
securityKeyalone among its second factors, and choose it in theappAuthnPolicyof the applicationsts-admin-console.
Warning. Changes 2 and 3 can lock an administrator out of the console if they hold no passkey that meets them. Before you save, make sure each administrator has registered a suitable passkey, and keep a second administrator session open while you test.
Device-bound passkeys only, or one kind of authenticator
Device-bound only. On the passkey policy, set backupEligibility to
disallow. A synced passkey is then not registered
(STS-AUTHN-0312) and does not sign anybody in (STS-AUTHN-0313). That
second rule also stops synced passkeys registered before you changed it, and
/portal/keys tells their owners. Most passkeys people already hold in a
phone or browser password manager are synced, which is why the default is
allow. See Synced passkeys under
the passkey policy.
Only security keys, or only the device’s own authenticator. Set
webauthn.authenticatorAttachment to cross-platform or platform (default
any). This is a filter in the browser, not a check by the service: the
browser offers only matching authenticators, but the service does not refuse
a passkey whose attachment turned out to be the other kind. To enforce the
kind of authenticator, use attestation (next recipe).
Which authenticator the browser leads with. The passkey policy’s
passkeyHints, securityKeyHints and signInHints rows send WebAuthn
hints (client-device, hybrid for a phone, security-key). They change
what the browser offers first. They are not enforced.
Only approved authenticator models
To accept passkeys only from particular models, the service needs a trusted attestation: a statement from the authenticator whose certificate chains to a root the service trusts.
- Load the FIDO Metadata Service (MDS3) BLOB on Monitoring → Risk, or
point
risk.mdsUrlat it. It gives the roots and the status of every certified model. See The FIDO metadata. To check what it says about one model, for example before you put its AAGUID inwebauthn.attestationAllowedAaguids, use Look up an authenticator on the same page (Looking up an authenticator). - Optionally add roots of your own in
webauthn.attestationTrustAnchors(a PEM bundle): a corporate CA, a vendor root or a test root. - Choose what to require:
| Setting | Default | Effect |
|---|---|---|
webauthn.attestationPolicy |
by-mode |
verify-if-present verifies every statement and refuses one that fails. require-trusted also refuses a passkey whose statement does not chain to a trusted root, so it refuses every synced passkey (they send none). by-mode is verify-if-present in product mode and off in development. off (development only) verifies nothing. |
webauthn.attestationAllowedAaguids |
empty | Only these models (AAGUIDs, comma-separated). Requires a trusted attestation. |
webauthn.attestationMinCertificationLevel |
none |
The lowest FIDO certification level (L1 to L3plus). Requires a trusted attestation from a model MDS lists. |
webauthn.attestationRequireFips |
false |
Only models MDS reports as FIPS 140 certified. Requires a trusted attestation. |
Models that MDS reports as revoked or compromised are refused at registration in every case where the statement is verified.
- Hold existing passkeys to the rules too. These settings apply at
registration. To apply them at every sign-in, turn on the passkey policy’s
enforceAttestationAtSignIn. A passkey that no longer meets them is then refused (STS-AUTHN-0316), and if the metadata cannot be checked the sign-in is refused (STS-AUTHN-0317).
The passkey’s recorded attestation is shown on /portal/keys and on the
person’s console page. A statement nothing verified is shown as claimed.
What the attestation was signed with. Each registration also records the
algorithm of the attestation statement (for example ML-DSA-65 or
ES256) and of every certificate in its chain, from the leaf to the trust
anchor. The person’s console page shows it under the attestation, marked
post-quantum where every one of them is, and GET /admin-api/users?user=
returns it for each passkey as attestationAlgorithms:
| Field | What it holds |
|---|---|
recorded |
false for a passkey registered before this was recorded, or with no attestation |
verified, trusted |
whether the statement was verified, and whether it chains to a trusted root |
statement |
the statement’s own signature algorithm; empty for apple and none |
certificates |
each certificate’s signature algorithm, leaf first and the anchor last, with its family and whether it is post-quantum |
postQuantum |
true only when there is a certificate and every signature above is post-quantum (ML-DSA, SLH-DSA or composite ML-DSA) |
Under webauthn.attestationPolicy off the algorithms are what the
authenticator claimed, and verified says so. What the attestation is signed
with is recorded separately from the passkey’s own algorithm: an ML-DSA
passkey may come with an attestation certificate signed with a classical
algorithm, and an ES256 passkey with an ML-DSA one. attestationAlgorithms
is about the attestation; postQuantumRequired is about the passkey. To require a post-quantum
attestation, see
Requiring a post-quantum attestation.
RFC 4998 evidence for classical attestations
Optionally, set webauthn.attestationEvidenceTsaUrl to an HTTPS RFC 3161
timestamp service and webauthn.attestationEvidenceTsaTrustAnchors to its
PEM trust anchor(s) (#947).
For each newly registered classical attestation that this service verifies
and trusts, it timestamps the exact attestation bytes and the verified
assessment, then stores the RFC 4998 EvidenceRecord on that passkey. A missing
TSA configuration leaves evidence recording disabled. When enabled, a TSA
failure refuses registration rather than saving an unattested evidence record.
The daily passkey report recount renews timestamp chains before the timestamp
certificate approaches expiry. Set
webauthn.attestationEvidenceHashAlgorithm to sha384 or sha512 to migrate
records by RFC 4998 hash-tree renewal; the recount adds a new chain covering
the archived object and all previous chains. Admins can download a passkey’s
evidence from its person page or
GET /admin-api/passkey-report/evidence?username=<name>&credentialId=<id>.
The export contains the RFC 4998 DER record, the archived object and the
timestamp tokens. Evidence is not backfilled for existing passkeys. The
default digest is SHA-256. The TSA URL and trust anchors are available under
Protocols → WebAuthn and in GET /admin-api/webauthn; the TSA URL must use
HTTPS, and its signing certificate must chain to the configured anchors and
have the critical id-kp-timeStamping purpose.
Enterprise attestation
Enterprise attestation (WebAuthn Level 3 section 5.4.7) is an attestation that may identify the individual authenticator, for example by its serial number. A browser passes the request through only for an RP ID its own policy allows, and an authenticator answers it only for an RP ID it was provisioned for, so this service asks for it only where you list the RP ID.
- List the RP IDs in
webauthn.attestationEnterpriseRpIds, comma-separated (for examplests.example.com). These must be the RP IDs your browsers and authenticators are provisioned to release enterprise attestation for. - Set
webauthn.attestationtoenterprise. Both are on Protocols → WebAuthn; savingenterprisewith the list empty is refused (STS-AUTHN-0500), and so is emptying the list whileenterpriseis chosen. Saving both in one form, or in onePOST /admin-api/config/set-many, works.
A registration at a listed RP ID asks for enterprise. At any other RP ID
it asks for direct, and says so in two places: the passkey’s attestation
record carries conveyance: direct and enterpriseDowngraded: true, and the
attestation part of the registration’s webauthn.ceremony audit row
(the third part of its authenticator detail) begins enterprise not
listed, asked direct. A registration at a listed RP ID
records conveyance: enterprise.
The device serial is read only from a statement an enterprise request
produced. A serial number in the certificate of an ordinary direct
attestation is not recorded and binds nobody, so with
enterpriseSerialAttribute set, a security key registered at an unlisted RP
ID names no serial and is refused (STS-AUTHN-0321).
Binding security keys to the people they were issued to. With enterprise
attestation, the passkey policy’s enterpriseSerialAttribute lets a security
key register only for the person whose entry lists its serial number
(STS-AUTHN-0320, STS-AUTHN-0321). It needs set-up outside this service.
See Binding security keys to the people they were issued to under
the passkey policy.
Other passkey policy rows
The passkey policy has more rows than the recipes above use. Each is described under the passkey policy:
| Row | What it is for |
|---|---|
enforcePinLength, minPinLength, pinLengthOnlyIfSupported |
A minimum security-key PIN length. Only security keys configured to report it to this service’s RP ID can report one. |
userDisplayName, rpNameExtras, credentialLabel |
The names a passkey prompt and the passkey list show. rpNameExtras helps somebody with accounts in several realms tell them apart. |
aggregateDevices |
One Use passkey choice for all of a person’s passkeys (the default), or a list to pick one from. |
recoveryAssurance |
Whether getting back in without the passkey restores access at once. See Recovery. |
postQuantumRequired |
See Requiring a post-quantum passkey. |
attestationAlgorithms |
See Requiring a post-quantum attestation. |
postQuantumAllowCredentials |
While a post-quantum passkey is required: name only the post-quantum passkeys at sign-in, or name them all and refuse a classical one with a page saying why. See Narrow, or list and refuse. |
registrationAlgorithms, registrationMode |
Which algorithms a registration for this profile’s people offers, and whether ML-DSA comes first, alone, or not at all. The console calls the first Algorithms a passkey registration offers. realm, the default, uses the list on Protocols → WebAuthn; a list here replaces that list for this profile’s people only. The Metadata tab of Protocols → WebAuthn says when a profile’s list is the one in force. |
lanes |
realm by default, following webauthn.pqcAlongsideClassical. both gives this profile’s people a classical and a post-quantum passkey side by side, one gives them one lane. |
clientRules |
Empty by default. The browsers and systems whose registrations offer no ML-DSA. See Clients that cannot make an ML-DSA passkey. |
adminEnrolment |
Off by default. On, an administrator may enrol a passkey for a person this profile applies to. See An administrator enrols a passkey. |
deviceBoundRequired |
Off by default. On, an issuance to an application this profile covers waits for a device-bound session. See Sessions. |
Different rules for different applications or groups
Beside the default passkey policy, a realm can keep named passkey
policies. Each has every row above, plus three selectors: the applications
it applies to, the groups whose members it applies to, and a precedence from
1 to 1000. At a sign-in, the named policy that lists the application, or a
group the person is in, applies. Where several match, the lowest
precedence number wins. Where none matches, default applies.
Registration on /portal/keys has no application, so only group selectors
apply there.
Console. Directory → Policies → Passkey policy → New policy. Give it a name, list the applications (the Find box searches the realm’s applications) and groups, set the precedence and the rows, and save. An application’s own page shows which passkey policies select it (The passkey policy that applies to it).
API. Send the whole profile, with profile naming it:
api policies/save-passkey-policy '{"profile":"strict-admins",
"selectApplications":"sts-admin-console","selectGroups":"","precedence":5,
"allowUsernameless":true,"requireUsernameless":false,
"securityKeyResidentKey":"required","backupEligibility":"disallow",
"enforcePinLength":false,"minPinLength":4,"pinLengthOnlyIfSupported":false,
"enforceAttestationAtSignIn":false,"passkeyHints":"client-device,hybrid",
"securityKeyHints":"security-key","signInHints":"none",
"enterpriseSerialAttribute":"","userDisplayName":"","rpNameExtras":"none",
"credentialLabel":"","aggregateDevices":true,"postQuantumRequired":"off",
"registrationAlgorithms":"realm","registrationMode":"realm",
"adminEnrolment":"off","deviceBoundRequired":false}'
# Remove it
api policies/reset-passkey-policy '{"profile":"strict-admins"}'
Unlike default, a realm’s named policies are not inherited from the default
realm.
Several host names
A passkey is bound to its RP ID, which by default is the host the service
was reached on. A passkey registered at sts.example.com therefore does not
work at login.example.com.
To use one passkey across sibling hosts:
- Set
webauthn.rpIdto the shared registrable domain, for exampleexample.com. It may only widen the RP ID to a suffix of the host. Anything else is refused by the browser. In product mode the service also refuses the ceremony by name (STS-AUTHN-0023). Development mode falls back to the host and logs why. - Set
webauthn.allowedOriginsto every origin the ceremony may come from, for examplehttps://sts.example.com,https://login.example.com. - If those hosts are separate listeners, set
authn.cookieDomainto the same domain, so they share one sign-in.
Every host under the RP ID can then use these passkeys, so widen it only deliberately.
Algorithms: classical and post-quantum
webauthn.algorithms is the list of signature algorithms offered to the
authenticator, in order of preference. The authenticator takes the first one
it supports. The default offers every algorithm the service can verify, with
the post-quantum ones first, strongest first:
- Post-quantum (RFC 9964):
ML-DSA-87,ML-DSA-65,ML-DSA-44(COSE -50, -49, -48), so an authenticator that can make every parameter set makes ML-DSA-87 (#1078). - Classical:
ESP256,ES256,Ed25519,EdDSA,ESP384,ES384,ESP512,ES512,Ed448,ES256K,PS256,PS384,PS512,RS256,RS384,RS512.
On Protocols → WebAuthn, the Settings tab gives each algorithm a checkbox
(offered or not) and a number (its place in the order). A passkey whose
algorithm was not offered is refused at registration (STS-AUTHN-0228).
- Select all is a switch above the list. Switching it on ticks every
algorithm the two settings below allow: not
RS1unlesswebauthn.insecureAlgorithmsis on, and only ML-DSA whilewebauthn.pqcOnlyis on. It reads those two as the form shows them, so a change you have not saved yet counts. Switching it off unticks every algorithm. - At least one must be ticked. With none ticked, Save is disabled and the
list says so. A save that sends an empty list anyway is refused
(
STS-ADMIN-0840). - The Metadata tab lists what a registration actually requests: the
offered algorithms first, numbered in the order they are sent, then the
others, each with the reason it is not offered. When the selected passkey
policy profile sets Algorithms a passkey registration offers, the profile’s
list replaces
webauthn.algorithmsfor that profile’s people, and the tab says so (#1130).
Two more settings change the list:
webauthn.pqcOnly(defaultfalse) offers only ML-DSA. A passkey made with anything else is then refused at registration. Passkeys already registered keep working.webauthn.insecureAlgorithms(defaultfalse, development mode only) also offers SHA-1’sRS1. While it is off, a passkey made withRS1does not sign in (STS-AUTHN-0294). Product mode never uses it.
What an authenticator must support. The service offers the list and
verifies whatever comes back. It does not check the browser or the
authenticator beforehand, and it keeps no list of products. Nearly every
authenticator can make an ES256 passkey; Windows Hello and some older TPMs
make RS256. Few authenticators can make an ML-DSA passkey yet. With the
default list, an authenticator that can make ML-DSA does so, and every other
one falls back to a classical algorithm, so nobody is shut out.
When the device cannot make an ML-DSA passkey
Some platforms do not simply skip an algorithm their authenticator cannot do:
they fail the whole registration. Since
#944 the passkey step at sign-in
and /portal/keys help the person when that happens.
What the person sees. When a registration that asked for ML-DSA fails with
the browser’s NotSupportedError, the page shows, in the person’s language:
- where a classical passkey is allowed: “Your device could not make a
post-quantum (ML-DSA) passkey”, and a Try again with a classical
passkey button. The retry asks for the classical algorithms only (the
configured list less ML-DSA). On
/portal/keysit starts the same kind of passkey again. - where it is not: “This service requires a post-quantum (ML-DSA) passkey, and this device could not make one. Use another device, or a security key that can.” No retry is offered.
A failure the browser reports less precisely (NotAllowedError, which is
also what a cancelled prompt or a timeout gives, or UnknownError) shows the
same guidance worded with “if”. Any other failure is reported as before. Where
the browser says, through PublicKeyCredential.getClientCapabilities(), that
it can make a passkey on a phone or another device nearby, the page adds a
line saying so. That call has no way to report which algorithms an
authenticator supports, so it cannot be used to choose the algorithms
beforehand.
When the retry is not offered, and is refused. A classical passkey is not
allowed, so the page offers no retry and the service refuses one asked for
anyway (STS-AUTHN-0420), when:
| Where | Refused when |
|---|---|
/portal/keys |
the person’s passkey policy has registrationMode: pq-only, or that row is realm and webauthn.pqcOnly is on, or postQuantumRequired is registration-and-sign-in |
| the passkey step at sign-in | as above, or postQuantumRequired is sign-in for this application (the new classical passkey would not sign the person in) |
registrationMode: pq-first still offers the classical retry where the
realm’s webauthn.pqcOnly is on. classical-only never asks for ML-DSA,
so this failure does not arise. See
the passkey policy.
With both lanes on, the retry creates a passkey in the classical lane.
Over the API. POST /authn-api/mfa/{id}/webauthn/options and the Account
API’s POST /account-api/keys (action: begin) take algorithms:
"classical" for the retry. The armed ceremony’s fallback says whether it
asks for ML-DSA (asksPostQuantum), whether the retry may be offered
(classical), and whether this ceremony is the retry (retrying). See the
sign-in API and the Account API.
See what was registered. Each passkey’s algorithm is shown on
/portal/keys (Details), in the Algorithm column on the person’s
console page (marked post-quantum or insecure), and on the audit log’s
session.start row of every sign-in it made (credentialAlgorithm).
Clients that cannot make an ML-DSA passkey
The retry above helps after the failure. Where you already know a client fails, you can stop the failure happening: a registration from that client is offered the list less ML-DSA from the start (ES256 and RS256 where that leaves nothing), exactly as if the person had pressed the retry. There are two ways to name such a client, and both can be used together.
A client is a browser and an operating system, by family and major
version only, written the way the passkey ceremony rows write it:
Chrome 141 on macOS 15, Firefox 144 on Linux. It is read from the
browser’s User-Agent and nothing else. Look at the client field of the
webauthn.ceremony rows in the audit log
(below)
to see what your people’s browsers are called.
Configured rules. The passkey policy’s clientRules row, on the
default profile and on each named profile, empty by default. It holds up to
20 rules separated by ;. Each rule is <browser> on <system>. Each side
is a family, or * for any, and may be followed by a major-version range:
141, 130-140, 130- (130 and later) or -140 (140 and earlier).
Families are matched without regard to case.
Firefox 140- on Linux; * on Android 13
A range needs a version to match, so Chrome 130- on * does not match a
client whose version could not be read. Set the row on
Directory → Policies → Passkey policy, or include it in
POST /admin-api/policies/save-passkey-policy:
curl -s -X POST "$STS/admin-api/policies/save-passkey-policy" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"profile":"default", ... ,"clientRules":"Firefox 140- on Linux"}'
A save is refused (STS-AUTHN-0309) with the first rule it could not read.
Learned clients
The realm can also learn which clients fail. Turn on
webauthn.clientLearning (per realm, off by default) on
Protocols → WebAuthn or with POST /admin-api/config/set. While it is
on:
- A registration that asked for ML-DSA and failed in the browser with
NotSupportedErroris counted against its client. Other failures (NotAllowedError, which is also a cancel or a timeout, and the rest) are not counted, because they say nothing about the algorithm. - Each person counts once however often they try, so one account cannot stop ML-DSA being offered to everybody else on the same browser.
- When
webauthn.clientLearningFailures(default 3) different people have failed this way from one client withinwebauthn.clientLearningWindowHours(default 168, one week), and no registration from it has succeeded, the client is learned: its registrations offer no ML-DSA from then on. The audit log gets onewebauthn.client.learnedrow. - A registration from a learned client that asked for ML-DSA and succeeded
(where no rule applied, for example under
registrationMode: pq-only) removes the entry, and so does clearing it. - Turning the switch off stops counting and stops applying what was learned. The entries are kept, and apply again if it is turned back on.
The learned clients are listed on Directory → Policies → Passkey policy → Learned clients, learned ones first, with the clients still being counted after them. Each has a Clear button, and Clear every learned client clears them all. Over the API:
| Operation | What it does |
|---|---|
GET /admin-api/policies?clientsPage=1&clientsPer=25 |
passkey.clients: whether learning is on, the two settings, and a page of rows (client, learned, learnedAt, learnedFailures, failures in the window, firstAt, lastAt) with paging |
POST /admin-api/policies/clear-passkey-client |
{"client":"Chrome 141 on macOS 15"}: clear one entry. One that is not listed is refused (STS-AUTHN-0477). |
POST /admin-api/policies/clear-passkey-clients |
{}: clear every entry |
The scheduler job passkeys.client-learning-sweep runs hourly in each
realm and removes the entries that were never learned and whose failures
have all aged out of the window. A realm keeps at most 500 entries. When it
is full of learned clients, a new client is not counted until one is
cleared (STS-AUTHN-0478 in the service log).
Where neither kind applies
The offer is never narrowed where a classical passkey is not allowed — the
same cases that refuse the retry (the table above): registrationMode:
pq-only, webauthn.pqcOnly, postQuantumRequired:
registration-and-sign-in, and at the passkey step at sign-in
postQuantumRequired: sign-in. It is not narrowed in the post-quantum lane
either (with both lanes
on, the person chose an ML-DSA passkey), nor for the
upgrade or the post-login migration, whose
point is ML-DSA. An administrator’s enrolment on a person’s behalf is not
narrowed. Failures at all of these still count towards learning.
What the ceremony row says
The offered field of the webauthn.ceremony row lists the algorithms the
registration actually offered. Where a rule named the client, a | and what
the rule did follow:
offered ends with |
Meaning |
|---|---|
\|configured: Firefox 140- on Linux |
the configured rule left ML-DSA out |
\|learned: Firefox 144 on Linux |
the learned client left ML-DSA out |
\|not applied (webauthn.pqcOnly): configured: ... |
a rule named the client and was not applied, and why |
A classical and a post-quantum passkey side by side
This section describes the setting webauthn.pqcAlongsideClassical,
added by #643, and the
passkey policy’s lanes row, which sets it per population
(#1104): realm (the default)
follows the setting, both gives the profile’s people two lanes even where
the setting is off, and one gives them one lane even where it is on.
webauthn.pqcOnly still means one lane, and lanes: both is refused beside
registrationMode: classical-only (STS-AUTHN-0476).
What it is, and why you would want it
It lets one person hold two passkeys of different kinds at once, often on the same authenticator:
- a classical passkey (ES256, RSA and the like), which nearly every authenticator, browser and password manager supports; and
- a post-quantum passkey (ML-DSA), which a quantum computer is not expected to be able to break.
You would turn it on to start moving people to post-quantum passkeys without losing the classical one they can use everywhere. The person can sign in with either. Later you can require the post-quantum one, for everybody or for some applications (next section).
Why a setting is needed for this. Without it, a person cannot hold both on one authenticator. Every passkey is created under the person’s one user handle. An authenticator replaces the passkey it already holds for this service when asked for another under the same handle, and the service asks for ML-DSA first. So by default a person holds one kind or the other on a given device.
How it works
With two lanes, each new passkey is created in a lane:
| Classical lane | Post-quantum lane | |
|---|---|---|
| Algorithms offered | webauthn.algorithms without ML-DSA (ES256 and RS256 if that leaves none) |
ML-DSA only: the ML-DSA algorithms webauthn.algorithms names, or all three if it names none |
| User handle | the person’s usual one | a second one, created the first time and then kept |
| Name the authenticator and password manager show | the username, for example alice |
alice+ml-dsa, then alice+ml-dsa-65 (see below) |
| Display name shown | the person’s name | the person’s name followed by (ML-DSA) |
| Passkeys the browser is told to skip | the person’s classical passkeys | the person’s post-quantum passkeys |
Because each lane has its own user handle, the authenticator and the password manager treat the two as two separate accounts. That is what lets both fit on one device.
Turning it on
It is a setting per realm, off by default. To give two lanes to some
people only, leave the setting off and set lanes to both on a
named passkey policy
that selects them (or set the setting on and lanes to one for the people
who should not have two).
Console. In the realm, open Protocols → WebAuthn, turn on Post-quantum passkey alongside a classical one, and save.
API.
api config/set '{"key":"webauthn.pqcAlongsideClassical","value":true}'
# In a trust realm
curl -sk -X POST "$BASE/realm/acme/admin-api/config/set" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key":"webauthn.pqcAlongsideClassical","value":true}'
Or set STS_WEBAUTHN_PQC_ALONGSIDE_CLASSICAL=true for the whole service.
It cannot be on together with webauthn.pqcOnly: a save that would turn
both on is refused (STS-AUTHN-0353). Turn webauthn.pqcOnly off first. If
both reach the service another way (an environment variable or a file),
pqcOnly wins and there is only one lane.
The classical lane offers the classical algorithms webauthn.algorithms
names. If it names only ML-DSA, the classical lane offers ES256 and
RS256.
What the person does
Every place a passkey is created asks Classical or post-quantum? where the person has two lanes. The choices are A classical passkey (“ES256 or RSA — what nearly every authenticator can make”) and A post-quantum passkey (“ML-DSA, listed as a second account ending in +ml-dsa. Few authenticators can make one yet.”).
On the portal, to hold both:
- Sign in and open
/portal/keys. - Under Add a passkey, Classical or post-quantum?, choose A classical passkey. Press Create a passkey or Use a security key, and create it.
- Back on Add a passkey, choose A post-quantum passkey, and create it with the same authenticator or another one.
The list now shows two passkeys. Under each one’s Details, the
Cryptography row says classical or “post-quantum (ML-DSA), listed as a
second account ending in “+ml-dsa””, and the Algorithm row names the
algorithm.
The Cryptography row is read from the passkey’s algorithm: an ML-DSA
passkey says post-quantum and any other says classical. Whether it was
made as a second account is added for a post-quantum passkey in that lane.
So in a realm that does not offer the two side by side, an ML-DSA passkey
says “post-quantum (ML-DSA)” with no mention of a second account.
The same question appears on the other doors that create a passkey:
- the activation link (
/portal/activate), when the person chooses a passkey; - the sign-in screen’s passkey step (
/authn/webauthn) when it registers a passkey, including the set-up step/authn/mfa-setupthat a required second factor leads to.
Each door creates one passkey. To get the second one, the person goes to
/portal/keys afterwards.
With the Account API, the lane is the lane member (classical or pqc)
of the begin action:
# with a token for the person (see account-api.md)
curl -sk -X POST "$BASE/account-api/keys" -H "Authorization: DPoP $AT" \
-H "DPoP: $PROOF" -H 'content-type: application/json' \
-d '{"action":"begin","kind":"passkey","lane":"pqc"}'
GET /account-api/keys lists the lanes the realm offers in enrol.lanes.
How the post-quantum passkey is named
- When it is created, the service cannot yet know which ML-DSA algorithm
the authenticator will choose, so the account is created as
<username>+ml-dsawith the display name<name> (ML-DSA). - Afterwards,
/portal/keysuses the WebAuthn Signal API to tell the person’s credential manager the exact algorithm, for examplealice+ml-dsa-65. It does this when every post-quantum passkey the person holds uses the same algorithm. If they hold more than one ML-DSA variant, the name stays<username>+ml-dsa. Whether the credential manager shows the new name depends on whether it supports the Signal API. - The Signal API is sent once per lane, so a passkey removed on the portal also disappears from the credential manager in either lane.
The name is never a username. The person signs in as alice, never as
alice+ml-dsa. A name typed at the sign-in screen that ends in +ml-dsa,
+ml-dsa-<n> or +pqc is refused, in every mode (STS-AUTHN-0354).
How sign-in picks between them
Nothing special is needed. Either passkey signs the person in, with or without the username:
- With the username typed, the request lists all of the person’s passkeys, so the browser offers whichever one is present.
- With no username, the browser’s account chooser shows both accounts
(
aliceandalice+ml-dsa…). Either one names the same person. - With the passkey policy’s
aggregateDevicesoff, a person with more than one passkey picks one from a list by its name first.
The audit log’s session.start row records which kind was used:
credentialAlgorithm (for example ML-DSA-65 (-49)) and credentialLane
(classical or pqc).
webauthn.maxKeysPerPerson (default 10) counts both lanes together.
Checking that it worked
| Where | What to look for |
|---|---|
| Protocols → WebAuthn | Post-quantum passkey alongside a classical one is on in this realm. |
GET /admin-api/mfa |
webauthn.pqcAlongsideClassical is true, and webauthn.lanes lists both lanes with each lane’s algorithms. |
| the person’s page, Directory → Users | In Passkeys (WebAuthn), the post-quantum passkey’s Algorithm is an ML-DSA algorithm marked post-quantum and post-quantum lane, beside a classical passkey. |
GET /admin-api/users?user=<username> |
Each passkey’s algorithm has name, postQuantum and lane (classical or pqc). |
/portal/keys |
Details shows Cryptography for each passkey. |
| Monitoring → Audit log | session.start rows carry credentialLane and credentialAlgorithm. |
Turning it off again
While the setting is off, a post-quantum passkey already registered goes on
signing in. No new one can be created in the post-quantum lane
(STS-AUTHN-0351). Every new passkey is created as before, with ML-DSA first
in the list.
Pitfalls
- An ML-DSA passkey made before you turned the setting on counts as classical-lane. It was created under the person’s usual user handle. It is still post-quantum for the purpose of requiring a post-quantum passkey, which looks at the algorithm. But on that same authenticator, creating a classical passkey is refused by the browser, because the authenticator already holds a passkey in that lane. Remove the old one first, or create the classical one on another authenticator.
- The two passkeys look like two accounts in the person’s password
manager. Tell people that
alice+ml-dsais the same account asalice. - Few authenticators make ML-DSA passkeys (see where ML-DSA passkeys are expected to work).
- Requiring a post-quantum passkey at registration changes the question.
While
postQuantumRequiredisregistration-and-sign-in, only the post-quantum lane is offered, and the pages do not ask. - The maximum counts both lanes. A person at
webauthn.maxKeysPerPersoncannot add the second kind until they remove one (STS-AUTHN-0067). - It cannot be combined with
webauthn.pqcOnly(STS-AUTHN-0353).
Where ML-DSA passkeys are expected to work
Support for ML-DSA passkeys depends on the whole chain: the browser, the operating system, the passkey provider or security key, and how the authenticator is reached. A browser that supports WebAuthn does not necessarily make or use an ML-DSA passkey, and the service cannot ask beforehand: WebAuthn has no way to report which algorithms an authenticator supports. This section describes the kinds of failure to plan for, what the service does about each, and how to find out which ones your own people meet (#1113). It does not list products or versions, because support is changing quickly and such a list would soon be wrong. Measure your own realm instead (below).
The service verifies ML-DSA-44, ML-DSA-65 and ML-DSA-87 (RFC 9964) at registration and sign-in, and its tests cover all three with a software authenticator. No real platform authenticator or security key has been tested by this project.
The kinds of failure
| What happens | What the person sees | What the ceremony row shows |
|---|---|---|
| The whole registration fails instead of falling back to a classical algorithm. Some platforms do not skip an offered algorithm they cannot do; they abandon the ceremony. | An error, or nothing, after the prompt. | outcome refused, clientError NotSupportedError (sometimes NotAllowedError or UnknownError), offered starting with ML-DSA. |
| The offered order is not honoured. The client makes a classical passkey even though ML-DSA was offered first. From the service’s side this looks the same as an authenticator that cannot make ML-DSA. | Nothing unusual: a passkey is registered. | outcome success, an ML-DSA offeredFirst, a classical algorithm. |
A security key with no PIN set refuses to make a credential. The service asks for user verification by default (webauthn.userVerification is required, and always for a sign-in with no username), and a key with no PIN or biometric cannot give it. Some browsers ask the person to set a PIN first; others end the ceremony. |
A prompt to set a PIN, or an error. | attachment cross-platform, outcome refused, clientError NotAllowedError or InvalidStateError. |
The key or signature is too large for the transport. ML-DSA public keys are 1,312 to 2,592 bytes and signatures 2,420 to 4,627 bytes, against about 70 bytes for an ES256 signature. A transport with tight message limits (some NFC, Bluetooth or cross-device paths) may fail or time out. |
An error or a timeout, often only with one way of reaching the key. | transports names the path; compare outcome by transports and algorithm. |
| Ceremonies are slower with the larger parameter sets. ML-DSA-87 keys and signatures are about twice the size of ML-DSA-44’s, and some authenticators take noticeably longer to make them. | A longer wait at the prompt. | durationMs, compared by algorithm over many rows. |
What the service does about each
- A registration that fails outright: the classical retry. Where a
classical passkey is allowed, the page offers Try again with a
classical passkey after a
NotSupportedError(and words the same advice with “if” after a less precise error). See when the device cannot make an ML-DSA passkey (#944). - Clients known to fail: leaving ML-DSA out of the offer. The passkey
policy’s
clientRules, and the clients a realm learns from failed registrations, leave ML-DSA out of a registration’s offer for a browser and operating system known to fail, so the person never meets the failure; never where a classical passkey is not allowed. See clients that cannot make an ML-DSA passkey (#1112). A ceremony row narrowed this way shows the rule in the report’sofferRulecolumn. - The passkey policy’s registration modes decide what a group of
people is offered.
pq-firstoffers ML-DSA first and the classical algorithms after it, so a client that can make ML-DSA does and the others fall back.pq-onlyoffers only ML-DSA and refuses the classical retry: use it only for people whose devices you know work.classical-onlyoffers no ML-DSA, for a group whose devices fail the whole registration. Profiles can be selected per group or application, so you can move a pilot group first. See the passkey policy. - The order of the offer. The algorithms are offered in the order set
on Protocols → WebAuthn (
webauthn.algorithms) or in a profile’sregistrationAlgorithms, and the Metadata tab of Protocols → WebAuthn lists them in that order, numbered. The default putsML-DSA-87first. PuttingML-DSA-44first instead asks for the smallest keys and signatures, which helps on tight transports and slow authenticators; a profile can do that for one group only. - A person who has an ML-DSA passkey but keeps using the old one.
While a post-quantum passkey is required at sign-in,
postQuantumAllowCredentials: list-and-refuselets the person pick any of their passkeys, then refuses a classical one with a page that says why and links to adding a post-quantum passkey. See narrow, or list and refuse (#946). - A security key with no PIN. Nothing in the service sets a PIN. Tell
people to set one with their key’s own tool before registering; lowering
webauthn.userVerificationis not the answer, because it weakens every passkey in the realm.
How to find out for your realm
Every registration and sign-in writes a ceremony audit
row,
and the passkeyCeremonies source in /report
reads them as a table you can filter, group and schedule
(#1107,
#1108). Its page lists
three predefined queries:
- ML-DSA registrations that failed, by client: where the whole
registration fails. Group by
clientErrorto see how. - Registrations that asked for ML-DSA and made a classical passkey, by client: where the client cannot make ML-DSA, or does not honour the order.
- Classical sign-ins by people who hold an ML-DSA passkey: who requiring post-quantum at sign-in would refuse today.
For the other failure kinds, start from any of them and change the
filter: attachment cross-platform with clientError NotAllowedError
for security keys, transports for the transport, and a select of
client, algorithm and durationMs sorted by durationMs for slow
ceremonies. The client column is the browser and operating system by
name and major version, so the answer is per client family, not per
device.
The audit ring holds only the latest audit.maxEvents rows. To keep a
history, save a definition with a schedule and keep its reports. The
realm report counts who
holds which kind of passkey now, and the
passkeyHistory source
(#1109) shows how that
count moved day by day. Before requiring ML-DSA, run both against
a pilot group.
Requiring a post-quantum passkey
This section describes the passkey policy row postQuantumRequired, added by
#718. It works with or without
the side-by-side setting above.
The row
A passkey counts as post-quantum when its signature algorithm is
ML-DSA-44, ML-DSA-65 or ML-DSA-87, as recorded when it was registered.
The algorithm decides, not the lane: an ML-DSA passkey created while
webauthn.pqcAlongsideClassical was off is post-quantum.
| Value | Registration | Sign-in |
|---|---|---|
off (default) |
any passkey | any passkey |
sign-in |
any passkey is still registered | a classical passkey does not sign anybody in (STS-AUTHN-0356) |
registration-and-sign-in |
only ML-DSA is offered, and a classical passkey is refused (STS-AUTHN-0355) |
as sign-in |
It applies to every use of a passkey: in place of a password, after a password, and the usernameless sign-in.
Turning it on
For the whole realm (console). Directory → Policies → Passkey policy
→ Default. Set the post-quantum row to sign-in or
registration-and-sign-in, and save.
For one application or one group. Create a named passkey policy that selects it, with the row set. The rest of the realm is unaffected.
API. Send the whole profile with postQuantumRequired set, as in the
named-policy example above (leave out profile for the default policy).
The save is refused while the realm offers no ML-DSA (STS-AUTHN-0357):
when webauthn.algorithms names no ML-DSA algorithm and neither
webauthn.pqcOnly nor webauthn.pqcAlongsideClassical is on. Otherwise
nobody could register a passkey that meets it.
Do this before you require it at sign-in: make sure the people it applies to have registered a post-quantum passkey, or a classical passkey that is their only way in stops working. Measuring the move below says how to find them.
What the person sees
- With the username typed, the passkey step asks the browser only for the person’s post-quantum passkeys. A person who holds none is told so before any prompt appears.
- With no username, the request cannot be narrowed. If the person picks a classical passkey, the sign-in is refused after the prompt.
- With
postQuantumAllowCredentialsset tolist-and-refuse, the passkey step lists every passkey and refuses a classical one after the prompt, with a page that says why. See Narrow, or list and refuse. /portal/keysand the console mark each classical passkey that no longer signs its owner in. The portal says: “This passkey signs with a classical algorithm, and this realm now accepts only post-quantum (ML-DSA) passkeys, so it will not sign you in. Remove it and register a post-quantum passkey.”- The first time each classical passkey is refused, a Shared Signals CAEP
credential-changeis sent. - Under
registration-and-sign-in, every new passkey is offered ML-DSA only. With the side-by-side setting on, only the post-quantum lane is offered.
Narrow, or list and refuse
With the username typed, the service can build the browser’s request in one of two ways while a post-quantum passkey is required at sign-in (#946):
| Behaviour | What the request names | What the person meets |
|---|---|---|
narrow (default) |
the person’s post-quantum passkeys only | A classical passkey is never offered. But a person whose device holds only their classical passkey gets the browser’s own “no passkey found” message, which does not say why. |
list-and-refuse |
every passkey the person holds | The person may pick a classical passkey. It is then refused after the prompt, and the sign-in screen says that this application requires a post-quantum passkey and links to Add a post-quantum passkey on the account page (/portal/keys). |
The trade-off. list-and-refuse lets a person pick a passkey that will be
refused, in exchange for a clear reason when they do. Either way, a person who
holds no post-quantum passkey at all is told before any prompt, with the same
page.
The account page link works only if the person can sign in to the portal: another way in, or a portal application whose passkey policy does not require a post-quantum passkey.
A refused passkey is not a sign-in. The refusal comes before any session,
authentication event or session.start row. It is recorded as a
session.refuse row with STS-AUTHN-0356. The passkey’s signature counter is
still advanced, as for every verified passkey, so the same answer cannot be
replayed.
Setting it.
- For the realm:
webauthn.postQuantumAllowCredentials(narroworlist-and-refuse) on Protocols → WebAuthn, or withPOST /admin-api/config/set. - For one passkey policy: the row
postQuantumAllowCredentialson Directory → Policies → Passkey policy:realm(the default, follows the setting),narroworlist-and-refuse. Over the API it is optional inPOST /admin-api/policies/save-passkey-policy; left out,realmis kept.
How it is decided: the XACML facts
Every passkey registration and sign-in asks the realm’s issuance policy
(XACML) a question: the action register-passkey or use-passkey. The built-in
policy’s rule reads the passkey policy row and the passkey’s facts. Since
#718, the question carries three more facts about the passkey, in the
resource category:
| Attribute | Type | Value |
|---|---|---|
urn:sts:xacml:passkey:algorithm |
string | the algorithm’s name, for example ML-DSA-65 |
urn:sts:xacml:passkey:post-quantum |
boolean | true for ML-DSA-44, -65 or -87 |
urn:sts:xacml:passkey:lane |
string | classical or pqc |
The row itself is urn:sts:xacml:passkey-policy:postQuantumRequired, and the
fact groups a question carries are listed in urn:sts:xacml:passkey:facts
(algorithm for these three), both in the environment category. The built-in
rule reads post-quantum and never lane, for the reason given above.
Since #946 the environment also carries
urn:sts:xacml:passkey-policy:postQuantumAllowCredentials, narrow or
list-and-refuse (the realm’s setting where the row is realm). No built-in
rule reads it. A realm’s own rule can use it, for example to treat a classical
passkey the person picked from a full list differently.
Most realms need nothing more than the row. To decide differently, for example “post-quantum required only for a certain resource”, you can write your own rule:
- Create an override of the issuance policy from its template, as in
Override a built-in policy in one realm.
Start from the template: it contains the passkey rules (
decidePasskeys). - Add a Deny rule targeted at the action
use-passkey(orregister-passkey), with a condition onurn:sts:xacml:passkey:post-quantumand onurn:sts:xacml:passkey:factscontainingalgorithm. Give it the obligationurn:sts:xacml:obligation:passkey, withurn:sts:xacml:passkey-verdictset torefuseand, optionally,urn:sts:xacml:passkey-codeandurn:sts:xacml:passkey-reason. The override you created from the template already contains the built-in post-quantum rule, one for each action, so open it in the Policy editor tab and use that rule as the starting point. - A refusal with no code of its own is recorded as
STS-AUTHN-0322.
An override built before #718 does not contain the post-quantum rule. It answers passkey questions itself, so the row has no effect in that realm. Rebuild the override from the current template. See XACML and Configuring XACML and ALFA.
Requiring a post-quantum attestation
The passkey policy row attestationAlgorithms
(#941) is about the passkey’s
attestation: the authenticator’s signed statement and the certificate
chain behind it. postQuantumRequired (above) is about the passkey’s own
algorithm. A realm moving to post-quantum passkeys may want both: an ML-DSA
passkey, attested by a chain a quantum computer cannot forge.
The row
| Value | Effect |
|---|---|
none (default) |
no rule |
post-quantum |
every signature may be any ML-DSA, SLH-DSA or composite ML-DSA one |
ml-dsa-44, ml-dsa-65, ml-dsa-87 |
that ML-DSA parameter set |
slh-dsa, composite-ml-dsa |
any SLH-DSA, or any composite ML-DSA, signature |
The value is a comma-separated list. Each signature — the statement’s, and
each certificate’s from the leaf to the trust anchor — must be one an entry
allows. ml-dsa-65 alone refuses an ML-DSA-65 attestation key whose
certificate an ML-DSA-87 root signed; ml-dsa-65,ml-dsa-87 accepts it.
- A trusted attestation is required, as for an AAGUID list: the chain
must reach a root in
webauthn.attestationTrustAnchorsor the FIDO metadata. Anybody can make an ML-DSA certificate; only a trusted one says something about the authenticator. Setting the row also makes registration ask the browser for direct attestation. - Registration: a passkey whose attestation does not meet the list is not
registered (
STS-AUTHN-0411).noneand self attestation carry no certificate and are refused. - Sign-in: only with
enforceAttestationAtSignInon, by what was recorded at registration (STS-AUTHN-0412). A passkey registered before this service recorded attestation algorithms stops signing in then. The first refusal of each passkey sends a CAEPcredential-change.
Set it on the default policy, or on a
named policy for one
application or group. Over the API it is one more field of the profile
(POST /admin-api/policies/save-passkey-policy); a save that leaves it out
keeps the default.
Check what your passkeys hold first. Few authenticators sign their
attestation with a post-quantum algorithm today. Read each person’s passkeys
(GET /admin-api/users?user=, attestationAlgorithms) before requiring
it, or nobody may be able to register.
The XACML facts
A question carrying the fact group attestation-algorithm has, in the
resource category:
| Attribute | Type | Value |
|---|---|---|
urn:sts:xacml:passkey:attestation-statement-algorithm |
string | the statement’s algorithm, for example ML-DSA-65 |
urn:sts:xacml:passkey:attestation-certificate-algorithm |
string bag | each certificate’s signature algorithm, leaf to anchor (ML-DSA-87, sha256-ecdsa, id-MLDSA65-ECDSA-P256-SHA512) |
urn:sts:xacml:passkey:attestation-post-quantum |
boolean | every one of them is post-quantum |
urn:sts:xacml:passkey:attestation-algorithm-allowed |
boolean | they meet the profile’s list (the built-in rule reads only this) |
The list itself is urn:sts:xacml:passkey-policy:attestationAlgorithms in
the environment, one value per entry. A realm’s own rule can use the raw
facts, as for the post-quantum passkey facts.
An issuance policy override built before #941 has no rule for the row:
rebuild it from the template.
Post-login activities: moving people to post-quantum passkeys
Requiring a post-quantum passkey
refuses a classical one. Before you turn that on, people need an ML-DSA
passkey, and the way to get them one is to ask them when they sign in. A
post-login activity is something a realm asks a person to do after
they sign in, before they are sent back to the application. The one
activity today, pqc-passkey-migration, asks them to register an ML-DSA
passkey.
This section walks through it. Every field, audit row and error code is on Post-login activities, the reference.
Who is asked
- A person holding a classical passkey and no ML-DSA one is asked for
an ML-DSA passkey. All their passkeys are asked about at once: a passkey
has no role, so one ML-DSA passkey is enough. (Until #1135 each role was
asked about separately,
primaryfirst.) - A person with no passkey is not asked unless the profile says so:
noPasskeyinvite(they can always skip) orrequire(they can skip until the allotted time has passed). The profile’snoPasskeyRole, which chose the role of that passkey, is gone since #1135. - A person whose passkeys are all ML-DSA is not asked, unless
the profile’s
attestationispost-quantum. Then an ML-DSA passkey counts only when its recorded attestation is post-quantum too, and a person whose ML-DSA passkey has a classical attestation (or none recorded) is asked for another one beside it. See Requiring a post-quantum attestation for what a post-quantum attestation is.
What counts as post-quantum is the passkey’s algorithm, never its lane: an ML-DSA passkey made in a realm with one lane counts.
Nobody is asked where the realm cannot make an ML-DSA passkey:
WebAuthn off, no use of a passkey accepted (webauthn.primaryAllowed and
webauthn.mfaAllowed both off, or both of the authentication policy’s
passkey rows), no ML-DSA algorithm offered, or a passkey profile
that refuses the post-quantum lane (registrationMode: classical-only). The
default webauthn.algorithms offers ML-DSA, so a realm with the default
settings can be asked. A sign-in is never held for something the person
cannot do there.
Turning it on, and for whom
The policy is off by default in every realm. Open Directory →
Policies → Post-login activity policy, or use
POST /admin-api/policies/save-postLogin-policy (send the whole profile,
as for the other policies; GET /admin-api/policies, the postLogin
member, has every field’s current value).
- The
defaultprofile reaches everybody in the realm. A realm without its own follows the default realm’s. - A named profile reaches the members of the groups in its
selectGroups. Where several reach one person, the lowestprecedence(1 to 1000) wins. Post-login profiles select by group only, never by application: the activity is about the person, not what they signed in to. - To leave a group out, give it a named profile with
enabledoff and a lower precedence than the profile that would otherwise reach it. That keeps them out of a started activity too (below). - Or start the activity for some groups instead of switching the policy on: a started activity on Directory → Post-login activities asks the members of its groups (or the whole realm) at sign-in, under the rules of the profile the policy selects for each person, even where that profile is off. See Reminders by mail.
A rollout usually goes like this:
- Count first. On Monitoring → Passkeys, see how many people hold only classical passkeys, and on which authenticator models (below).
-
Start with a pilot group. A named profile for the group,
enabledon, with no allotted time (graceDays0), so everybody can skip:api policies/save-postLogin-policy '{"profile":"pqc-pilot", "selectGroups":["pqc-pilot"],"precedence":10, "enabled":true,"activity":"pqc-passkey-migration", "snoozeDays":7,"graceDays":0,"noPasskey":"leave", "method":"automatic","attestation":"any", "noPasskeyFallback":"none"}' - Watch where it fails in the
passkeyCeremoniesreport source (below), and fix what you can before widening it. - Set a deadline. Give
graceDaysa value. From each person’s first invitation they have that many days; after that Skip is gone and the sign-in waits until the passkey is registered. - Remind the people who rarely sign in by mail.
- Require it. When the people who hold only classical passkeys are
near zero, set
postQuantumRequiredon their passkey profile. Do not do it earlier: a person whose only way in is a classical passkey is refused at the passkey step, before any post-login activity can ask them.
At the sign-in
- They sign in at the sign-in screen as usual: a password, a passkey, a one-time code, an emailed code, and so on.
- If they are due the activity, the session is held, not started. The
page
/authn/post-login, Register a post-quantum passkey, says why they are asked and, where there is a deadline, until when they can skip. It has Register a post-quantum passkey, Skip for N day(s) while skipping is still allowed, and Start over. - Skip puts the activity off for the profile’s
snoozeDays, and the held session starts. -
Register goes to the passkey page. Only ML-DSA is offered, and there is no classical retry, except for a person with no passkey under
noPasskeyFallback(below). How a classical passkey is moved is the profile’smethod:methodWhat happens upgradeThe person uses their classical passkey once, then creates the ML-DSA passkey on the same authenticator. It replaces the classical one in place, there and here: one passkey, nothing left to clean up. See Upgrading a classical passkey. alongsideAn ML-DSA passkey is added beside the classical one: in the post-quantum lane where the person’s passkey profile has two lanes, or on a different authenticator where it has one (the page says which). automatic(default)alongsidewhere the person’s passkey profile has two lanes,upgradewhere it has one.A person asked only because of the attestation rule always adds the new passkey alongside: there is nothing classical to replace.
- When the passkey is registered, the held session starts and the person
goes back to the application. The session keeps the
amrandacrof the sign-in they had already completed; the new passkey does not change them.
A person with no passkey whose device cannot make ML-DSA. With the
profile’s noPasskeyFallback set to classical, a failed ML-DSA
registration is followed by Try again with a classical passkey, where
the person’s passkey profile allows a classical one
(when the device cannot make an ML-DSA passkey).
A classical passkey is better than a password, and its holder is asked to
migrate it later like everybody else. The default, none, offers ML-DSA
only.
Any other device that cannot make it. The browser’s error sends the
person back to /authn/post-login, which names the error and says what to
do: register on a separate security key that supports ML-DSA, or ask an
administrator; and Skip, while it is still allowed. An administrator can
upgrade a classical passkey from the
person’s page, or, where the person’s passkey profile has adminEnrolment
on, enrol one. Each such failure
writes an authn.post-login.unsupported audit row with the error name, the
browser and the platform. The passkey policy’s clientRules and learned
clients do not narrow this registration: its point is ML-DSA.
Sign-ins that do not use the sign-in screen — a federation partner, a
wallet, Kerberos, a client certificate — cannot show the page. An optional
activity does not interrupt them. A required one refuses them
(STS-AUTHN-0473), and the person is told to sign in at the sign-in screen.
Reminders by mail
The activity is asked only at a sign-in, so somebody who rarely signs in may
reach a deadline without being asked. An administrator can start the
activity on Directory → Post-login activities
(/admin/post-login-activities, or the /admin-api operations of the same
name), optionally only for the members of groups chosen by search. While it
runs, those people are asked at their next sign-in whether or not the
policy is on (a named profile switched off still leaves its group out), and
the people it is due for are mailed every so many days, each person
once a round, up to a maximum per person, until it is paused or ended.
The mail only asks the person to sign in. Its one link carries no code and
nothing about the person, it goes to the address on their entry, and a
person may decline it on /portal/email. A mail is not an invitation: the
allotted time still starts at the first sign-in that asks them. The fields
and the scheduler job are in
Post-login activities.
Watching progress
Three sources in the reports application (/report) follow
the move. Each comes with predefined queries you can run, change, save and
schedule:
| Source | What it answers |
|---|---|
postLoginProgress |
Where each person stands: first asked, how often asked and skipped, done, when their allotted time ends, and the reminders mailed to them. Its queries: progress by activity, the people past their grace period, the people who skipped most. |
passkeyHistory |
The share of people holding an ML-DSA passkey, day by day, for the realm and for each named passkey profile (The move over time). |
passkeyCeremonies |
Every registration and sign-in, with the client and the browser’s error: where ML-DSA registrations fail, and who still signs in with a classical passkey after making an ML-DSA one (the ceremony audit rows). An upgrade’s ceremonies have the door upgrade; the activity’s other registrations, sign-in-step. |
Monitoring → Passkeys counts who holds which kind of passkey now. The
audit log has a row for each person asked, skipping and done
(authn.post-login.asked, .skipped, .done), and for each failure on a
device (authn.post-login.unsupported).
Upgrading a classical passkey
An upgrade moves one classical passkey to ML-DSA in place, on the same
authenticator. It runs in two places: at a sign-in, as the
post-login activity
when its method is upgrade (or automatic for a person whose passkey
profile has one lane), and from the console, run by an administrator on the
person’s behalf (below). There is no upgrade button on /portal/keys and no
setting that switches it on: at a sign-in, the post-login activity policy,
or a started post-login activity, is the switch.
The ceremony is two steps:
- Prove it. The person uses the classical passkey once. It is not changed.
- Register ML-DSA. The registration offers ML-DSA only and leaves that
classical credential out of
excludeCredentials, under its own user handle, so the same authenticator answers and replaces it. There is no classical retry: the point of the upgrade is ML-DSA.
The ML-DSA passkey then replaces the classical one in place: one row, keeping its name, recording when it was upgraded and from which algorithm. The person keeps one passkey, on the same authenticator, and nothing is left behind for them to clean up. Where the post-login activity adds the ML-DSA passkey alongside instead, see the methods.
The classical passkey on this service is touched only once the new registration has been verified, so a failed upgrade leaves it as it was. The authenticator itself may replace the credential it holds as soon as it creates the new one, which can be before this service has accepted the answer. A registration that fails after that leaves the classical passkey here and no classical credential on that device; the person signs in another way and is asked again.
On the console. Directory → Users, on a classical passkey, Upgrade to
ML-DSA: an administrator runs the same two steps on a person’s behalf, in
the administrator’s browser, against the person’s authenticator. The
console’s upgrade follows the person’s lanes rather than a method: with
two lanes it adds the ML-DSA passkey in the post-quantum lane and keeps the
classical one, with one lane it replaces the classical one in place. Over
the API the actions are begin-passkey-upgrade, prove-passkey-upgrade,
finish-passkey-upgrade and cancel-passkey-upgrade on
POST /admin-api/users.
A ceremony that does not complete leaves the classical passkey as it was
(STS-AUTHN-0450 when the realm cannot make an ML-DSA passkey, 0451–0458
for the steps). The new passkey meets every rule a registration does,
including postQuantumRequired and attestationAlgorithms.
A passkey that may have been replaced on its authenticator
An authenticator keeps one discoverable credential per relying party and user handle: a new one created under the same handle replaces the old one on the device, while iya-sts would keep both rows. So when a passkey is registered under a user handle another of the person’s passkeys uses, with the same AAGUID (the same model of authenticator), the older row is marked may have been replaced. It is a marker, never a deletion — two keys of one model are two devices.
/portal/keysand the console’s person page show the marker; Remove (or Clear key) deletes the row.- The marker goes away when that passkey signs somebody in.
- The “last way in” check on a removal does not count a marked passkey.
- Each marking leaves an audit row,
webauthn.key.possibly-replaced.
A passkey with no user handle recorded (registered before handles were kept)
or no AAGUID (attestation none on a platform that blanks it) is never
marked: nothing says it was the same authenticator.
Measuring the move to post-quantum passkeys
Monitoring → Passkeys (/admin/passkey-report) counts the realm’s people
as post-quantum only, classical only, both or none, every passkey by
algorithm and lane, and which authenticator models (AAGUIDs) hold which
algorithms. Admin console → Passkeys
describes every tab.
Before requiring post-quantum passkeys for a group or an application:
Moving them first is the post-login activity; this report tells you when they have moved. Keep another way in for each person until their new passkey has signed them in on their own device.
- Open Monitoring → Passkeys. On Profiles, find the named policy that selects the group, and click count its population; or type the group’s cn in Group.
- On People, the classical only count is the people the rule would refuse at sign-in. Click list them, and Export CSV for a list of them; mail reminders can ask them for you.
- On Models, see which authenticators already make ML-DSA passkeys, and which of your people’s do not.
- When classical only is near zero, set
postQuantumRequiredon that policy (above).
API.
get() { # get <path and query under /admin-api>
curl -sk "$BASE/admin-api/$1" -H "Authorization: Bearer $TOKEN"
}
# The whole realm
get passkey-report
# One group, and its classical-only people
get "passkey-report?group=engineering&list=classical-only&per=50"
# The next page: send back the cursor the answer gave
get "passkey-report?group=engineering&list=classical-only&per=50&page=2&from=<list.paging.nextFrom>"
# The same list as a file
get "passkey-report/export?group=engineering&list=classical-only&format=csv" > classical.csv
# Count the realm again now (after a bulk import, for example)
api passkey-report/recount '{}'
The reads need a token with admin:read; the recount needs admin:write.
The counts are kept as passkeys are written and recounted once a day, so they
are current without reading the directory. Under a group or profile filter
the members are read (at most 50,000). The answer’s counted says which.
The move over time
Monitoring → Passkeys shows today. To see whether the share of people
holding an ML-DSA passkey is rising, or where it stalled, read the
passkey history in the reports application
(/report).
Each time the daily recount runs, it also keeps one row for the realm for
that day: the people by kind, the passkeys by algorithm, by lane and by
attestation algorithm, and the population of each named passkey profile
that selects people by group. A recount you start with Recount now
replaces that day’s row with its newer count. The rows are kept for
webauthn.passkeyHistoryDays days (400 by default), and the same recount
removes older ones.
- Open
/report, choose the sourcepasskeyHistory, and pick the predefined query Post-quantum adoption by day. Press Run. You get one row a day: the people holding a passkey, how many of them hold an ML-DSA one, and that as a percentage (postQuantumShare). - Post-quantum adoption by passkey profile by day gives the same
numbers for each named profile’s group, so you can see which group to
remind, or which is ready for
postQuantumRequired. - Save a query as a definition and schedule it to keep a copy of the numbers outside the service, or to have it emailed.
Things to know:
- A day on which the recount did not run has no row. The recount is a
scheduler job (
passkeys.report-recount); if it is off, or the scheduler is paused, the history stops too. - A profile’s numbers cover at most 50,000 of its members, as the
profile filter on Monitoring → Passkeys does;
completeis false on a day when there were more. At most 25 profiles are counted a day; the rest appear withcountedfalse. - The numbers are the realm’s at the moment of the recount. Passkeys registered later that day appear in the next day’s row.
Watching the move to post-quantum passkeys: the ceremony audit rows
Every passkey registration and sign-in at the sign-in screen, the
usernameless sign-in, /portal/keys, an activation link, an upgrade, an
administrator’s enrolment and a device link on /portal/devices writes one
audit row, webauthn.ceremony, whether it completed or not
(#943,
#1108). The
passkey ceremonies source in /report
reads them back as a table you can filter and group. Find them on
Monitoring → Audit log or with
GET /admin-api/audit?action=webauthn.ceremony (add actor= for one
person, outcome=refused for the ones that failed). The row’s detail:
| Field | What it holds |
|---|---|
ceremony |
registration or assertion |
door |
sign-in-step, usernameless, portal, activation, upgrade, admin-enrolment or device-link |
role, lane |
for a sign-in, the step the passkey answered: primary (passwordless) or mfa (after a password); empty for a registration, since a passkey is registered with no role (#1135). classical or pqc |
application |
the application the sign-in was for (its client or relying-party id); absent at /portal/keys, an activation link, a device link and an administrator’s enrolment |
durationMs |
milliseconds from the ceremony’s options being issued to its response arriving, measured by the service (never reported by the browser). It includes the time the person spent at the prompt, so compare many rows, not one |
offered |
the algorithms offered, in order: for a registration, the list the browser was given (pubKeyCredParams); for a sign-in, the algorithms of the person’s passkeys the ceremony could use |
algorithm |
the algorithm that was used; it is post-quantum when it is one of the ML-DSA algorithms |
client |
the browser and operating system, by name and major version only (Chrome 141 on macOS 15), from the User-Agent |
clientError |
for a ceremony the browser did not complete, the error name its script reported: NotAllowedError (declined, timed out, or no passkey — the browser does not say which), NotSupportedError, InvalidStateError, SecurityError, AbortError, …; other for anything else. The error’s message is never recorded |
capabilities |
the client capabilities the browser reported as available, from a fixed list |
authenticator |
three facts separated by \|: the attachment (platform or cross-platform), the transports the browser reported (internal, hybrid, usb…), and for a registration what its attestation was signed with, leaf to anchor, with (PQ) where all of it is post-quantum, led by enterprise not listed, asked direct where webauthn.attestation is enterprise and this RP ID is not on its list. For example platform\|internal,hybrid\|ES256 |
Rows written before #1108 carry attachment, transports, attestation
and postQuantum as separate fields instead of authenticator, and a few
written in between carry the capabilities on client after a |; the
/report source reads all of them.
A refused row carries the error code the refusal was recorded under (for a
browser that gave up, STS-AUTHN-0022).
A separate row, webauthn.algorithm-mismatch, is written when a person
who holds an ML-DSA passkey signs in with a classical one, where
post-quantum was not required. Its detail names the algorithm used, the
post-quantum algorithms the person held, the door and the client.
Many of these mean people still reach for their old passkey, and requiring
post-quantum at sign-in would refuse them today.
What these rows answer:
- Is ML-DSA offered, and offered first?
offeredon registrations. - Which browsers make an ML-DSA passkey when asked? Registrations whose
offeredlists ML-DSA first, grouped byclientandalgorithm. - Which fail, and how?
outcome=refusedwithclientError:NotSupportedErrorwhere nothing on the device can make any algorithm offered,NotAllowedErrorwhere the person declined or nothing answered. - Who has a post-quantum passkey but does not use it? The mismatch rows.
No service log line is written per ceremony or per failed ceremony: the rows are the record, and a refusal’s one log line comes from the request itself.
Losing a passkey, and account recovery
Hold more than one. The simplest recovery is a second passkey on a
different device or a security key. /portal/keys says so to anybody with
only one.
Recovery codes. A person who uses a passkey as a second step can generate
single-use recovery codes on /portal/mfa. Where they hold codes, the
passkey step offers I do not have my passkey — use a recovery code, which
takes them to /authn/backup-code.
A recovery code replaces a second factor only: it does not sign in a person
whose passkey is their only credential. See
Recovery codes.
Removing a lost passkey.
- The person, on
/portal/keys, with Remove. They cannot remove their last way in (STS-AUTHN-0070): no password and no other passkey, while the realm lets a passkey sign somebody in on its own. Where it does not (webauthn.primaryAllowedoff), no passkey is a way in and the removal is not refused. - An administrator, on the person’s page under Directory → Users, with
Remove beside the passkey, or
POST /admin-api/users/clear-keywithuserandcredentialId. - Removing a passkey ends the sessions it signed in, as the first factor
or as the step after a password. The person’s other sessions are kept. A
CAEP
credential-changeis sent.
An administrator’s reset. There is no self-service reset of a second factor. On the person’s page:
- Remove every passkey (
disable-primary-keys) removes every passkey the person holds, and ends the sessions each of those passkeys signed in. Other sessions stay. - Disable all MFA (
disable-mfa) removes the authenticator app, every passkey and the recovery codes, and ends the sessions each removed factor signed in. A session a password alone signed in stays.
Both are withheld where the person has no password and passkeys may be a
first factor in the realm, and the API refuses them there
(STS-AUTHN-0161), so neither can lock anybody out.
Until #1135 the first removed only the passkeys enrolled to sign in on their own and the second only those enrolled as a second step; a passkey has no role now, so both take every passkey.
- Generate a password reset link (
issue-password-reset), or an activation link through the API (issue-activation), lets a person who lost their only passkey set up a new credential.
Whether a recovery restores access at once is the passkey policy’s
recoveryAssurance row, for a person who holds a passkey:
| Value | After a reset link, an activation link, a generated password, a recovery code or an emailed code |
|---|---|
any (development default) |
access is restored at once |
not-weaker (product default) |
the first sign-in reaches no application. The person is asked to register a new passkey, then signs in with it. |
operator-only |
as not-weaker, and the self-service doors (forgot-password, recovery codes, emailed factors) are refused. Only an administrator’s link or generated password is left. |
The codes are STS-AUTHN-0380 (refused) and STS-AUTHN-0381 (limited to
registering a passkey). See Account recovery under
the passkey policy.
Realms
- Settings and policies are per realm. Every
webauthn.*setting can be set per realm. Thedefaultpasskey policy and the authentication policy are inherited from the default realm until a realm saves its own. Named passkey policies are not inherited. - The RP ID comes from the host, not the realm. Realms on one host share
an RP ID. Each person’s passkeys are created under their own random user
handle, so a person with accounts in two realms has two separate passkeys.
rpNameExtrasadds the realm’s name to the prompt so they can tell them apart. - Some browser hints need a single realm. A usernameless sign-in tells the
browser about a passkey the service holds for nobody
(
signalUnknownCredential) only where one trust realm is defined. - Across cells, a usernameless sign-in finds only people whose home is the cell that drew the screen. Anybody else types their username.
Development and product mode
| Development | Product | |
|---|---|---|
| The ceremony (challenge, origin, RP ID, flags, signature, counter) | verified | verified |
| The password in front of a passkey | not checked | checked |
| The passwordless box, for a name with no passkey | registers a passkey on the spot | refused (STS-AUTHN-0206) |
webauthn.rpId that does not fit the host |
the host is used, and logged | the ceremony is refused (STS-AUTHN-0023) |
webauthn.attestationPolicy by-mode |
off: nothing verified, the format recorded |
verify-if-present; off cannot be set |
webauthn.insecureAlgorithms |
may be turned on | cannot be set |
recoveryAssurance default |
any |
not-weaker |
phrh |
never earned under the default attestation policy | earned by a trusted, device-bound passkey |
See also Authentication → Development and product mode and What is not checked.
Settings
Every webauthn.* setting. All can be changed while running, per realm, on
Protocols → WebAuthn or with POST /admin-api/config/set. The full
description of each is in Configuration → WebAuthn.
| Setting | Environment variable | Default |
|---|---|---|
webauthn.enabled |
STS_WEBAUTHN_ENABLED |
true |
webauthn.rpName |
STS_WEBAUTHN_RP_NAME |
IYA STS |
webauthn.rpId |
STS_WEBAUTHN_RP_ID |
empty (the host) |
webauthn.allowedOrigins |
STS_WEBAUTHN_ALLOWED_ORIGINS |
empty (derived from the address) |
webauthn.algorithms |
STS_WEBAUTHN_ALGORITHMS |
ML-DSA-87, ML-DSA-65, ML-DSA-44, EdDSA, ES256, RS256, in that order (#1166); every other algorithm may be ticked — ES384 is off by default because certain browsers on Windows are incompatible with it |
webauthn.insecureAlgorithms |
STS_WEBAUTHN_INSECURE_ALGORITHMS |
false |
webauthn.pqcOnly |
STS_WEBAUTHN_PQC_ONLY |
false |
webauthn.pqcAlongsideClassical |
STS_WEBAUTHN_PQC_ALONGSIDE_CLASSICAL |
false |
webauthn.postQuantumAllowCredentials |
STS_WEBAUTHN_POST_QUANTUM_ALLOW_CREDENTIALS |
narrow |
webauthn.clientLearning |
STS_WEBAUTHN_CLIENT_LEARNING |
false |
webauthn.clientLearningFailures |
STS_WEBAUTHN_CLIENT_LEARNING_FAILURES |
3 |
webauthn.clientLearningWindowHours |
STS_WEBAUTHN_CLIENT_LEARNING_WINDOW_HOURS |
168 |
webauthn.userVerification |
STS_WEBAUTHN_USER_VERIFICATION |
required |
webauthn.attestation |
STS_WEBAUTHN_ATTESTATION |
direct |
webauthn.attestationEnterpriseRpIds |
STS_WEBAUTHN_ATTESTATION_ENTERPRISE_RP_IDS |
empty |
webauthn.attestationPolicy |
STS_WEBAUTHN_ATTESTATION_POLICY |
by-mode |
webauthn.attestationTrustAnchors |
STS_WEBAUTHN_ATTESTATION_TRUST_ANCHORS |
empty |
webauthn.attestationEvidenceTsaUrl |
STS_WEBAUTHN_ATTESTATION_EVIDENCE_TSA_URL |
empty (disabled) |
webauthn.attestationEvidenceTsaTrustAnchors |
STS_WEBAUTHN_ATTESTATION_TSA_TRUST_ANCHORS |
empty |
webauthn.attestationEvidenceHashAlgorithm |
STS_WEBAUTHN_ATTESTATION_HASH_ALGORITHM |
sha256 |
webauthn.passkeyHistoryDays |
STS_WEBAUTHN_PASSKEY_HISTORY_DAYS |
400 |
webauthn.attestationAllowedAaguids |
STS_WEBAUTHN_ATTESTATION_ALLOWED_AAGUIDS |
empty |
webauthn.attestationMinCertificationLevel |
STS_WEBAUTHN_ATTESTATION_MIN_CERTIFICATION |
none |
webauthn.attestationRequireFips |
STS_WEBAUTHN_ATTESTATION_REQUIRE_FIPS |
false |
webauthn.attestationAllowSafetynet |
STS_WEBAUTHN_ATTESTATION_ALLOW_SAFETYNET |
false |
webauthn.attestationAndroidSoftwareKeys |
STS_WEBAUTHN_ATTESTATION_ANDROID_SOFTWARE |
false |
webauthn.timeoutMs |
STS_WEBAUTHN_TIMEOUT_MS |
60000 |
webauthn.authenticatorAttachment |
STS_WEBAUTHN_ATTACHMENT |
any |
webauthn.credProps |
STS_WEBAUTHN_CRED_PROPS |
true |
webauthn.primaryAllowed |
STS_WEBAUTHN_PRIMARY_ALLOWED |
true |
webauthn.mfaAllowed |
STS_WEBAUTHN_MFA_ALLOWED |
true |
webauthn.maxKeysPerPerson |
STS_WEBAUTHN_MAX_KEYS |
10 |
Three ceremony settings are enforced, not just requested:
webauthn.userVerification (with required, an authenticator that did not
verify the person is refused), webauthn.algorithms with webauthn.pqcOnly
and webauthn.insecureAlgorithms (a credential whose algorithm was not
offered is refused), and webauthn.rpId (checked through the RP ID hash).
The others are requests to the browser
(why).
Turning a setting off never removes passkeys people already hold, but
since #1135 the two use settings stop the use they name for every passkey.
webauthn.enabled off stops new registrations; the passkeys already held stay
listed. webauthn.primaryAllowed off means no passkey signs anybody in on its
own, and webauthn.mfaAllowed off means no passkey answers the step after a
password; with both off (or both of the authentication policy’s passkey rows)
no new passkey can be registered (STS-AUTHN-0531). Until #1135 these
settings stopped only new passkeys in a role, and a passkey already enrolled
in it went on working. Before turning webauthn.primaryAllowed off, make sure
the people who have no password have another way in.
Two related settings are not webauthn.*: authn.mfaStepTtlS (300 seconds)
is how long a person has to finish the passkey step, so a
webauthn.timeoutMs longer than that does not help; and
security.activationTtlMinutes is how long an activation link lasts.
The passkey policy’s rows are listed under
the passkey policy, and the
authentication policy’s under
the authentication policy.
webauthn.residentKey and webauthn.usernameless no longer exist: the
passkey policy’s securityKeyResidentKey and allowUsernameless replaced
them, and a configuration that still names either is refused at start.
Management API operations
Every console control has an API operation. The request bodies are in
GET /admin-api/openapi.json.
| Operation | What it does |
|---|---|
GET /admin-api/webauthn |
the webauthn.* settings and what the ceremony offers |
GET /admin-api/mfa |
who holds which factor, and the WebAuthn report (algorithms, lanes, attestation) |
GET /admin-api/passkey-report |
who holds which passkey algorithm across the realm, by kind, algorithm, lane and model, filtered by group, profile or application, with the people behind a count (list, page, per, from) |
GET /admin-api/passkey-report/export |
the people behind a count, as CSV or JSON (format) |
POST /admin-api/passkey-report/recount |
count the realm’s passkeys again now, on the scheduler |
GET /admin-api/users?user=<name> |
one person, with each passkey’s algorithm, lane, backup state, attestation and any policy refusal |
POST /admin-api/users/begin-passkey-enrolment, finish-passkey-enrolment |
enrol a passkey for a person, in the caller’s browser, when their profile’s adminEnrolment is on |
POST /admin-api/users/begin-passkey-upgrade, prove-passkey-upgrade, finish-passkey-upgrade, cancel-passkey-upgrade |
upgrade one of a person’s classical passkeys to ML-DSA |
GET /admin-api/passkey-report/evidence |
one passkey’s RFC 4998 evidence (username, credentialId) |
POST /admin-api/users/clear-key |
remove one passkey (user, credentialId) |
POST /admin-api/users/rename-key |
rename one passkey (user, credentialId, label; empty restores the default) |
POST /admin-api/users/disable-primary-keys |
remove every passkey the person holds, and end the sessions each signed in |
POST /admin-api/users/disable-mfa |
remove every second factor, and end the sessions each removed factor signed in |
POST /admin-api/users/require-mfa, stop-requiring-mfa |
require a second factor of one person |
POST /admin-api/users/issue-activation |
a single-use activation link (user, optional deliver: "mail") |
GET /admin-api/policies |
the passkey, authentication and post-login activity policies (among the others), with every field’s current value, and the learned clients (passkey.clients) |
POST /admin-api/policies/save-passkey-policy, reset-passkey-policy |
save a passkey policy (optionally named with profile), or remove it |
POST /admin-api/policies/clear-passkey-client, clear-passkey-clients |
clear one learned client (client), or all of them |
POST /admin-api/policies/save-authn-policy, reset-authn-policy |
save the authentication policy, or go back to inheriting |
POST /admin-api/policies/save-postLogin-policy, reset-postLogin-policy |
save a post-login activity profile (optionally named with profile), or remove it |
GET /admin-api/post-login-activities; POST /admin-api/post-login-activities/start, /pause, /resume, /end |
started post-login activities and their mail reminders: list them, search the groups to start one for, start one, or change its state |
POST /admin-api/config/set, /config/reset |
change or reset a setting |
The person’s own operations are on the Account API
(/account-api/keys), and the sign-in screen’s on the
sign-in API.
Error codes you will meet
A code is recorded on the audit row and in the log. It is never sent to the
browser or a client. Look a code up in the audit log with
GET /admin-api/audit?code=STS-AUTHN-0356. Every code is listed under
Error codes → STS-AUTHN.
| Code | What happened |
|---|---|
STS-AUTHN-0023 |
product mode: webauthn.rpId does not fit the host |
STS-AUTHN-0035 |
the signature counter did not go up (a possibly cloned authenticator) |
STS-AUTHN-0044 |
passkeys are off in this realm (webauthn.enabled): no new passkey is registered, and one already held goes on working |
STS-AUTHN-0045, 0046 |
a passkey was refused as a first factor (webauthn.primaryAllowed) or as a second factor (webauthn.mfaAllowed) |
STS-AUTHN-0531 |
a passkey was not registered: the realm accepts a passkey neither as a first nor as a second factor |
STS-AUTHN-0067 |
the person already holds webauthn.maxKeysPerPerson passkeys |
STS-AUTHN-0070 |
removing the passkey would remove the person’s last way in |
STS-AUTHN-0171 |
a passwordless sign-in, where a second factor is required |
STS-AUTHN-0204 |
a request demanded a passkey and got something else |
STS-AUTHN-0206 |
product mode: passwordless sign-in for a person with no passkey |
STS-AUTHN-0228 |
the passkey’s algorithm was not one of those offered |
STS-AUTHN-0294 |
a passkey with an insecure algorithm (RS1) |
STS-AUTHN-0303 |
the passkey’s user handle is not the one it was registered under |
STS-AUTHN-0309 |
a passkey policy was not saved: a field missing or not one of its values, a hint list that contradicts its request, or a clientRules rule that could not be read |
STS-AUTHN-0312, 0313 |
a synced passkey, where only device-bound ones are accepted (registration, sign-in) |
STS-AUTHN-0314, 0315 |
a security key’s PIN is shorter than the minimum (registration, sign-in) |
STS-AUTHN-0316, 0317 |
the attestation rules refused the passkey at sign-in, or could not be checked |
STS-AUTHN-0320, 0321 |
an enterprise security key whose serial is not the person’s, or unreadable |
STS-AUTHN-0322 |
a realm’s own issuance policy refused the passkey without a code of its own |
STS-AUTHN-0333 |
a username was given where only a usernameless passkey sign-in is allowed |
STS-AUTHN-0350, 0351, 0352 |
a passkey lane was refused: unknown, not offered here, or no ML-DSA available |
STS-AUTHN-0353 |
webauthn.pqcAlongsideClassical and webauthn.pqcOnly cannot both be on |
STS-AUTHN-0354 |
the typed username ends in +ml-dsa, +ml-dsa-<n> or +pqc |
STS-AUTHN-0355, 0356 |
a classical passkey, where a post-quantum one is required (registration, sign-in) |
STS-AUTHN-0357 |
a passkey policy requiring post-quantum, while the realm offers no ML-DSA |
STS-AUTHN-0380, 0381 |
an account recovery refused, or limited to registering a new passkey |
STS-AUTHN-0411, 0412 |
a passkey whose attestation is not signed with what attestationAlgorithms allows (registration, sign-in) |
STS-AUTHN-0413, 0414 |
a passkey change could not be noted in the passkey report, or its daily recount failed; the report catches up at the next recount |
STS-AUTHN-0415 |
the passkey report could not read the directory (503) |
STS-AUTHN-0416 |
a passkey report filter was refused: two at once, or a profile or application that is not here |
STS-AUTHN-0419 |
an RFC 4998 evidence record could not be renewed; the previous one is kept |
STS-AUTHN-0421, 0422 |
an evidence export named no passkey, or the passkey has no evidence record |
STS-AUTHN-0420 |
a registration retried with the classical algorithms only, where a classical passkey is not allowed |
STS-AUTHN-0430, 0431 |
a passkey policy was not saved: registrationMode classical-only contradicts postQuantumRequired or attestationAlgorithms, or pq-only / pq-first would offer no ML-DSA |
STS-AUTHN-0432 |
a passkey’s algorithm is not one the selected profile offers at registration |
STS-AUTHN-0433 |
the post-quantum lane was asked for where the profile’s registrationMode is classical-only |
STS-AUTHN-0440 |
an administrator’s passkey enrolment, where the person’s profile has adminEnrolment off. 0441–0448 are the other refusals of that ceremony |
STS-AUTHN-0450 |
a passkey upgrade, where the realm cannot make an ML-DSA passkey. 0451–0458 are the other refusals of that ceremony |
STS-AUTHN-0459 |
a sign-in that must earn phrh, where none of the person’s passkeys for the step can earn it |
STS-AUTHN-0461 |
a passkey policy requires a device-bound session while authn.deviceBoundSessions is off |
STS-AUTHN-0462 |
an issuance waited for a device-bound session the browser skipped or did not finish |
STS-AUTHN-0470–0475 |
the post-login activity policy or its step: see Post-login activities |
STS-AUTHN-0476 |
a passkey policy was not saved: lanes both with registrationMode classical-only |
STS-AUTHN-0477 |
a learned client was not cleared: none named, or not on the list |
STS-AUTHN-0478 |
the realm’s learned clients are full of learned entries, so a new client was not counted (service log) |
STS-AUTHN-0490–0495 |
the post-login activity’s mail reminders: see Post-login activities |
STS-AUTHN-0500 |
webauthn.attestation enterprise with webauthn.attestationEnterpriseRpIds empty |
STS-AUTHN-0520–0522 |
the day’s passkey history row, its pruning, or one profile’s count in it could not be written or read |
STS-ADMIN-0522 |
an administrator’s removal of a passkey was refused |
When the browser shows no prompt at all, the passkey step has a link,
My browser did not ask — tell me why, and a Technical details section
with the RP ID, the user-verification requirement and whether this step
registers or asks for a passkey. A wrong webauthn.rpId usually shows up this
way, because the browser refuses the ceremony before the service sees it.
Limits, and what is not supported
- An administrator enrols a passkey only when the passkey policy allows
it (
adminEnrolment, off by default). The ceremony runs in the administrator’s browser, against an authenticator they hold, and is verified as the person’s own registration. Nothing made elsewhere can be imported. While the row is off, give the person an activation link. - A passkey cannot be used at the password-only doors (an LDAP bind, WS-Trust, SCIM, Shared Signals, EST Basic). A person with a second factor uses an app password there.
webauthn.authenticatorAttachmentand the hints are not enforced. Use the passkey policy’sbackupEligibilityor the attestation settings to enforce what kind of passkey is accepted.- A passkey with the username typed is one factor (
acr "1"), whatever user verification the authenticator did. Only the usernameless sign-in, which requires user verification, records two. - A minimum PIN length is reported only by security keys configured to tell this service. Platform authenticators and synced passkeys report nothing.
- SAML honours the EAP class URIs. A SAML 2.0
RequestedAuthnContext, or SAML 1.1’s non-specauthnContextClassRefparameter, naming onlyphror onlyphrhis the same demand asacr_values. When the request does not name them, the assertion’s class is unchanged. A WS-Federationwauthnaming one of those URIs, and a GNAPinteract.acrofphrorphrh, are the same demand. Withacr_values=phrh(or a required detail acr ofphrh), the passkey step offers only passkeys whose recorded attestation is trusted and whose backup eligibility is clear, and says so before the ceremony when none of the held keys can earn it. A usernameless sign-in has no list to narrow and is still refused after the ceremony. - Only some authenticators make ML-DSA passkeys, and the service cannot
tell beforehand which: WebAuthn has no way to ask. It helps after a
failure (the classical retry), leaves ML-DSA out for clients known to fail
(
clientRulesand learned clients), and lets you measure your own realm. See Where ML-DSA passkeys are expected to work. - A post-quantum attestation has been tested only with a chain made for the test. No real authenticator’s post-quantum attestation has been tried; the attestation algorithms are recorded and enforced as WebAuthn and the ML-DSA certificate profile describe them.
- A ceremony row records the browser’s error name, never its message. When a browser declines a registration, the row keeps the error’s name from a fixed list and the client capabilities it reported, and the pending ceremony stays available for its retry.
- An in-place upgrade can leave the classical credential only on this service. The authenticator may replace the credential it holds as soon as it creates the new one, which can be before this service has accepted the registration. A registration that fails after that leaves the classical passkey here and none on that device (Upgrading a classical passkey).
- A person cannot upgrade a passkey on
/portal/keys. The upgrade runs at a sign-in, when the post-login activity asks for it, or from the console. The refusal page shown when a post-quantum passkey is required links to adding one on/portal/keys. - Device Bound Session Credentials (binding the session cookie to the
browser) are a separate, optional feature. A passkey profile can require
a bound session (
deviceBoundRequired), and only whileauthn.deviceBoundSessionsis on. See Sessions. - The FIDO Alliance’s conformance tools have not been run. The relying party is covered by this project’s own ceremony and attestation tests (Conformance suites).
Related
- Authentication: the sign-in service, passwords, TOTP, recovery codes, and the full reasoning behind passkeys
- Security profiles:
acr_values, step-up and phishing-resistant sign-in - Applications and named authentication
policies:
appAuthnPolicy - Devices: linking a platform passkey to a registered device, and a remembered browser skipping the second factor
- Risk scoring: the FIDO metadata, and compromised authenticator models
- XACML 3.0 and ALFA and Configuring XACML: the issuance policy that decides passkey questions
- CAEP events: what a passkey added, removed or refused sends
- Trust realms, Sessions, Error codes