Passkey Integration Guide

How to add passkey support to your site

Passkeys can greatly streamline your registration and sign-in processes, while also significantly improving security over traditional authentication methods like passwords and one time codes.

This guide is written for web integrations, using Typescript. Passkeys also work in native apps, using iOS/macOS (ASAuthorization) and Android (CredentialManager) SDKs.

Perhaps you've already read a guide or two covering the integration process, but there's often more to it than it first seems.

If you want to add passkeys the easy way with SnapAuth, we've got you covered.

Let's dive in!

Setup

Before you can start writing any code, there's a few thing to take care of:

Select a WebAuthn library

Most mainstream languages have one or more libraries that can help with a lot of the core backend operations. This may be one of the easier steps, but there's a number of things to consider while deciding, such as:

Prepare credential storage

This will vary a bit based on the library you just selected, but will generally involve adding at least one new table to your database (or equivalent) to hold the credential data. It'll need to support a number of different access paths, especially for supporting autofill-assisted requests (so mind your indexes). Autofilled passkeys create a super streamlined user experience, but require some special handling behind the scenes that's quite different from most authentication flows.

CREATE TABLE `credentials` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint unsigned NOT NULL,
  `webauthn_id` text NOT NULL,
  `credential_data` text NOT NULL,
  PRIMARY KEY (`id`)
)

The actual storage is very library-specific, and will likely require additional columns.

Prepare challenge management

Along with credential storage, you'll also need to manage challenges. These are short-lived one-time-use values that are critical for the security of WebAuthn. While your selected library will typically handle the generation of challenges, they need to be stored and kept secure throughout both the registration and authentication processes.

Typically, this means a dedicated redis or memcached instance, or a challenges table in your database.

There's security-critical handling of race conditions that must be addressed, which hopefully your library will deal with. Your WebAuthn library should have some documented guidelines on how best to proceed.

You'll need to create and expose an API that runs this process and vends it to the user. It'll be used during the processes described below.

Prepare client utilities

Most client APIs require interacting with fairly low-level binary primitives.

While new PublicKeyCredential.parseCreationOptionsFromJSON() and PublicKeyCredential.toJSON() APIs aim to simplify this a lot, browser support is currently limited. So for now, you need either to import a polyfill or add some utilities:

const toArrayBuffer = (base64: string): ArrayBuffer => {
  const bin = atob(base64)
  return Uint8Array.from(bin, c => c.charCodeAt(0))
}

const base64URLToArrayBuffer = (base64URL: string): ArrayBuffer => {
  const base64 = base64URL.replaceAll('-', '+').replaceAll('_', '/')
  return toArrayBuffer(base64)
}

const toBase64URL = (buffer: ArrayBuffer): string => {
  let bin = ''
  const bytes = new Uint8Array(buffer)
  const len = bytes.byteLength

  for (let i = 0; i < len; i++) {
    bin += String.fromCharCode(bytes[i])
  }
  return btoa(bin)
    .replaceAll('+', '-')
    .replaceAll('/', '_')
    .replaceAll('=', '')
}

These may vary slightly based on your backend/library.

Store a user's passkey

Before users can authenticate using a passkey, it must first be registered with your service.

We highly recommend letting users register multiple passkeys.

Set up a registration flow

To start the process of register a new credential, you'll need to use the navigator.credentials.create() API:

const challenge = await fetch('/your-challenge-generator-endpoint')

const abortController = new AbortController()

// Omitted: lots of API availability checks
const creationOptions = {
  publicKey: {
    rp: {
      id: "yoursite.com",
      name: "Your Site",
    },
    user: {
      id: toArrayBuffer(userId),
      name: "Some Name",
    },
    attestation: "direct", // Varies
    excludeCredentials: [], // Contents should be fetched from server
    challenge: base64URLToArrayBuffer(challenge),
    publicKeyCredParams: [
      { type: "public-key", alg: -7 }, // ES256
      // Maybe others, depending on your library.
      // Commonly also RS256 (-257) and Ed25519 (-8)
    ],
  },
  signal: abortController.signal,
}

try {
  const credential = await navigator.credentials.create(creationOptions)
  // Format response as JSON and send it to your backend.
  // This format is library-specific
  const toSend = {
    rawId: toBase64URL(credential.rawId),
    type: credential.type,
    attestationObject: toBase64URL(credential.response.attestationObject),
    clientDataJSON: toBase64URL(credential.response.clientDataJSON),
    // Possibly a number of other fields
  }
  // Send this to your backend, probably with fetch().
} catch (error) {
    // All sorts of scenarios
}

All of this must be in response to a user gesture of some kind (often onClick or onSubmit).

Process the registration data

Great, you've now got the client-provided credential data, and sent it to your backend! Your WebAuthn library should have more details on exactly how to proceed here, and do most of the hard parts for you. Expect roughly the following steps:

  1. Parse the data from the HTTP request body
  2. Validate that the challenge in the data is one being tracked by your backend, and hasn't expired or been used already
  3. Validate that the origin in the data aligns with your service's domain
  4. Decode the CBOR-formatted attestation data
  5. Decode the binary authData from inside the attestation
  6. Verify the signature in the attestation and examine the chain of trust
  7. If everything checks out, store the credential data (including its id and public key) somewhere linked to the user. There's about a half-dozen fields in total that should be stored, several of which are flags inside the attestation CBOR.

The full procedure is quite involved, but your library should do most of the work.

Authenticating with a passkey

Finally, all of your hard work will pay off! We're almost there!

Add an API to get a user's credential IDs

For a user to sign in using passkeys, your site will need to provide a list of valid credentials for them to use when signing in. Besides being a requirement for the browser APIs, it allows many hardware authenticators to function.

