惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
S
Securelist
V
Vulnerabilities – Threatpost
C
Cyber Attacks, Cyber Crime and Cyber Security
Schneier on Security
Schneier on Security
Cyberwarzone
Cyberwarzone
Simon Willison's Weblog
Simon Willison's Weblog
Hacker News - Newest:
Hacker News - Newest: "LLM"
P
Palo Alto Networks Blog
T
Troy Hunt's Blog
SecWiki News
SecWiki News
Security Archives - TechRepublic
Security Archives - TechRepublic
T
The Blog of Author Tim Ferriss
Project Zero
Project Zero
Microsoft Security Blog
Microsoft Security Blog
The Register - Security
The Register - Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
J
Java Code Geeks
F
Full Disclosure
阮一峰的网络日志
阮一峰的网络日志
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Attack and Defense Labs
Attack and Defense Labs
Know Your Adversary
Know Your Adversary
WordPress大学
WordPress大学
PCI Perspectives
PCI Perspectives
N
News | PayPal Newsroom
The Last Watchdog
The Last Watchdog
酷 壳 – CoolShell
酷 壳 – CoolShell
P
Privacy & Cybersecurity Law Blog
P
Proofpoint News Feed
V
Visual Studio Blog
C
CERT Recently Published Vulnerability Notes
H
Help Net Security
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
云风的 BLOG
云风的 BLOG
月光博客
月光博客
T
The Exploit Database - CXSecurity.com
I
InfoQ
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
U
Unit 42
腾讯CDC
小众软件
小众软件
V2EX - 技术
V2EX - 技术
罗磊的独立博客
Cloudbric
Cloudbric
Recorded Future
Recorded Future
IT之家
IT之家
Google DeepMind News
Google DeepMind News
C
CXSECURITY Database RSS Feed - CXSecurity.com

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Laravel's First-Party Passkeys: A Detailed WebAuthn Guide
Nazar Boyko · 2026-06-02 · via DEV Community

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:

  1. Your server generates a random challenge and a PublicKeyCredentialCreationOptions object describing what kind of key it wants. It hands these to the browser.
  2. The browser calls navigator.credentials.create({ publicKey: options }). The OS prompts the user for biometrics.
  3. 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 a clientDataJSON.
  4. 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:

  1. Server generates a new challenge and a PublicKeyCredentialRequestOptions object, hands it over.
  2. Browser calls navigator.credentials.get({ publicKey: options }). User taps their sensor.
  3. The authenticator signs over the challenge (technically over authenticatorData plus a hash of clientDataJSON) with the stored private key and returns a signature. Notice what's not in the response: the public key. The server already has it.
  4. 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.

Side-by-side sequence diagram of the two WebAuthn ceremonies: registration creating a key pair and authentication signing a challenge, across browser, authenticator, and Laravel server lanes.

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/passkeys is 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/passkeys is the npm package. It runs the browser-side ceremonies (the navigator.credentials calls 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 from config/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,
    ]),
],

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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,
],

Enter fullscreen mode Exit fullscreen mode

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.com while you're on www.example.com (more specific is fine). It will refuse to create a passkey for rpId www.example.com while you're on example.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.

Enter fullscreen mode Exit fullscreen mode

  • Changing the rpId invalidates every existing passkey. They're cryptographically bound to the old value. If you launch on app.example.com and later "tidy up" to example.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 is http://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 plain http:// on an IP or internal hostname: there, navigator.credentials.create() throws a SecurityError before 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

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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);
    }
});

Enter fullscreen mode Exit fullscreen mode

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",
    },
});

Enter fullscreen mode Exit fullscreen mode

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.

Reference diagram of Fortify's four passkey endpoint groups (login, confirm, register, delete), each following the GET-options then POST-submit pattern.

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:

  • NotAllowedError is 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 a NotAllowedError as "the user backed out", log it quietly, and let them try again. Don't paint the login screen red.

  • InvalidStateError means "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.

  • ConstraintError is 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-known file is a single point of failure. If you ever go multi-domain and use Related Origin Requests (a .well-known/webauthn file 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.