padosoft / laravel-rebel-bridge-passkeys
WebAuthn passkey step-up driver for Laravel Rebel: bridges spatie/laravel-passkeys into Rebel's step-up registry, issuing phishing-resistant AAL3 challenges.
Package info
github.com/padosoft/laravel-rebel-bridge-passkeys
pkg:composer/padosoft/laravel-rebel-bridge-passkeys
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- padosoft/laravel-rebel-core: ^0.1
- padosoft/laravel-rebel-step-up: ^0.1
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- padosoft/laravel-rebel-email-otp: ^0.1
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- spatie/laravel-passkeys: ^1.0
Suggests
- spatie/laravel-passkeys: Provides the SpatiePasskeyChallenger implementation backed by spatie/laravel-passkeys. Without it, bind your own PasskeyChallenger to the container.
This package is auto-updated.
Last update: 2026-06-04 02:06:07 UTC
README
WebAuthn passkey step-up driver for Laravel Rebel.
Bridges spatie/laravel-passkeys (or any WebAuthn library you prefer) into Rebel's step-up
DriverRegistry, giving you a phishing-resistant (AAL2) step-up factor with one composer require
and two lines of configuration.
Glossary
Before diving in, here are the terms you will encounter throughout this README.
| Term | What it means |
|---|---|
| Passkey | A cryptographic credential stored in a hardware-backed secure enclave (phone, laptop TPM, YubiKey). You use it with Face ID, fingerprint, or a PIN. No password involved. |
| WebAuthn / FIDO2 | The W3C + FIDO Alliance standard that defines how browsers talk to authenticators. Passkeys are WebAuthn credentials. |
| Phishing-resistant | An assertion is cryptographically bound to the exact origin (domain + protocol) of the page that called navigator.credentials.get(). A phishing site on a different domain literally cannot capture and replay a valid assertion — the signature simply won't verify. OTP/TOTP/SMS are NOT phishing-resistant. |
| AAL2 (phishing-resistant) | NIST SP 800-63B Authenticator Assurance Level 2. This driver declares AAL2 + phishing-resistant: a passkey can't be phished, but most consumer passkeys are synced across a vendor cloud (iCloud Keychain, Google Password Manager) rather than hardware-bound, so strict NIST reserves AAL3 for device-bound/security-key authenticators only. The honest default is AAL2-phishing-resistant; a deployment that mandates device-bound passkeys can raise it. |
| AMR | Authentication Methods Reference — a list of short strings (e.g. ['webauthn']) that describe HOW the user authenticated. Used by compliance and audit systems. |
| Step-up | An in-session re-authentication challenge. The user is already logged in; before a sensitive action (e.g. changing payment method, approving a transfer) you ask them to confirm their identity again using a strong factor. |
| DriverRegistry | The Rebel step-up registry. Each package contributes its own driver (email-OTP, TOTP, passkey…). The Rebel core selects the right driver for a given purpose/assurance policy. |
| PasskeyChallenger | The seam (interface) in this bridge. It decouples the driver logic from any specific WebAuthn library. |
How a passkey step-up works (step by step)
Browser Your Laravel App Secure Enclave / Key
| | |
|--- 1. User clicks "Confirm" ----->| |
| |--- 2. start() ----------------->|
| | PasskeyChallenger.startChallenge()
| | Generates a fresh random nonce (challenge)
|<-- 3. WebAuthn options JSON -------| |
| (challenge embedded) | |
| | |
|--- 4. navigator.credentials.get() (browser asks OS to sign) ------->|
|<----- 5. Signed assertion (JSON) ------- (Face ID / fingerprint) ---|
| | |
|--- 6. POST assertion JSON -------->| |
| |--- 7. verify() ---------------->|
| | PasskeyChallenger.verifyAssertion()
| | - challenge matches nonce from step 2?
| | - cryptographic signature valid?
| | - origin == registered domain?
| | => true or false
|<-- 8. 200 OK (or 422 Forbidden) --|
Replay protection: the nonce issued in step 2 is stored as the step-up reference. Even if an
attacker intercepts the signed assertion in step 5, they cannot replay it — verifyAssertion() will
reject it because the nonce has already been consumed (or a new nonce will be different).
Phishing protection: the browser signs the assertion with the private key only for the exact
origin that issued the challenge. If you host pay.example.com and an attacker sets up
pay-examp1e.com, the assertion from the real device is cryptographically bound to pay.example.com
and will NOT verify on the phishing domain.
Requirements
- PHP
^8.3 - Laravel
^12or^13 padosoft/laravel-rebel-core ^0.1padosoft/laravel-rebel-step-up ^0.1- A
PasskeyChallengerbinding in your container (see Installation)
Optional (for the built-in Spatie adapter):
spatie/laravel-passkeys ^1.0
Installation
1. Install the bridge
composer require padosoft/laravel-rebel-bridge-passkeys
The package auto-discovers and registers its service provider. No manual registration needed.
2. Bind a PasskeyChallenger
The bridge ships with a production adapter for spatie/laravel-passkeys. Install it:
composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate
Then bind the adapter in your AppServiceProvider:
use Padosoft\Rebel\Bridge\Passkeys\Challengers\SpatiePasskeyChallenger; use Padosoft\Rebel\Bridge\Passkeys\Contracts\PasskeyChallenger; public function register(): void { $this->app->singleton(PasskeyChallenger::class, SpatiePasskeyChallenger::class); }
3. Add HasPasskeys to your User model
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys; use Spatie\LaravelPasskeys\Models\Passkeys\HasPasskeysTrait; class User extends Authenticatable implements HasPasskeys { use HasPasskeysTrait; // ... }
4. Publish the config (optional)
php artisan vendor:publish --tag="rebel-bridge-passkeys-config"
Bringing your own WebAuthn library
Not using spatie/laravel-passkeys? No problem. Implement the PasskeyChallenger interface
against your preferred library (e.g. web-auth/webauthn-framework) and bind it:
use App\WebAuthn\MyWebAuthnChallenger; use Padosoft\Rebel\Bridge\Passkeys\Contracts\PasskeyChallenger; $this->app->singleton(PasskeyChallenger::class, MyWebAuthnChallenger::class);
The interface you need to implement:
interface PasskeyChallenger { // Does this user have at least one passkey registered? public function hasPasskey(Authenticatable $user): bool; // Issue a fresh single-use challenge. Return an opaque string (the nonce, // or serialised WebAuthn options JSON) that you can later pass to verifyAssertion(). public function startChallenge(Authenticatable $user): string; // Verify the assertion (JSON from navigator.credentials.get()) against // the challenge issued by startChallenge(). Return true on full success. public function verifyAssertion(Authenticatable $user, string $assertion, string $challenge): bool; }
Configuration
config/rebel-bridge-passkeys.php:
| Key | Environment variable | Type | Default | Description |
|---|---|---|---|---|
drivers.passkeys |
REBEL_PASSKEYS_DRIVER_PASSKEYS |
bool |
true |
Enable the 'passkeys' step-up driver. The driver is only registered when this is true AND a PasskeyChallenger is bound in the container. Set to false to disable the driver without unbinding the challenger. |
Usage examples
Example 1 — Protect a route with a passkey step-up
// routes/web.php Route::post('/account/delete', [AccountController::class, 'destroy']) ->middleware(['auth', 'rebel.stepup:delete-account']);
// config/rebel-step-up.php 'purposes' => [ 'delete-account' => [ 'required_aal' => 'aal2', 'require_phishing_resistant' => true, 'drivers' => ['passkeys'], ], ],
Example 2 — Manual step-up in a controller
use Padosoft\Rebel\StepUp\RebelStepUp; use Padosoft\Rebel\StepUp\StepUpContext; use Padosoft\Rebel\Core\Context\SecurityContext; class PaymentController extends Controller { public function confirm(Request $request, RebelStepUp $stepUp): JsonResponse { $context = new StepUpContext( subject: $request->user(), purpose: 'confirm-payment', security: SecurityContext::fromRequest($request, app(KeyedHasher::class)), ); // Start the step-up: issues a WebAuthn challenge. $result = $stepUp->start($context, driver: 'passkeys'); return response()->json([ 'challenge_reference' => $result->reference, 'webauthn_options' => $result->options, ]); } public function verify(Request $request, RebelStepUp $stepUp): JsonResponse { $context = new StepUpContext( subject: $request->user(), purpose: 'confirm-payment', security: SecurityContext::fromRequest($request, app(KeyedHasher::class)), ); $verified = $stepUp->verify( $context, input: $request->input('assertion'), // JSON from navigator.credentials.get() reference: $request->input('reference'), // reference from start() ); if (! $verified) { return response()->json(['error' => 'Step-up failed'], 422); } // Proceed with the protected action. $this->processPayment($request); return response()->json(['status' => 'confirmed']); } }
Example 3 — Check if the driver is available before showing the UI
use Padosoft\Rebel\StepUp\DriverRegistry; use Padosoft\Rebel\StepUp\StepUpContext; $driver = app(DriverRegistry::class)->get('passkeys'); if ($driver && $driver->isAvailableFor($context)) { // Show the "Use passkey" button. }
Example 4 — Testing with FakePasskeyChallenger
use Padosoft\Rebel\Bridge\Passkeys\Contracts\PasskeyChallenger; use Padosoft\Rebel\Bridge\Passkeys\Testing\FakePasskeyChallenger; // In your test boot or TestCase::defineEnvironment(): $app->singleton(PasskeyChallenger::class, fn () => new FakePasskeyChallenger( registered: true, // user has a passkey expectedAssertion: 'valid', // the assertion string that will be accepted challenge: 'test-nonce', // the challenge returned by startChallenge() )); // All step-up driver logic works offline without any WebAuthn ceremony.
Audit events
Every step-up action is recorded to rebel_auth_events via the core AuditLogger. The following
events are emitted so that the step-up funnel, compliance AMR breakdown, and audit explorer panels
in the Rebel admin all light up:
| Event type | When | channel |
amr |
aal |
|---|---|---|---|---|
stepup.passkeys.started |
A challenge was issued via start() |
passkey |
['webauthn'] |
aal2 |
stepup.passkeys.verified |
Assertion verified successfully | passkey |
['webauthn'] |
aal2 |
stepup.passkeys.failed |
Assertion rejected, missing reference, or any exception | passkey |
['webauthn'] |
aal2 |
All events include subjectType and subjectId (the authenticated user's class and ID). The raw
WebAuthn assertion and credential bytes are never included in any audit field.
Competitor card-battle table
How does passkey-based phishing-resistant (AAL2) step-up compare to what other platforms offer?
| Feature | Laravel Rebel (this package) | Laravel Fortify (plain) | Shopify | Auth0 | Okta |
|---|---|---|---|---|---|
| Passkey / WebAuthn step-up | ✅ AAL2, phishing-resistant | ❌ No step-up system | ❌ No developer step-up API | ⚠️ Enterprise tier only | ⚠️ Paid addon |
| Phishing-resistant by spec | ✅ Origin-bound by design | ❌ | ❌ | ✅ (if enabled) | ✅ (if enabled) |
| AMR / AAL in audit trail | ✅ ['webauthn'], AAL2 |
❌ No standard audit | ❌ | ⚠️ OIDC claims only | ⚠️ OIDC claims only |
| Bring your own WebAuthn lib | ✅ Any library via PasskeyChallenger | ❌ | ❌ | ❌ | ❌ |
| Works without Fortify | ✅ Zero Fortify dependency | N/A | N/A | N/A | N/A |
| Offline tests (no browser) | ✅ FakePasskeyChallenger | ❌ | ❌ | ❌ | ❌ |
| Step-up funnel analytics | ✅ started/verified/failed events | ❌ | ❌ | ⚠️ Paid dashboard | ⚠️ Paid dashboard |
| Open source / MIT | ✅ | ✅ (no step-up) | ❌ Closed SaaS | ❌ Closed SaaS | ❌ Closed SaaS |
Vibe coding with batteries included
This package ships everything an AI agent or a new contributor needs to keep working productively:
CLAUDE.md— AI working guide: conventions, security rules, PHPStan recipes, Definition of Done.AGENTS.md— Operational contract: branching, PR flow, guardrails, release checklist..claude/skills/rebel-package-dev/SKILL.md— Invocable skill for the dev loop (TDD, PHPStan-max fixes, telemetry rules).Testing\FakePasskeyChallenger— Deterministic test double: configure accept/reject, which users have a passkey, and which challenge is issued. Fully offline — no browser, no WebAuthn API call.
Dev loop
composer test # Pest tests (offline, uses FakePasskeyChallenger) composer phpstan # Static analysis, level max composer pint # Code style (Laravel preset)
Quick start for contributors
git clone https://github.com/padosoft/laravel-rebel-bridge-passkeys cd laravel-rebel-bridge-passkeys composer install composer test
Package structure
src/
Challengers/
SpatiePasskeyChallenger.php # Production adapter for spatie/laravel-passkeys
Contracts/
PasskeyChallenger.php # The seam: swap the WebAuthn library here
Drivers/
PasskeysStepUpDriver.php # Core driver (key:'passkeys', AAL2, phishing-resistant)
Testing/
FakePasskeyChallenger.php # Deterministic test double
RebelPasskeysBridgeServiceProvider.php
config/
rebel-bridge-passkeys.php # drivers.passkeys toggle
tests/
Feature/
PasskeysDriverTest.php # 17 tests covering all paths
License
MIT — see LICENSE.
Part of the Laravel Rebel enterprise-auth suite by Padosoft.
