Skip to content

Security

How to Add Passkeys to a Laravel or Node.js App (2026 Guide)

A practical 2026 guide to adding phishing-resistant passkey login to Laravel and Node.js apps: how WebAuthn works, recommended libraries, code, security checks, recovery and rollout.

Prakash Tank

Prakash Tank

November 10, 2026 · 11 min read

Summary

Passkeys have moved from "nice to have" to a feature customers expect. They replace passwords and one-time codes with a public-key credential that lives in the user's device or password manager, unlocked with a fingerprint, face scan or device PIN. Because the credential is tied to your domain, a fake login page cannot trick the user into handing it over. That makes passkeys the most practical phishing-resistant login you can add to a SaaS product or customer portal today.

This guide is for CTOs, product owners and developers who want to add passkeys to an existing Laravel or Node.js application. It covers how passkeys work, the libraries we recommend, working code for registration and sign-in, the security checks you must not skip, account recovery, and a rollout plan that keeps passwords as a fallback while adoption grows.

Why passkeys matter in 2026

The FIDO Alliance Passkey Index 2026 reports that more than one in three successful sign-ins among participating services now use a passkey, up from roughly one in four a year earlier. The same report found that passkey sign-ins succeed more often than traditional MFA sign-ins (93% compared with 78%) and are much faster. Google, Apple and Microsoft all ship passkey support in their operating systems and password managers, so most of your users already have a device that can create one.

For a business, the benefits are concrete:

  • Fewer account takeovers. Phished or reused passwords are still one of the most common ways attackers get into customer accounts. A passkey cannot be typed into a lookalike site.
  • Lower support and SMS costs. Fewer "forgot password" tickets and fewer SMS one-time codes to pay for.
  • Better conversion. Sign-in becomes one tap, which matters for checkout, mobile web and returning users.
  • Compliance and enterprise sales. Phishing-resistant authentication increasingly appears in security questionnaires from larger customers.

How passkeys work, in plain terms

Passkeys are built on the W3C WebAuthn standard. There are two ceremonies:

  1. Registration. Your server sends a random challenge and some options. The browser asks the user's authenticator (phone, laptop, security key or password manager) to create a new key pair for your domain. The private key never leaves the authenticator. Your server receives and stores the public key and a credential ID.
  2. Authentication. Your server sends a new challenge. The authenticator signs it with the private key after the user unlocks it. Your server verifies the signature with the stored public key.

Three terms come up constantly:

  • Relying Party ID (RP ID): the domain the passkey is bound to, for example example.com. It must match or be a parent of the page's domain.
  • User presence (UP) and user verification (UV): UP means someone touched the device; UV means the device verified the user with biometrics or a PIN. They are different security properties, and your policy should say which you require.
  • Synced vs device-bound passkeys: synced passkeys are backed up through iCloud Keychain, Google Password Manager or a third-party manager; device-bound ones live on a single security key. Most consumer and SaaS apps should accept both.

The architecture you need

Whatever your stack, a passkey implementation has the same moving parts:

  • A credentials table linked to users: credential ID, public key, signature counter, transports, a friendly name, created and last-used timestamps.
  • Four endpoints: registration options, registration verify, authentication options, authentication verify.
  • Short-lived challenge storage bound to the session (or a signed, single-use token for APIs). Each challenge is used once and expires within a few minutes.
  • A maintained WebAuthn server library. Do not write CBOR parsing, COSE key handling or signature verification yourself. The OWASP Passkey Security Cheat Sheet is explicit about this.
  • A credential management page where users can name, add and remove passkeys after a recent re-authentication.

A typical migration for the credentials table looks like this:

