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.

33

Verification checks

18 on registration and 15 on login, each one a registered assertion in the source.

27

Typed error codes

Every failure arrives as one of these, with an HTTP status attached.

3.6

Kilobytes over the wire

The client half, minified and compressed. 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

    15 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

    malformed_response

    The response has the fields it must have

  2. 02

    challenge_not_found

    The challenge is one we issued and have not yet consumed

  3. 03

    type_mismatch

    The challenge was issued for a login, not a registration

  4. 04

    challenge_mismatch

    The signed challenge equals the stored one

  5. 05

    type_mismatch

    clientData.type is exactly "webauthn.get"

  1. 06

    origin_mismatch

    clientData.origin is in the allow-list

  2. 07

    origin_mismatch

    The ceremony did not run in a cross-origin frame

  3. 08

    unknown_credential

    The credential ID is one we have stored

  4. 09

    unknown_credential

    The credential belongs to the account being signed into

  5. 10

    unknown_credential

    The user handle, when present, names the credential owner

  1. 11

    rpid_mismatch

    The RP ID hash matches this relying party

  2. 12

    user_not_present

    The user-present flag is set

  3. 13

    user_not_verified

    The user was verified, when the ceremony required it

  4. 14

    bad_signature

    The signature verifies against the stored public key

  5. 15

    counter_regression

    The signature counter has not gone backwards

Where it runs

Your stack, not a new one

What the server half needs

WebCrypto, and nothing else. Signature verification, X.509 parsing and hashing are written against the global crypto, so no ceremony path imports a node: built-in. A test walks every source file on each run and fails if one appears, which is why Workers and Deno carry no asterisk above, and why Cloudflare needs no nodejs_compat flag.

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, that same route handler works 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