Passwords are a liability. Laravel finally has first-party passkeys.
Passwords are the weakest part of your app, and you’ve known it for years. People reuse them, they get phished, they paste them into a login page that looks near enough to the real one. You can bolt on 2FA, rate limiting, a haveibeenpwned check at signup — it all helps — but underneath it, you’re still defending a shared secret that the user will happily hand to anyone who asks nicely enough.
Passkeys fix the actual problem rather than padding around it. There’s no shared secret to steal: the private key never leaves the user’s device, the signature is bound to your domain, and phishing simply stops working because the browser won’t release a key to the wrong origin. They’ve been viable for a couple of years now, but in Laravel land adding them used to mean reaching for a third-party WebAuthn package and writing a fair bit of glue to make it sit nicely with the rest of your auth.
That’s changed. Laravel now ships a first-party laravel/passkeys package that plugs straight into Fortify. It’s early days — still pre-1.0 as I write this, so expect the odd edge of the API to shift under you before it settles — but it already does the job well. I went passkey-first on a recent Laravel 13 build — passkeys as the default, passwords as the fallback rather than the other way round — and the headline is this: the WebAuthn ceremony is the easy part. The interesting work, the part the docs skip over, is what “this account has no password” does to the rest of your flows. We’ll get to that. First, the easy bit.
Getting it in
The whole thing rides on Fortify, so you’ll want that in place already. From there it’s a composer require, a npm install for the browser half, and a migration:
# composer.json — Laravel 13, PHP 8.5
composer require laravel/passkeys
npm install @laravel/passkeys
php artisan vendor:publish --tag=passkeys-migrations
php artisan migrate
The migration it publishes is refreshingly boring, which is what you want from a credentials table:
// database/migrations/xxxx_create_passkeys_table.php
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete();
$table->string('name'); // "MacBook", "iPhone" — the user names it
$table->string('credential_id')->unique(); // the WebAuthn credential handle
$table->json('credential'); // the public key blob
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
No private keys in there — that’s the whole point. You store the public key and a handle, and the device keeps the secret half to itself.
The user model
Two lines on the model. Implement the contract, pull in the trait:
// app/Models/User.php
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\PasskeyAuthenticatable;
class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
{
use PasskeyAuthenticatable;
}
The trait gives you a passkeys() relationship and the bits Fortify needs to look a user up by their credential. Then turn the feature on in Fortify’s config:
// config/fortify.php
Features::passkeys([
'confirmPassword' => true,
]),
That confirmPassword flag matters more than it looks — it routes the package’s sensitive actions through Fortify’s password-confirmation gate. Hold that thought, because it’s the first thing that bites you once you’ve got passwordless users. For now it’s doing exactly what you want.
Login and register
Enabling the feature registers a set of routes for the ceremony — /passkeys/login/options and /passkeys/login for signing in, plus /user/passkeys/options and /user/passkeys for adding one while logged in. You don’t write those. What you write is the thin browser layer that calls them, and the package gives you a helper for that too:
// resources/js/passkeys.js
import { Passkeys } from '@laravel/passkeys'
const loginRoutes = {
options: '/passkeys/login/options',
submit: '/passkeys/login',
}
const registrationRoutes = {
options: '/user/passkeys/options',
submit: '/user/passkeys',
}
window.passkeys = {
isSupported: () => Passkeys.isSupported(),
verify: () => Passkeys.verify({ routes: loginRoutes }),
register: ({ name }) => Passkeys.register({ name, routes: registrationRoutes }),
autofill: () => Passkeys.autofill({ routes: loginRoutes }),
}
Under the bonnet Passkeys.verify() is doing the navigator.credentials.get() dance — fetching a challenge from your server, asking the browser and the device to sign it, posting the result back. You don’t have to touch any of that, which is the part that used to be a slog.
The nice touch is autofill(). Put autocomplete="email webauthn" on your email field and run the autofill call on mount, and the browser offers the user’s passkey right there in the email box’s dropdown — no button press, no second screen:
{{-- the email field on your login page --}}
<input name="email" type="email" autocomplete="email webauthn" />
// run once the login page is up
if (await window.passkeys.isAutofillSupported()) {
window.passkeys.autofill()
}
I still keep an explicit “Sign in with a passkey” button alongside it as a fallback, because conditional UI support is patchy enough that you don’t want it to be the only way in. Belt and braces.
Managing passkeys
A passkey isn’t much use if the user can only ever have one. The account page wants a small list — name, when it was added, when it was last used — an “add” form, and a remove button per row. Adding is the same register call from above with a name attached; the relationship gives you the list for free:
@foreach (auth()->user()->passkeys as $passkey)
<li>
<strong>{{ $passkey->name }}</strong>
<span>Added {{ $passkey->created_at->diffForHumans() }}</span>
<span>{{ $passkey->last_used_at?->diffForHumans() ?? 'Never used' }}</span>
</li>
@endforeach
So far, so CRUD. And if I’d stopped at passwords-with-passkeys-bolted-on, this is where the post would end. But I went passwordless-first, and that’s where it gets interesting.
The good bit: passwordless signup
Passkey-first means a new user can sign up without ever choosing a password. They give you a name and an email, the browser creates a passkey, and that’s the account. No password field, nothing to forget, nothing to leak.
The catch is sequencing. You can’t run the passkey-registration ceremony for a user who doesn’t exist yet — the credential has to belong to a record. So it’s a two-step move: create the account first (with a null password), log the user in, then run the standard registration ceremony against the now-authenticated user. A small dedicated endpoint handles step one:
// app/Http/Controllers/Auth/PasskeyRegistrationController.php
public function __invoke(Request $request, CreateNewUser $createNewUser): JsonResponse
{
$input = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
]);
$user = $createNewUser->createWithoutPassword($input);
event(new Registered($user)); // fires your email-verification mail
Auth::login($user);
$request->session()->regenerate();
// Prime the password-confirmation timestamp so the user can chain
// straight into the passkey ceremony without being bounced to
// /password/confirm — which a passwordless user can't satisfy.
$request->session()->put('auth.password_confirmed_at', time());
return response()->json(['redirect' => route('verification.notice')]);
}
The user-creation action just writes a null password instead of a hashed one:
// app/Actions/Fortify/CreateNewUser.php
public function createWithoutPassword(array $input): User
{
return User::create([
'name' => $input['name'],
'email' => strtolower($input['email']),
'password' => null,
]);
}
Note the comment about priming auth.password_confirmed_at. That’s the first of the dead-ends, met early: the registration ceremony is a sensitive action behind Fortify’s confirm-password gate (remember that flag), and a user who’s three seconds into existing and has no password can’t satisfy that gate. So you tell the session the password was “just confirmed” — which is true enough; they proved control of the account by creating it this instant — and let them through.
I didn’t force passwordless on everyone, mind. Plenty of people still want a password, and that’s fine. So the password path stays, and after a password user verifies their email I show them a one-time interstitial: you could add a passkey, you know. The logic for whether to nudge them is small and worth getting right so you don’t pester the same person twice:
private function shouldSuggestPasskey(User $user): bool
{
return $user->password !== null // passwordless users already have one
&& $user->passkey_suggestion_dismissed_at === null // not dismissed before
&& $user->passkeys()->doesntExist(); // doesn't already have a passkey
}
The dead-ends nobody warns you about
Here’s the thing the tutorials don’t tell you. The moment you allow password === null, you’ve created accounts that can’t do two things the rest of your auth quietly assumes everyone can do. Both will let a real person lock themselves out if you don’t design around them.
You can’t confirm a password you don’t have
Fortify guards sensitive actions — adding a passkey, changing security settings — behind a re-confirm-your-password prompt. Sensible for password users. Useless for passwordless ones: there’s no password to type, so the prompt is a wall with no door.
The fix is to treat a passkey as a valid way to re-confirm. Before any sensitive action, check whether the confirmation window is still open; if it’s lapsed and the user has a passkey, run the passkey confirm ceremony instead of redirecting them to a password form they can’t fill in:
async ensureConfirmed() {
const { confirmed } = await (await fetch('/user/confirmed-password-status')).json()
if (confirmed) return true
// No password to confirm? Re-confirm with a passkey instead.
if (this.userHasPasskeys && (await window.passkeys.isSupported())) {
return await window.passkeys.confirm() // hits /passkeys/confirm/*
}
// Genuinely has a password and no passkey — send them to the form.
window.location.href = '/user/confirm-password'
}
The package gives you the confirm routes; you just have to remember to reach for them. Miss this, and your passwordless users hit a brick wall the first time they try to manage their own account, which is a deeply unfunny bug to get reported.
You can’t delete your only way in
The other one is nastier because it’s a foot-gun the user fires themselves. A passwordless account with exactly one passkey is one “remove” click away from having no way to log in, ever. No password, no passkey, no door.
So the last credential gets a guard. In the remove handler, refuse the delete if it would leave a passwordless user with nothing — and wrap the count-and-delete in a transaction with an advisory lock, so two browser tabs both clicking remove at the same instant can’t each pass the check and delete the last two between them:
public function destroy(int $passkeyId): void
{
$user = auth()->user();
DB::transaction(function () use ($user, $passkeyId): void {
// Serialise count + delete per user so two concurrent removes
// can't both slip past the last-passkey check.
DB::statement('SELECT pg_advisory_xact_lock(?)', [$user->id]);
$count = $user->passkeys()->count();
if ($user->password === null && $count === 1) {
$this->toastWarning(
"Can't remove your last passkey",
"Set a password first, or you'll be locked out of your account.",
);
return;
}
$user->passkeys()->whereKey($passkeyId)->first()?->delete();
});
}
That covers the button on the account page. But Fortify also exposes its own DELETE /user/passkeys/{passkey} route, and I’d rather not rely on every caller remembering the rule. So the same guard goes on the model as a deleting observer — defence in depth, and it gives you one place that’s authoritative no matter which path the delete came in on:
// app/Observers/PasskeyObserver.php
public function deleting(Passkey $passkey): void
{
$actor = auth()->user();
if (! $actor instanceof User) {
return; // CLI / system deletes — leave them be
}
$owner = User::find($passkey->user_id);
if ($owner?->getKey() !== $actor->getKey()) {
return; // an admin removing someone else's — not our concern here
}
if ($owner->password !== null) {
return; // has a password to fall back on
}
if ($owner->passkeys()->count() > 1) {
return; // not their last one
}
abort(403, 'Cannot remove the last passkey for a passwordless account.');
}
Notice it deliberately stays out of the way of admin and CLI deletes — you don’t want your defence-in-depth guard blocking a support agent or a cleanup job. It only fires when a user is about to remove their own last way in.
Was it worth it
Yes, easily. The actual passkey machinery — the part I’d have dreaded a couple of years ago — Laravel now does for you, and does well. composer require, two lines on the model, a config flag, a thin sprinkle of JavaScript, and you’ve got phishing-resistant logins that most users find easier than typing a password.
The lesson, if there is one, is that the cryptography was never the hard part. The hard part is the blast radius of a single design decision: allow a passwordless account, and you have to go and find every flow that quietly assumed a password exists — password confirmation, last-credential deletion, the recovery story when someone abandons signup halfway through — and design each one again. None of it is difficult. All of it is easy to forget until a user finds it for you.
Add the passkeys. Just go in knowing the package hands you the easy 80%, and the remaining 20% is yours to think about. Have fun.
Filed under Developer. No comments, on purpose.