CREATE TABLE passkeys (
  id              BIGSERIAL PRIMARY KEY,
  user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id   TEXT NOT NULL UNIQUE,      -- base64url
  public_key      BYTEA NOT NULL,
  counter         BIGINT NOT NULL DEFAULT 0,
  transports      JSONB,
  name            VARCHAR(100),
  backed_up       BOOLEAN NOT NULL DEFAULT FALSE,
  last_used_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX passkeys_user_id_idx ON passkeys(user_id);

Adding passkeys to a Laravel application

In PHP, the underlying library most packages build on is the WebAuthn Framework (web-auth/webauthn-lib). For most Laravel teams, a Laravel package on top of it is the fastest safe route. Two well-known options are spatie/laravel-passkeys, which ships a Livewire component for managing passkeys and a Blade component for signing in, and laragear/webauthn, which integrates WebAuthn with Laravel's authentication guards and suits API-first or Inertia apps.

With the Spatie package, the setup follows the usual Laravel pattern. Check the package documentation for the version that matches your Laravel release:

composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate

Then make your User model implement the package's passkey interface and trait, register the package routes, and drop the components into your views:

use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;

class User extends Authenticatable implements HasPasskeys
{
    use InteractsWithPasskeys;
}

// routes/web.php
Route::passkeys();
<!-- profile page: let signed-in users create and manage passkeys -->
<livewire:passkeys />

<!-- login page: "Sign in with a passkey" -->
<x-authenticate-passkey />

Whichever package you pick, set the relying party name, ID and allowed origins in config from environment variables, not from the incoming request. On a multi-tenant Laravel SaaS with custom customer domains, decide early whether each tenant domain is its own RP ID (passkeys will not work across them) or whether all sign-in happens on one central auth domain. We cover the wider trade-offs in our guide to multi-tenant Laravel SaaS architecture.

Adding passkeys to a Node.js application

In Node.js, the most widely used server library is SimpleWebAuthn (@simplewebauthn/server). It works the same in Express, Fastify, NestJS or a Next.js route handler. Here is a trimmed Express example for registration; the db and session helpers stand in for your own data layer:

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpName = 'Acme App';
const rpID = process.env.RP_ID!;                 // e.g. "acme.com"
const expectedOrigin = process.env.APP_ORIGIN!;  // e.g. "https://app.acme.com"

app.post('/passkeys/register/options', requireRecentLogin, async (req, res) => {
  const user = req.user;
  const existing = await db.passkeys.findByUser(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    attestationType: 'none',
    excludeCredentials: existing.map(c => ({ id: c.credentialId, transports: c.transports })),
    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
  });

  req.session.regChallenge = options.challenge;   // single use
  res.json(options);
});

app.post('/passkeys/register/verify', requireRecentLogin, async (req, res) => {
  const expectedChallenge = req.session.regChallenge;
  delete req.session.regChallenge;

  const { verified, registrationInfo } = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: rpID,
  });
  if (!verified || !registrationInfo) return res.status(400).json({ ok: false });

  const { credential, credentialBackedUp } = registrationInfo;
  await db.passkeys.create({
    userId: req.user.id,
    credentialId: credential.id,
    publicKey: Buffer.from(credential.publicKey),
    counter: credential.counter,
    transports: credential.transports,
    backedUp: credentialBackedUp,
  });
  res.json({ ok: true });
});

Authentication mirrors this with generateAuthenticationOptions and verifyAuthenticationResponse. Look up the stored credential by the ID in the response, pass its public key and counter to the verifier, and on success update the counter and last_used_at before creating the session. Field names have changed between major versions of SimpleWebAuthn, so pin the version and follow its docs.

The browser side

Chrome's passkey guidance recommends calling the native WebAuthn APIs directly in the browser. Modern browsers can parse the JSON options your server returns:

// Registration
const optionsJSON = await fetch('/passkeys/register/options', { method: 'POST' }).then(r => r.json());
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.create({ publicKey });
await fetch('/passkeys/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(credential.toJSON()),
});

// Sign-in with autofill ("conditional UI")
if (await PublicKeyCredential.isConditionalMediationAvailable?.()) {
  const reqJSON = await fetch('/passkeys/login/options', { method: 'POST' }).then(r => r.json());
  const assertion = await navigator.credentials.get({
    mediation: 'conditional',
    publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(reqJSON),
  });
  // POST assertion.toJSON() to /passkeys/login/verify
}

For conditional UI, add autocomplete="username webauthn" to the username or email field so the browser offers saved passkeys in its autofill list. If you must support older browsers that lack the JSON helpers, feature-detect them and fall back to a small conversion helper such as the one in @simplewebauthn/browser.

Security checks you must not skip

