Authentication

startAuthentication and finishAuthentication, usernameless and username-first.

Signing in. Two calls, and two flows that differ only in whether you name the account up front.

startAuthentication

startAuthenticationmethod

startAuthentication(input?: StartAuthenticationInput): Promise<StartAuthenticationResult>

Issues request options. Call it with no arguments for the usernameless flow.

usernamestring
userIdstring
userVerification'required' | 'preferred' | 'discouraged'default config value

Returns

{ options: AuthenticationOptionsJSON; userId?: string }

The two flows

Usernameless: prefer this

const { options } = await passkeys.startAuthentication();

allowCredentials is omitted. The browser lists every passkey for your domain and shows a picker. The account is resolved from the user handle in the response.

Username-first: only if you must

const { options } = await passkeys.startAuthentication({
  username: 'ada',
});

allowCredentials names that account's credentials. Needed for authenticators that cannot store discoverable credentials.

Why usernameless is the recommended path

finishAuthentication

finishAuthenticationmethod

finishAuthentication(response: AuthenticationResponseJSON): Promise<VerifyAuthenticationResult>

Verifies the assertion. On success the returned user is authenticated.

Returns

{ verified: true; user; credential; signCount; userVerified }
const { user, userVerified } = await passkeys.finishAuthentication(req.body);
 
req.session.userId = user.id;
req.session.strongAuth = userVerified;

The fourteen checks

In WebAuthn §7.2 order.

#CheckFailure code
1Challenge exists, unexpired, consumed on readchallenge_not_found
2Challenge was issued for a logintype_mismatch
3Signed challenge equals the issued one, constant timechallenge_mismatch
4clientData.type is webauthn.gettype_mismatch
5clientData.origin is allowedorigin_mismatch
6Not run inside a cross-origin frameorigin_mismatch
7Credential is registeredunknown_credential
8Credential belongs to the account the ceremony was scoped tounknown_credential
9User handle in the response matches the credential's ownerunknown_credential
10rpIdHash equals SHA-256 of the configured rpIDrpid_mismatch
11User-present flag setuser_not_present
12User-verified flag set, when requireduser_not_verified
13Signature verifies over authenticatorData and the client data hashbad_signature
14Signature counter did not go backwardscounter_regression

Why checks 8 and 9 both exist

The signature check

The authenticator signs authenticatorData concatenated with SHA-256(clientDataJSON). passkify rebuilds that byte string, loads the stored COSE public key, and verifies.

Why the stored key is raw COSE bytes rather than PEM

The signature counter

Some authenticators increment a counter on every signature. If one arrives lower than the stored value, the same credential has signed twice from different states, which is what a cloned authenticator looks like.

hooks: {
  onCounterRegression: ({ user, storedCounter, presentedCounter }) => {
    logger.warn('counter went backwards', { userId: user.id, storedCounter, presentedCounter });
    return false;   // reject
  },
}

Re-authentication

For a sensitive action, run a second ceremony scoped to the signed-in account:

app.post('/settings/danger/start', requireAuth, async (req, res) => {
  const { options } = await passkeys.startAuthentication({
    userId: req.session.userId,
    userVerification: 'required',   // demand a biometric this time
  });
  res.json(options);
});

Because the requirement is recorded with the challenge, finishAuthentication enforces 'required' for this ceremony even though the server default is 'preferred'.

Next