This will look something like SELECT webauthn_id FROM credentials WHERE user_id = ?, wrapped up in your API tooling.

This process is skipped for the autofill flows, but going autofill-only is quite rare.

When implementing this, be sure to add rate-limiting. Also be aware that this can leak metadata without specific precautions.

Set up an authentication flow

Now that you've registered a credential for a user, you'll want to let them use it. This most commonly means signing in to your site. However, since using passkeys is far more streamlined than typing in a password or using a OTP procedure, they're great for protecting any sort of sensitive action as inline auth refreshes.

The good news is that this procedure looks fairly similar to what you just did for registration, so getting everything going this time around should be a bit easier. Like before, we'll set up some data to pass to the browser API; in this case, navigator.credentials.get():

// username comes from a form field, etc
const challenge = await fetch('/your-challenge-generator-endpoint')
const userCredentialIds = await fetch('/your-credential-list-endpoint', { username })

// We're omitting the following for brevity:
// - Most error handling
// - AbortController setup
const requestOptions = {
  publicKey: {
    challenge: toArrayBuffer(challenge),
    rpId: "yoursite.com", // Not always needed
    allowCredentials: userCredentialIds.map(id => {
      return {
        id: toArrayBuffer(id),
        type: "public-key",
      }
    }),
  ],
  // Numerous other options available
}

try {
  const assertion = navigator.credentials.get(requestOptions)
  
  // Format for backend processing
  const toSend = {
    rawId: toBase64URL(credential.rawId),
    type: credential.type,
    authenticatorData: toBase64URL(credential.response.authenticatorData),
    clientDataJSON: toBase64URL(credential.response.clientDataJSON),
    signature: toBase64URL(credential.response.signature),
    userHandle: toBase64URL(credential.response.userHandle),
    // ALSO include some hint of who is signing in (form data, usually)
  }
  // Send it to the backend for verification
} catch (error) {
  // ...
}

Passkey Autofill

The best user experience is passkey autofill, known behind-the-scenes as "conditional mediation". If the user's device is aware that it's registered for your site, it can highlight this and make signing in a one-click or one-tap experience.

The client code is similar to above. Instead of filling in allowCredentials for a known user, the field is omitted. At the root of the data structure (next to, not inside publicKey), mediation: "conditional" gets added.

const requestOptions = {
  publicKey: { ... },
  mediation: "conditional",
}

You also must add autocomplete="username webauthn" to an <input> element somewhere on the page (your username field on a sign in screen, typically). Without this, the autofill API will not start.

Unlike the above flow, navigator.credentials.get(requestOptions) is called early in the page lifecycle (e.g. on load), rather than in response to a user gesture. This comes with a few caveats:

Process the authentication data

Once again, your library ought to be doing the bulk of the work here, and provide specific guidance. The steps will be, roughly:

  1. Parse the data from the HTTP request body
  2. Determine who is currently trying to sign in. In the above example, this will usually be from form data on the page. Look up credentials associated with them
  3. Validate that the challenge in the data is one being tracked by your backend, and hasn't expired or been used already
  4. Check that the parsed credential ID is associated with the user
  5. Get the public key from the matching credential
  6. Cryptographically verify that the public key signed authData || sha256(clientDataJSON). Since clientDataJSON contains the challenge and page origin, this asserts that the request is expected by your backend and originated from the paired private key.
  7. Verify that the signCount has not gone backwards (which indicates a replay attack or cloned private key)
  8. Update the credential state from the signed data: at minimum, the counter and backup state

The "determine who is currently trying to sign in" step can vary significantly for modal and autofill requests. For the former, the user has directly provided that information, and you only need to check that the used credential is for that user. The latter needs to inspect the handle set up during registration (user.id) and cross-check a storage identifier. The process may be different still if implementing a "sudo mode", where you already know who is signed in but want to re-verify. Getting this wrong could result in authenticating the wrong user!

If that all went well, the user identified during the procedure has demonstrated a cryptographic proof of possession of their previously-registered key. Simply put, they are authenticated. You can now proceed, whether that's generating a session cookie, authorizing an API call, or whatever else.

Seem like a lot?

It can be—and SnapAuth is here to help!

The full WebAuthn spec is about 250 pages long - before going through all of the linked RFCs and related data format and handling specs. Plus it's evolving, and currently on the third publication. We've read it cover-to-cover... more times than we can count. We've gone through the processes described here: vetted the libraries, handled the format-shifting, set up the data storage, added the error handling, and more.

We've packed it up into easy to use APIs that let you focus on your product and benefit from how great passkeys are for you users.

Instead of the above, your integration can be as simple as:

// Registration
const snapAuth = new SnapAuth.SDK('your_publishable_key')
const onRegisterSubmit = async (e) => {
  e.preventDefault()
  const registration = await snapAuth.startRegister({
    name: 'display name',
  })
  if (registration.ok) {
    // POST registration.data.token to your backend
  }
}
document.getElementById('register-form').on('submit', onRegisterSubmit)

and

// Authentication - both modal AND autofill
const snapAuth = new SnapAuth.SDK('your_publishable_key')
const onSignInSubmit = async (e) => {
  e.preventDefault()
  const auth = await snapAuth.startAuth({
    handle: 'username from form',
  })
  await onAuth(auth)
}
const onAuth = async (auth: AuthResponse) => {
  if (auth.ok) {
    // POST auth.data.token to your backend
  }
}
//
document.getElementById('sign-in-form').on('submit', onSignInSubmit)
snapAuth.handleAutofill(onAuth)

We manage all the hard parts for you, and keep everything safe and up to date with the latest specs. If that seems more palatable, give us a try.

Start building for free - no credit card required.

Try SnapAuth today