Use case — Passwordless passkey
A passkey can be a second factor behind a password, or it can be the whole login. This page covers the second case: the user picks a credential in the browser, the credential names the account, and no password is typed. You offer it next to the password so that users without a registered device can still sign in.
Use it when you want users to move from passwords to passkeys gradually. If you only want a passkey as an extra factor after the password, put op.PrimaryPasskey in a rule instead; see MFA / step-up.
Specs referenced on this page
- WebAuthn Level 3: the credential ceremonies, user verification, discoverable credentials
- RFC 8176: Authentication Method Reference Values (
amr,hwk) - NIST SP 800-63B: Authenticator Assurance Levels (AAL)
Source:
examples/29-passkeyenrols a device on a page the example owns, then signs in with it through the OP.
The login flow
LoginFlow.Primary takes a op.StepOneOf holding the password step and the passkey step:
passkeys := op.PrimaryPasskey{
Store: st.Passkeys(),
RPID: "id.example.com",
RPDisplayName: "Example Identity",
RPOrigins: []string{"https://id.example.com"},
RequireUserVerification: true,
}
flow := op.LoginFlow{
Primary: op.StepOneOf{Steps: []op.Step{
op.PrimaryPassword{Store: st.UserPasswords()},
passkeys,
}},
}
provider, err := op.New(
/* ... */
op.WithLoginFlow(flow),
op.WithUI(op.SPAUI{LoginMount: "/login", StaticDir: "./web"}),
)At Primary the OP does not know the user yet, so it cannot check which factors the user has enrolled. It offers every step in Steps, in declaration order, through a factor-choice prompt (op/interaction.PromptTypeChooseFactor, "auth.choose_factor"). The UI submits the chosen step's kind under the factor field (interaction.FactorChoiceField). The kinds are password and passkey. A choice outside the offered kinds is refused the same way a stale state_ref is, and once a factor is chosen the attempt stays on it; restarting the login offers the choice again.
A bare Primary: passkeys also runs the passwordless ceremony, without the choice. It leaves users who have no registered device unable to sign in, so keep the password beside it until every user has enrolled.
How the passkey step behaves at Primary
Placed in a rule, op.PrimaryPasskey asserts against the credentials of a user an earlier step already identified. At Primary, or inside a StepOneOf there, it runs a passwordless ceremony with discoverable credentials:
- The prompt carries a fresh challenge and an empty
allow_credentialslist, so the browser offers any resident credential for the relying party. - The credential's stored record names the subject.
- The assertion's user handle must match that subject.
An unknown credential, and a user handle that does not match the credential's subject, are answered with the same fresh challenge, so the response does not reveal which credentials exist.
User verification is required
A passkey that stands alone is the only factor of the login. op.New therefore refuses a PrimaryPasskey at Primary, or inside a StepOneOf there, unless RequireUserVerification is true. The authenticator must perform a PIN or biometric gesture, and an assertion whose UV flag is clear is rejected.
The gesture is what makes the credential two factors in one: possession of the key plus something the user knows or is. A login through such a step reaches AAL2, and its amr is ["hwk"], the single-factor hardware-key value of RFC 8176. An RP that sends acr_values for AAL2 is satisfied by it. User verification is read from the flags of each assertion, not copied from the stored credential. A passkey step that does not require it, such as one in a rule behind another factor, counts a presence-only assertion as a single factor: amr swk, AAL1, never AAL2.
The same field applies to enrolment. Keep RequireUserVerification in the one op.PrimaryPasskey value you share with op/passkeykit, so a credential accepted at registration is usable at login.
Other StepOneOf rules at Primary:
- Every step must be able to identify the user on its own:
op.PrimaryPassword,op.PrimaryPasskeywithRequireUserVerification, or anop.ExternalStepwithoutCountFailures. A TOTP, email OTP or recovery-code step is refused there, because each needs a user a previous step already bound. IfNoneEnrolledmust stay atop.UnenrolledDeny, the zero value.op.UnenrolledSkipis refused, since no subject exists yet to check enrolment against.- A
StepOneOfcannot nest, and a kind may appear only once anywhere in the flow.
The interaction UI must run WebAuthn
The browser's navigator.credentials.get() call is script, and the assertion it produces goes back to the OP in the prompt's reply. The built-in HTML driver renders no client-side script, so no user could finish the step. op.New refuses the combination with an error that names WithUI, SPAUI and DriverUI.
Use op.WithUI(op.SPAUI{...}) with a front end that runs the ceremony and renders the factor-choice prompt, or op.DriverUI with your own interaction.Driver. A driver the library did not write is assumed capable. The SPA bundle the numbered examples share handles both prompts, which is why example 29 needs no UI of its own. The factor labels come from the i18n keys auth.factor.password and auth.factor.passkey; a factor kind without a catalogue entry shows its kind string. See SPA / custom interaction for the prompt protocol.
Passkeys need a domain, not an IP
A WebAuthn Relying Party ID must be a domain name, and browsers reject an IP literal for it. For local development, bind the issuer to localhost and set op.WithAllowLocalhostIssuerForDev(). Production relying parties belong behind https.
Enrolment is your page
The OP has no view of your account management, so it cannot offer "add a passkey". The registration ceremony runs on a page and two handlers you write, using op/passkeykit:
registrar, err := passkeykit.New(passkeys) // the same value the login flow installs
opts, session, err := registrar.Begin(ctx, passkeykit.User{
Subject: subject, Name: username, DisplayName: displayName,
})
// Send opts to the browser; keep session on the server.
record, err := registrar.Register(ctx, session, user, responseBytes)Four things stay with you:
- Authenticate the user first.
passkeykitregisters whatever subject the handler names. A registration endpoint reachable without a session lets an attacker add an authenticator to someone else's account. Require a recent sign-in, at least as strong as the factor being added. - Ferry the session. The challenge and the finish call arrive on two requests, and the
Sessionmust be integrity-protected between them. The example keeps it in a server-side map keyed by a random cookie value. Treat it as single-use and drop it after the finish call, whether it succeeded or not. - Share the relying party identity.
passkeykit.Newtakes the veryop.PrimaryPasskeyvalue the login flow uses, so the RP ID and origin list a credential is bound to cannot drift from the ones it is checked against. ChangingRPIDinvalidates every enrolled credential. - Persist the store. A passkey record that does not survive a restart is a device the user must enrol again. The in-memory store is for examples.
A user's first login has no passkey to choose, so they sign in with the password, open the enrolment page, register a device, and pick the passkey next time.
Claims for a passkey login
/userinfo and the ID token read claims from the user store. When WithUserStore is not set, the OP reads them from the Store of the PrimaryPassword in Primary or in the StepOneOf there. The store given to op.WithStore is never consulted, so a passkey-only flow, which has no PrimaryPassword, must pass op.WithUserStore with a store that returns the user record for the subject the credential names; op.New refuses to start without one. With a bundled adapter, op.WithUserStore(st.Users()) does it.
Cloning and attestation
CloneDetectionHandler receives a signal when an authenticator reports a sign counter that did not strictly increase, which is what a duplicated device looks like. The OP fails that login; the handler is where you disable the credential and tell the user. AAGUIDAllowlist restricts which authenticator models may register and requires AttestationRoots; see the warning in MFA / step-up.
Next
- MFA / step-up: passkeys as a second factor, and the other steps.
- SPA / custom interaction: the prompt protocol your UI implements.
- Start here: pick the next page by goal.