darkghosthunter/larapass
Larapass adds WebAuthn and passkey authentication to Laravel. Users can register security keys or device biometrics and sign in without a password, with ready-made models, routes, and events for simple, phishing-resistant passwordless login.
composer require darkghosthunter/larapass
php artisan vendor:publish --tag=larapass-config
php artisan vendor:publish --tag=larapass-migrations
php artisan migrate
config/larapass.php first. The four knobs that cause 90% of setup pain are relying_party.id, relying_party.origin, user_handle, and guard.use DarkGhostHunter\Larapass\WebAuthnAuthenticatable;
class User extends Authenticatable
{
use WebAuthnAuthenticatable; // adds webAuthnCredentials() and handle logic
}
navigator.credentials.create() / navigator.credentials.get() calls against them.POST attestation), confirm a row lands in web_authn_credentials, then log out and authenticate with that passkey through the login endpoints. Once that loop works end-to-end, everything else is configuration.SHOW COLUMNS/Schema::getColumnType() check on web_authn_credentials.attestation_type; it should be a string, not JSON.Standard register-then-login flow
Two browser round trips per ceremony: fetch options, then post the credential/assertion back. Keep the challenge in the same session/statefulness context.
GET options → navigator.credentials.create() → POST attestation payload → package stores a WebAuthn\PublicKeyCredentialSource in web_authn_credentials.GET options → navigator.credentials.get() → POST payload → package validates signature and resolves the user.Headless / custom controllers
Skip the shipped routes and drive the facade directly when you need a JSON API, custom guards, or extra business rules:
use DarkGhostHunter\Larapass\Facades\WebAuthn; // or Larapass facade
$options = WebAuthn::generateAttestation($user); // PublicKeyCredentialCreationOptions
// ... send JSON to browser, receive response ...
WebAuthn::validateAttestation($data, $user); // returns credential source
Disable the built-in routes (routes => false or the env toggle) as soon as you go headless, otherwise you get duplicate URLs and confusing CSRF failures.
Userless (passwordless) login
Pass a null user into the assertion options to get an "allow any credential" challenge; the package resolves the owning user from the stored user handle during validation. This is the pattern for a login page that doesn't ask for an email first — but it only works for credentials captured with a stable, globally unique user_handle.
Guard and multi-auth scenarios
Set guard in config when the passkey endpoints must authenticate into a non-default guard. Everything downstream (the Authenticated event payload, Auth::guard() resolution, redirect targets) keys off this setting, so set it explicitly rather than relying on the default.
Event-driven side effects
Hook listeners to the package's domain events instead of overriding controllers:
AttestationSuccessful / AuthenticationSuccessful — audit log, notify, rate-limit resetAttestationFailed / AuthenticationFailed — throttling, alerting, lockout countersCredentialCreated / CredentialUpdated / CredentialDeleted — invalidate sessions, sync to other services, fire analyticsRegister them in EventServiceProvider; they're the cleanest extension point the package offers.
Attestation policy
attestation in config controls whether you request none, self, or basic conveyance. none is right for consumer apps; basic/direct only if you actually verify the attestation chain server-side and gate enrollment on it (e.g. hardware-key-only access for staff).
web_authn_credentials.attestation_type was incorrectly declared as json; it should be string. The stored value was being written as a bare attestation constant, so a JSON column either double-encodes or fails validation on read. Write a migration:
Schema::table('web_authn_credentials', fn (Blueprint $t) =>
$t->string('attestation_type')->nullable()->change()
);
If you have existing rows, normalize them (json_decode the quoted value, or null them and re-register) before switching the type. Note ->change() needs doctrine/dbal on older Laravel versions.relying_party.id must be the registrable domain suffix of the browser origin, and relying_party.origin must be scheme+host+port identical to what the browser sees. A trailing slash, http vs https, or a www. mismatch yields a cryptically generic SecurityError in the browser with no server log.localhost. No exceptions, and emulator/ngrok hostnames count as their own origins — you'll need separate config for each.Sanctum + stateful middleware missing), you get "challenge not found" / invalid-type errors. Ensure the session cookie and CSRF token are sent on the POST.user_handle must be immutable and unique. If you point it at email and users can change their email, previously registered passkeys stop resolving. Prefer an opaque, never-changing column.config/larapass.php against the package's current stub (vendor:publish --force into a temp path, compare) — config keys were realigned, so a stale published file may silently fall back to defaults for guard, timeout, or attestation.timeout rather than chasing phantom server bugs.CredentialDeleted.How can I help you explore Laravel packages today?