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 checks

15 checks, in WebAuthn §7.2 order
#CheckFailure codeSpecification
1The response has the fields it must havemalformed_responseWebAuthn §7.2 step 1
2The challenge is one we issued and have not yet consumedchallenge_not_foundWebAuthn §7.2 step 11
3The challenge was issued for a login, not a registrationtype_mismatchWebAuthn §7.2 step 11
4The signed challenge equals the stored onechallenge_mismatchWebAuthn §7.2 step 11
5clientData.type is exactly "webauthn.get"type_mismatchWebAuthn §7.2 step 10
6clientData.origin is in the allow-listorigin_mismatchWebAuthn §7.2 step 12
7The ceremony did not run in a cross-origin frameorigin_mismatchWebAuthn §7.2 step 13
8The credential ID is one we have storedunknown_credentialWebAuthn §7.2 step 5
9The credential belongs to the account being signed intounknown_credentialWebAuthn §7.2 step 6
10The user handle, when present, names the credential ownerunknown_credentialWebAuthn §7.2 step 6
11The RP ID hash matches this relying partyrpid_mismatchWebAuthn §7.2 step 15
12The user-present flag is setuser_not_presentWebAuthn §7.2 step 16
13The user was verified, when the ceremony required ituser_not_verifiedWebAuthn §7.2 step 17
14The signature verifies against the stored public keybad_signatureWebAuthn §7.2 step 20
15The signature counter has not gone backwardscounter_regressionWebAuthn §7.2 step 21

Why the account check and the user-handle check 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