The library does the cryptography, but your application owns the workflow. These are the mistakes we see most often in security reviews:

  • Hard-code the RP ID and allowed origins on the server. Never derive them from request headers or window.location.
  • Make challenges single-use and short-lived, and bind them to the session or account that requested them.
  • Require recent authentication before adding or removing a passkey, and notify the user by email when either happens.
  • Decide your UV policy. For sensitive actions such as payouts or admin access, require userVerification: 'required' and check the UV flag on the response.
  • Do not use the AAGUID for security decisions. It identifies the authenticator model and is only safe for UX, such as showing a provider icon.
  • Let users register more than one passkey so losing a phone does not push them into a weak recovery path.
  • Rate-limit the endpoints and log ceremonies so you can spot abuse and debug failures.

These sit alongside the controls in our OWASP API Security Top 10 guide for Laravel and Node.js and our comparison of Laravel Sanctum, Passport and JWT, since a passkey sign-in still ends with a session or token that must be protected.

Account recovery: the part that decides your real security

A passkey login is only as strong as the way someone gets back into an account without one. If recovery is "enter the code we texted you", attackers will simply target recovery. Good patterns include:

  • Encouraging synced passkeys or a second passkey at registration time.
  • Verified email recovery combined with a cooling-off period and notifications for high-value accounts.
  • Support-assisted recovery with identity checks for enterprise customers, and admin-driven resets for workforce apps.
  • Keeping password plus TOTP as a fallback during the transition, then reviewing it once most active users have a passkey.

A practical rollout plan

  1. Add passkeys as an option for signed-in users on the security settings page. No change to the login page yet.
  2. Prompt after a successful password login: "Sign in faster next time with a passkey."
  3. Enable conditional UI on the login page so returning users see their passkey in autofill.
  4. Use passkeys for step-up on sensitive actions instead of SMS codes.
  5. Measure enrolment, passkey sign-in share, failure rates by browser and recovery requests, then decide whether to offer passwordless-only accounts.

For a typical Laravel or Node.js product with an existing auth system, the first three steps are usually a focused piece of work rather than a rebuild. Most of the effort goes into recovery design, credential management UI, testing across browsers and devices, and updating support processes.

FAQ

Do passkeys replace passwords completely?

They can, but most products run passkeys alongside passwords at first. Once enough active users have a passkey and recovery is solid, you can offer passwordless-only accounts.

Do passkeys work on mobile apps as well as the web?

Yes. iOS and Android support passkeys natively. To share passkeys between your website and app, you configure the platform's associated domains (Apple) or Digital Asset Links (Android) so both use the same RP ID.

Which library should I use for Laravel?

Use a maintained package such as spatie/laravel-passkeys or laragear/webauthn, both built on the PHP WebAuthn Framework. The choice mostly depends on whether your front end is Livewire and Blade or an API with a separate client.

Are passkeys compatible with SSO and enterprise identity providers?

Yes. If you use an identity provider such as Okta, Microsoft Entra ID or Auth0, passkeys can be enabled there instead of in your own code. Building it yourself makes sense when you own the login and want full control.

What happens if a user loses their device?

Synced passkeys are restored from the user's cloud account on a new device. For device-bound keys, the user signs in with another registered passkey or goes through your recovery flow, which is why supporting multiple passkeys matters.

Can passkeys be phished?

The passkey itself is bound to your domain, so a lookalike site cannot use it. Attackers may still target weaker recovery or fallback methods, so those need the same care.

Prakash Tank

Written by

Prakash Tank

Founder & Full-Stack Architect, DayDecoder

Prakash started as a certified Laravel developer and grew into a full-stack architect who owns a project end to end: requirements, architecture, development, deployment, and support. He designs event-driven systems with...

More about Prakash

Keep reading

Related articles

Let's talk

Turn this into a plan for your project.

Share what you are building. We will reply within one business day with honest advice and next steps.

  1. 1

    We reply within 24 hours

    A real person reads your message and replies on the next business day at the latest.

  2. 2

    Free 30-minute call

    We talk through your goals, users, and budget, and answer every question.

  3. 3

    Proposal & fixed quote

    You get a written scope, timeline, and price. No obligation.

contact@daydecoder.com Ahmedabad, India

Get a free consultation

Takes under a minute. No spam, no obligation.

What do you need help with?
16 + 4 =

Your details stay private. NDA on request.