feat(auth): prefer rather than require passkey user verification
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m59s

Requiring user verification made password managers such as NordPass ask
for their master password on every passkey use. Every ceremony now asks
for it as preferred and no longer rejects a result without it.

This is a deliberate trade-off: a passkey used without verification is
possession-only, so passwordless sign-in and step-up rest on the device or
vault being unlocked. The spec records the decision.
This commit is contained in:
2026-09-16 15:20:31 +00:00
parent 934501f4ec
commit 217e2dc5a9
4 changed files with 18 additions and 20 deletions
-4
View File
@@ -216,10 +216,6 @@ func HandleEnrolPasskeyFinish(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, tr := range parsed.Response.Transports {
transports = append(transports, string(tr))
+1 -1
View File
@@ -44,7 +44,7 @@ func HandlePasskeyLoginBegin(c *gin.Context) {
// Discoverable login: no allowCredentials, so the authenticator offers
// whichever resident credential it holds for this RP ID.
options, sessionData, err := w.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationRequired))
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
+11 -12
View File
@@ -67,8 +67,14 @@ func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
RPID: rpID,
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
ResidentKey: protocol.ResidentKeyRequirementRequired,
// Preferred, not required, and not checked on the result. Requiring
// it made password managers such as NordPass ask for their master
// password on every use, which other sites avoid by preferring it.
// The cost is deliberate: an authenticator that skips verification
// makes a passkey possession-only, so passwordless sign-in and
// step-up then rest on the device or vault being unlocked.
UserVerification: protocol.VerificationPreferred,
},
})
}
@@ -182,7 +188,7 @@ func HandleMFAWebAuthnBegin(c *gin.Context) {
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationRequired))
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
@@ -328,10 +334,6 @@ func HandleRegisterPasskeyFinish(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, t := range parsed.Response.Transports {
transports = append(transports, string(t))
@@ -388,7 +390,7 @@ func HandleStepUpWebAuthnBegin(c *gin.Context) {
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationRequired))
webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
@@ -435,7 +437,7 @@ func HandleStepUpWebAuthnFinish(c *gin.Context) {
}
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
// step-up, so the verification rules (user verification, clone detection,
// step-up, so the verification rules (backup eligibility, clone detection,
// instance scope) exist once.
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
var body struct {
@@ -490,9 +492,6 @@ func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauth
if err != nil {
return nil, err
}
if !cred.Flags.UserVerified {
return nil, errors.New("user verification was not performed")
}
// A counter that fails to advance is the library's clone signal. Zero on
// both sides means the authenticator does not keep one, which is normal.
if cred.Authenticator.CloneWarning {