Picture the worst morning of your career: a copy of your production database is sitting on a forum, for sale, and you find out from a customer.
If your app authenticates with passwords, that morning is a catastrophe. Even with bcrypt, an attacker now has every hash to grind offline, every reused password to spray at other sites, every "forgot password" flow to social-engineer. You're sending emails, forcing resets, calling lawyers.
Now run the same morning for an app on passkeys. The attacker downloads your passkeys table and finds a list of public keys. A public key is about as secret as your house number. It's designed to be handed out. There's nothing to crack, nothing to reverse, nothing to replay against another site. The thing that actually authenticates your users, the private key, was never on your server in the first place. It's still sitting in the secure enclave of their phone, behind their thumb.
That's the whole pitch for passkeys, and as of 2026 you don't need a third-party package to get it in Laravel. The framework ships a first-party laravel/passkeys package, and Fortify wires the entire WebAuthn flow up behind one feature flag. This is the detailed install: what each piece does, the exact config, the frontend ceremonies, and the handful of footguns that will quietly break your login page if you don't know about them.
What a passkey actually is
Before the install makes sense, you need the shape of the thing you're installing. A passkey is a public/private key pair scoped to one site. The private key lives on the user's device: in the Secure Enclave on an iPhone, the TPM on a Windows box, or a hardware security key like a YubiKey. The matching public key is the only thing your server ever sees or stores.
WebAuthn, the W3C standard underneath all of this, defines two "ceremonies", and the whole protocol is just those two. The FIDO Alliance gave the consumer-friendly name "passkey" to a WebAuthn credential, and the big platforms (Apple, Google, Microsoft) added syncing on top in 2022, so the same passkey follows you from your phone to your laptop through iCloud Keychain or Google Password Manager. But the cryptography is identical to the old hardware-key flow.
The registration ceremony is how a passkey gets created:
- Your server generates a random challenge and a
PublicKeyCredentialCreationOptionsobject describing what kind of key it wants. It hands these to the browser. - The browser calls
navigator.credentials.create({ publicKey: options }). The OS prompts the user for biometrics. - The authenticator generates a fresh key pair, stores the private key, and signs a response. The browser returns an
attestationObject(which carries the new public key) plus aclientDataJSON. - Your server runs the spec's verification steps (confirm the challenge matches the one you issued, confirm the origin is yours) and saves the public key against the user.
The authentication ceremony is how that passkey logs someone in:
- Server generates a new challenge and a
PublicKeyCredentialRequestOptionsobject, hands it over. - Browser calls
navigator.credentials.get({ publicKey: options }). User taps their sensor. - The authenticator signs over the challenge (technically over
authenticatorDataplus a hash ofclientDataJSON) with the stored private key and returns asignature. Notice what's not in the response: the public key. The server already has it. - Server looks up the stored public key, verifies the signature. If it checks out, the user possesses the private key. Logged in.
The asymmetric-crypto move is the whole security story. Your server never holds a secret that's worth stealing. There's no shared password, so there's nothing to phish. And because the signature is bound to a specific origin, a phishing site on a look-alike domain cannot produce a valid signature for your domain. The math refuses.
What Laravel actually shipped
If you tried passkeys in Laravel before 2026, you reached for a community package: spatie/laravel-passkeys, or asbiin/laravel-webauthn, both of which wrap the lower-level PHP WebAuthn libraries. They work. But you owned the glue: routes, controllers, the credential model, the challenge storage, the JavaScript.
The first-party stack splits cleanly into three pieces, and it helps to keep them straight:
-
laravel/passkeysis the Composer package. It owns the server-side WebAuthn logic: generating challenges, verifying credentials, persisting passkeys, plus the migrations, events, and "escape hatches" for when you want custom authorization or responses. -
@laravel/passkeysis the npm package. It runs the browser-side ceremonies (thenavigator.credentialscalls are fiddly to get right by hand) and posts the results to your endpoints. It ships first-class helpers for React, Vue, and Svelte. -
Fortify is the headless auth layer most Laravel apps already use. Fortify wraps
laravel/passkeys, registers all the routes, and configures it fromconfig/fortify.php. You enable one feature and the endpoints appear.
Note
The first-party package is young: it was still pre-1.0 at the time of writing. The API below is what Fortify documents today; pin your versions and re-check the docs before a production rollout, because pre-1.0 packages move.
The path of least resistance is Fortify. The rest of this guide assumes a Fortify app, because that's the configuration Laravel documents and the one that needs the least hand-written code. If you're not on Fortify, you can install laravel/passkeys directly and define your own routes against its actions, but you'll be writing the glue Fortify gives you for free.
Step 1: Enable the feature
Fortify is feature-flagged. Open your Fortify config and switch passkeys on alongside whatever else you already run:
config/fortify.php
use Laravel\Fortify\Features;
'features' => [
// ... login, two-factor, etc.
Features::passkeys([
'confirmPassword' => true,
]),
],
The confirmPassword option controls one thing: whether Fortify makes the user re-confirm with a password before they're allowed to register or delete a passkey. Leave it on. Adding or removing a way to log into an account is exactly the kind of sensitive action you want gated behind a fresh confirmation. Otherwise anyone who walks up to an unlocked laptop can quietly add their own passkey and own the account forever.
Step 2: Prepare your User model
The laravel/passkeys package needs your User model to advertise that it can own passkeys. That's a contract plus a trait:
app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
class User extends Authenticatable implements PasskeyUser
{
use Notifiable, PasskeyAuthenticatable;
}
PasskeyUser is the interface Fortify type-hints against; PasskeyAuthenticatable is the trait that gives the model the relationship and helper methods to manage its stored credentials. Run the package's migrations and you'll get a table to hold each user's passkeys. A user can have several (their phone, their laptop, a backup security key), which is one of the quiet wins over passwords. Don't model it as a single column on users.
Step 3: Configure the relying party
This is the step that looks like boilerplate and is actually where most broken passkey setups go wrong. Fortify exposes a passkeys array in config/fortify.php:
config/fortify.php
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'user_handle_secret' => config('app.key'),
'timeout' => 60000,
],
Four keys, and three of them have a sharp edge:
relying_party_id is the domain a passkey is bound to, the "rpId". This is the single most important value in the whole setup. A passkey created for relying_party_id of app.example.com will only work when the browser's origin is on that domain. That binding is what makes passkeys phishing-proof, and it's also what makes them brittle if you get it wrong. The default, the host parsed out of APP_URL, is usually right. But two rules will save you a bad afternoon:
-
The rpId must be a registrable domain that the current origin is equal to, or a suffix of. A browser will happily create a passkey for rpId
example.comwhile you're onwww.example.com(more specific is fine). It will refuse to create a passkey for rpIdwww.example.comwhile you're onexample.com, or for an unrelated domain entirely. Get this backwards and the browser throws the error every WebAuthn newcomer eventually meets:
SecurityError: The relying party ID is not a registrable domain
suffix of, nor equal to the current domain.
-
Changing the rpId invalidates every existing passkey. They're cryptographically bound to the old value. If you launch on
app.example.comand later "tidy up" toexample.com, every passkey your users registered is now dead and they're locked out. Decide on the rpId once. The common advice is to register passkeys against your root domain from day one so you keep room to add subdomains later.
allowed_origins is the list of full origins (scheme + host + port) permitted to complete a ceremony. The rpId says which domain the key belongs to; allowed_origins says which exact URLs are allowed to use it. Keep it tight: this is a real security boundary, not a CORS afterthought.
user_handle_secret seeds the opaque user identifier WebAuthn stores in the authenticator. The spec is explicit that you should not put personally identifying information (like an email or sequential ID) into the user handle, because it gets persisted on the device. Fortify derives an opaque handle from this secret, defaulting to your APP_KEY, so the same user is recognized across registrations without leaking who they are.
timeout is how long, in milliseconds, the browser keeps the prompt alive before giving up. The default is 60000, sixty seconds. That's a reasonable window; don't crank it to five minutes thinking you're being generous, because a dead prompt the user forgot about is a worse experience than a clean timeout they can retry.
Warning
WebAuthn requires a secure context (HTTPS). The one exception ishttp://localhost, which every browser treats as secure, so passkeys work in local dev without a cert. The trap is the staging box you reach over plainhttp://on an IP or internal hostname: there,navigator.credentials.create()throws aSecurityErrorbefore your Laravel code ever runs, and it looks like a backend bug when it's really the protocol refusing an insecure origin.
Step 4: Wire up the frontend
The browser half is where teams burn the most time hand-rolling, because the navigator.credentials API speaks in ArrayBuffers and base64url, and one encoding mistake gives you a DataError with no useful message. The @laravel/passkeys package exists to make that disappear:
install the client
npm install @laravel/passkeys
The whole API for a Fortify app is two calls:
resources/js/passkeys.ts
import { Passkeys } from "@laravel/passkeys";
// Register a new passkey for the currently authenticated user.
// `name` is the human label they'll see in their passkey list.
await Passkeys.register({ name: "MacBook Pro" });
// Run the login ceremony for an unauthenticated visitor.
await Passkeys.verify();
Passkeys.register() hits the registration endpoints, Passkeys.verify() runs the login ceremony. Under the hood each call does the GET-options / navigator.credentials.* / POST-credential dance for you. Both throw if the user cancels or the ceremony fails, so wrap them:
resources/js/login.ts
import { Passkeys } from "@laravel/passkeys";
const button = document.getElementById("passkey-login");
button?.addEventListener("click", async () => {
try {
await Passkeys.verify();
// Fortify returns a redirect target; follow it.
window.location.href = "/dashboard";
} catch (error) {
// Most failures here are the user dismissing the OS prompt,
// a NotAllowedError. That's not a bug; it's a cancel. Treat it
// as "nothing happened", not "show a scary error".
console.warn("Passkey sign-in was cancelled or failed", error);
}
});
If your routes differ from the defaults, you override them per call. This is also how you point verify() at the confirm flow instead of login:
resources/js/confirm.ts
import { Passkeys } from "@laravel/passkeys";
// Re-confirm a sensitive action with a passkey instead of a password.
await Passkeys.verify({
routes: {
options: "/passkeys/confirm/options",
submit: "/passkeys/confirm",
},
});
await Passkeys.register({
name: "MacBook Pro",
routes: {
options: "/user/passkeys/options",
submit: "/user/passkeys",
},
});
On a React, Vue, or Svelte app you can pull the framework-specific helpers from the subpath exports (@laravel/passkeys/react, @laravel/passkeys/vue, @laravel/passkeys/svelte), which wrap the same calls in SSR-safe hooks/composables so you're not poking at navigator during server render.
The four flows, endpoint by endpoint
Even with the JS package doing the heavy lifting, it pays to know the endpoints Fortify registers, because your custom UI talks to them and your logs will show them. Every flow follows the same two-beat rhythm: GET the options, POST the credential.
Logging in (guest). GET /passkeys/login/options returns the WebAuthn challenge your frontend feeds to navigator.credentials.get(...). Then POST /passkeys/login with the resulting credential payload, plus an optional remember boolean for the "remember me" checkbox. On success Fortify logs the user into the configured guard and answers with a redirect for a normal request, or a 200 carrying a JSON { redirect: ... } for an XHR call.
Confirming a password (authenticated). Same shape, for when Laravel's password.confirm middleware wants a recent confirmation: GET /passkeys/confirm/options, then POST /passkeys/confirm. Success marks the session as password-confirmed. This is what lets a user re-authorize a sensitive action with their thumb instead of retyping a password.
Registering a passkey (authenticated). GET /user/passkeys/options returns creation options for navigator.credentials.create(...). Then POST /user/passkeys with a name field and a credential field holding the serialized PublicKeyCredential. Success returns the new passkey's id and name (and a passkey-registered status for non-XHR requests).
Deleting a passkey. DELETE /user/passkeys/{passkey}. Returns a passkey-deleted status. Give users this: a passkey list with a delete button is table stakes, and it's also your "lost my phone" recovery story.
Fortify also drops a dedicated rate limiter on the login, confirm, and register routes. If the default is too tight or too loose for you, override it through fortify.limiters.passkeys and a matching RateLimiter::for(...) definition in a service provider. Don't disable it: these are unauthenticated endpoints accepting cryptographic payloads, and they deserve a throttle.
The gotchas nobody warns you about
The install is short. The debugging, if you skip the parts above, is not. A few failure modes show up again and again in production WebAuthn setups, and most of them surface as a browser-side DOMException rather than anything in your Laravel logs:
NotAllowedErroris almost never a bug. It's the catch-all the browser throws when the user dismisses the OS prompt, lets it time out, or there was no user activation behind the call. In the auth vendor Corbado's analysis of large-scale deployments, the overwhelming majority of these, north of 95% in well-tuned apps, are expected user behavior, not failures. Treat aNotAllowedErroras "the user backed out", log it quietly, and let them try again. Don't paint the login screen red.InvalidStateErrormeans "already registered," and that's useful. When you try to register a passkey for a credential the authenticator already holds for your rpId, you get this. It's not garbage; it's the browser's built-in dedup. You can catch it and tell the user "you've already set up a passkey on this device" instead of creating a confusing duplicate.ConstraintErroris usually a missing screen lock. Especially on Android: if the device has no PIN, pattern, or biometric configured, the authenticator can't satisfy the "verify the user" requirement and bails. There's nothing to fix server-side; the user needs a lock screen.The cross-platform completion gap is real. The same flow does not convert equally everywhere. Corbado's field data puts identifier-first passkey completion around 85-95% on iOS but only roughly 45-60% on Windows 11 and lower still on Windows 10, where cross-device flows (scan a QR code with your phone) carry more of the load. The lesson isn't "don't use passkeys." It's keep a fallback, because a chunk of your users on some platforms will bounce off the passkey prompt and need the email-and-password path.
Your
.well-knownfile is a single point of failure. If you ever go multi-domain and use Related Origin Requests (a.well-known/webauthnfile that lets one passkey work across several of your domains), remember that file is served by your app. Take the homepage down for maintenance and you've also taken down passkey login across every related origin, because the browser can't fetch the manifest. It's a sneaky coupling that doesn't show up until the one time it matters.
Passkeys don't replace your password, yet
Here's the opinionated part, and it's the one decision people get wrong because the marketing says "passwordless". Keep passkeys sitting next to email-and-password login, not instead of it. Offer the fast, phishing-proof path, but don't rip out the fallback. That's the right call.
The reason is account recovery, and it's unglamorous. A password lives in your user's head; a passkey lives on a device. Devices get dropped in lakes, factory-reset, and left in taxis. If a passkey is the only way into an account and the device is gone, your recovery flow is the new weakest link, and a recovery flow that's too easy hands an attacker a way around all that lovely phishing resistance you just installed. So in practice you offer passkeys as the fast, phishing-proof everyday path, keep at least one fallback (a password, or a magic-link email), and let users register multiple passkeys so one lost device isn't a lockout.
There's a second axis worth knowing: synced vs device-bound. A consumer passkey synced through iCloud Keychain or Google Password Manager survives a lost phone because it's backed up to the cloud: convenient, and good enough for most apps. A device-bound passkey on a hardware security key never leaves that key: higher assurance, but lose the key and that credential is gone for good. Your server can express a preference for one or the other through the WebAuthn options, but for a typical web app, accepting synced passkeys is what gets you adoption.
That's the install. One feature flag, one contract on your User model, four lines of relying-party config, and two JavaScript calls. The payoff is that the next time someone walks off with your database, the worst they get is a list of public keys and a wasted afternoon. The framework finally makes the secure thing the easy thing. The only real work left is deciding your rpId once and never touching it again.
Originally published at nazarboyko.com.


Top comments (0)