# 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

### startAuthentication
"
>

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

  
- **username** (`string`) — 
    Scope the ceremony to one account. Usually unnecessary, see below.
  
  
- **userId** (`string`) — 
    Same, by user handle, when you already know who is signing in. Useful for
    re-authentication before a sensitive action.
  
  
- **userVerification** (`'required' | 'preferred' | 'discouraged'`, default `config value`, required) — 
    Override for this ceremony. Recorded with the challenge and enforced at
    verification, so a stricter policy cannot be dropped between the two calls.
  

**Returns** `{ options: AuthenticationOptionsJSON; userId?: string }` — 
`userId` is present only when the ceremony was scoped to a known account.

### The two flows

**Do this — Usernameless: prefer this:**

```js
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.

**Not this — Username-first: only if you must:**

```js
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**
>
> 
It is the entire point of passkeys. The browser already knows which credentials
exist for your domain, so asking for a username first adds a screen, a text
field and a failure mode without buying anything.

It is also better for security: with no `allowCredentials`, the response
reveals nothing about which accounts exist. See the enumeration note below.

> **An unknown username does not produce an error**
>
> 
`startAuthentication({ username: 'nobody' })` returns a normal, well-formed
challenge with no `allowCredentials`.

Answering honestly would turn your login form into a free account-enumeration
oracle: anyone could learn which usernames are registered by watching for a
404. The ceremony fails later at verification, exactly like any other bad
attempt.

This means a typo'd username produces `unknown_credential` at the *finish*
step, not a helpful message at the start. That trade is deliberate.

## finishAuthentication

### finishAuthentication
"
>

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

**Returns** `{ verified: true; user; credential; signCount; userVerified }` — 
`userVerified` reports whether the authenticator checked a PIN or biometric, as
opposed to mere presence. Useful when you allow `'preferred'` but want to demand
verification before a sensitive action.

```js
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.

| # | Check | Failure code | Specification |
| --- | --- | --- | --- |
| 1 | The response has the fields it must have | `malformed_response` | WebAuthn §7.2 step 1 |
| 2 | The challenge is one we issued and have not yet consumed | `challenge_not_found` | WebAuthn §7.2 step 11 |
| 3 | The challenge was issued for a login, not a registration | `type_mismatch` | WebAuthn §7.2 step 11 |
| 4 | The signed challenge equals the stored one | `challenge_mismatch` | WebAuthn §7.2 step 11 |
| 5 | clientData.type is exactly "webauthn.get" | `type_mismatch` | WebAuthn §7.2 step 10 |
| 6 | clientData.origin is in the allow-list | `origin_mismatch` | WebAuthn §7.2 step 12 |
| 7 | The ceremony did not run in a cross-origin frame | `origin_mismatch` | WebAuthn §7.2 step 13 |
| 8 | The credential ID is one we have stored | `unknown_credential` | WebAuthn §7.2 step 5 |
| 9 | The credential belongs to the account being signed into | `unknown_credential` | WebAuthn §7.2 step 6 |
| 10 | The user handle, when present, names the credential owner | `unknown_credential` | WebAuthn §7.2 step 6 |
| 11 | The RP ID hash matches this relying party | `rpid_mismatch` | WebAuthn §7.2 step 15 |
| 12 | The user-present flag is set | `user_not_present` | WebAuthn §7.2 step 16 |
| 13 | The user was verified, when the ceremony required it | `user_not_verified` | WebAuthn §7.2 step 17 |
| 14 | The signature verifies against the stored public key | `bad_signature` | WebAuthn §7.2 step 20 |
| 15 | The signature counter has not gone backwards | `counter_regression` | WebAuthn §7.2 step 21 |

> **Why the account check and the user-handle check both exist**
>
> 
They defend different flows.

The account check covers username-first: the ceremony was started for Ada, so a
credential belonging to Bob must not satisfy it, even though Bob's credential is
perfectly valid and his signature verifies.

The user-handle check covers usernameless: there is no scoped account, so the user handle in
the response is what tells you who signed in. It has to agree with the
credential's stored owner, or an attacker could present a valid signature from
their own passkey alongside somebody else's handle.

Dropping either one leaves a hole in the flow the other does not cover.

### 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 credential record keeps `publicKey` as the exact CBOR the authenticator
produced. It is re-parsed on every login into a Node `KeyObject` through a JWK
import, which puts curve and point validation inside OpenSSL rather than in
library code.

Storing a converted format would mean the conversion happened once, at
registration, with no way to revisit it if a bug were found. Keeping the
original means the parse is re-run and re-validated on every use, and a fix
applies retroactively to credentials already in your database.

## 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.

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

> **Most passkeys report zero forever**
>
> 
Synced credentials cannot maintain a coherent counter across devices, so they
do not try. passkify skips the comparison when both the stored and presented
counters are zero; otherwise every second login would fail.

Treat the counter as a detection signal, not a defence. An attacker holding the
private key can report whatever counter they like.

## Re-authentication

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

```js
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

- [Credential management](/docs/server/credentials)
- [HTTP adapters](/docs/server/adapters) to skip writing these routes
- [Security model](/docs/guides/security) for the reasoning behind each check
