Passwordless authentication

Passkeys without the homework

Client and server in one package. No runtime dependencies. Every check the specification asks for, on every login.

Get started free

Four numbers you can verify yourself

0

Runtime dependencies

The install tree is this package and nothing else.

92

Tests

Most of them feed the verifier tampered input and expect a throw.

29

Checks per ceremony pair

Counted across registration and authentication together.

6.5

Kilobytes in the browser

The client half, minified. The verifier never ships to a user.

Why this is worth changing

A password is a secret you keep for someone else

What you hold today

A hash of something your user probably reused. It sits in your database until the day it does not, and on that day the damage is theirs, not yours. Phishing does not even need the breach: a convincing copy of your login page is enough, because the user is the one being asked to recognise it.

What you hold instead

A public key. It verifies signatures and can do nothing else. Publish it on a billboard and no account is closer to being taken. The private half never leaves the device it was made on, and the browser will only sign for the exact domain that made it.

One login, start to finish

There is no secret to steal

  1. Challenge

    A challenge is issued

    Thirty-two random bytes, recorded once against the ceremony and valid for five minutes. Reading it deletes it, so the same challenge cannot be answered twice.

    server.ts
    const options = await passkeys.startAuthentication();
    
    // { challenge: 'Yk3n...', rpId: 'acme.com',
    //   userVerification: 'preferred', timeout: 300000 }
    res.json(options);
  2. Signature

    The device signs, not the user

    The browser will only sign for the domain the credential was made for. A convincing replica of your login page asks the authenticator for a signature and is refused, because the origin does not match.

    login.ts
    import { login } from 'passkify/client';
    
    // Touch ID, Face ID, Windows Hello, or a key
    // in the hand. No username, no password field.
    const user = await login();
  3. Verification

    Fourteen checks, in order

    Origin, domain binding, challenge, flags, signature, counter. Any one of them failing throws a typed error. There is no result object with a boolean on it that a caller can forget to read.

    server.ts
    try {
      const { user } = await passkeys.verifyAuthentication(body);
      req.session.userId = user.id;
    } catch (error) {
      if (isPasskeyError(error)) {
        // error.code: 'bad_signature' | 'origin_mismatch' | ...
      }
    }
  4. Session

    You receive a user

    Then you open the session exactly as you already do. passkify has no opinion about your session library, your user table, or your framework, and it does not want one.

    server.ts
    app.use(passkeys.express({
      onLogin: (req, res, { user }) => {
        req.session.userId = user.id;
      },
    }));

Verification

Every assertion is doubted before it is believed

These are the checks the authentication path runs, in the order it runs them. Each label is a real member of the exported error type, thrown at that exact point.

  1. 01

    challenge_not_found

    The challenge exists and has not expired.

  2. 02

    type_mismatch

    The client data says webauthn.get, not webauthn.create.

  3. 03

    challenge_mismatch

    The signed challenge is the one this server issued.

  4. 04

    origin_mismatch

    The origin is one of the origins you configured.

  5. 05

    unknown_credential

    The credential ID is registered here.

  1. 06

    malformed_response

    The user handle decodes as base64url UTF-8.

  2. 07

    unknown_credential

    The credential belongs to the account claiming it.

  3. 08

    rpid_mismatch

    The RP ID hash matches your domain, not a neighbour of it.

  4. 09

    user_not_present

    The user-present flag is set on the authenticator data.

  1. 10

    user_not_verified

    The human was verified when your config required it.

  2. 11

    bad_signature

    The signature verifies against the stored public key.

  3. 12

    unknown_user

    The account that owns the passkey still exists.

  4. 13

    counter_regression

    The signature counter has not moved backwards.

Where it runs

Your stack, not a new one

The one runtime requirement

Attestation verification uses node:crypto, including X509Certificate. Bun and Deno provide it. Cloudflare Workers needs the nodejs_compat flag, and the guide says to test that on your target before you commit to it. An edge runtime without Node compatibility cannot run the server half at all, so on Next.js keep the route handler on the Node runtime.

Every target above, with a worked example

Design rules

Rules the API will not bend

Failure throws

There is no verified: false to mistake for success. A library that returns one invites the check that gets forgotten.

One challenge, one attempt

Reading a challenge deletes it, whether verification then succeeds or fails. Replay finds nothing to match.

No account may be claimed

Registration refuses an existing username. Adding a passkey to an account requires a session, never a request body.

The login form tells no tales

An unknown username receives an ordinary challenge. Nobody learns which accounts exist by asking.

Read it, then run it

The demo is the library

Nothing on the demo page is simulated. It mounts a real PasskeyServer over a memory store and verifies the assertion your own authenticator produces. If it works there, the same twelve lines work in your application.

app/api/passkey/[...passkey]/route.ts
const handler = passkeys.handler({
  basePath: '/api/passkey',
  getSessionUserId: (req) => readSession(req),
});

export {
  handler as GET, handler as POST,
  handler as PATCH, handler as DELETE,
};

Stop storing passwords

The documentation covers every method and every option, and says why each one is shaped the way it is.

Read the quickstart