padosoft / laravel-rebel-bridge-otpz
Bridge the benbjurstrom/otpz email one-time-password package into Laravel Rebel step-up. Exposes OTP email magic-code as a step-up driver (AAL2, AMR otp). Part of padosoft/laravel-rebel-*.
Package info
github.com/padosoft/laravel-rebel-bridge-otpz
pkg:composer/padosoft/laravel-rebel-bridge-otpz
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)
- benbjurstrom/otpz: ^0.7.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- benbjurstrom/otpz: Enables the OTP email one-time-password step-up driver. Requires the otpz migrations and the Otpable interface on the user model.
This package is auto-updated.
Last update: 2026-06-04 02:08:45 UTC
README
laravel-rebel-bridge-otpz
Step-up driver that bridges benbjurstrom/otpz (email one-time-passcode login) into the Laravel Rebel step-up framework. A user who has already logged in can re-confirm sensitive actions by entering a short code emailed to them — no password required, fully audited, offline-testable.
Glossary
| Term | Meaning |
|---|---|
| Step-up | A re-confirmation challenge issued to an already-authenticated user before a sensitive action (e.g. wire transfer, delete account). The user is not logged out; they just prove fresh intent. |
| OTP | One-Time Password — a short numeric or alphanumeric code that is valid exactly once and expires in minutes. |
| Magic code | The term otpz uses for its OTP: a 10-char uppercase code sent by email; hashed at rest; consumed on first successful use. |
| AAL | Authenticator Assurance Level (NIST SP 800-63B). AAL1 = one factor; AAL2 = two distinct factors; AAL3 = hardware key. |
| AMR | Authentication Methods Reference — a list of how the user proved identity, e.g. ['otp']. |
| Phishing-resistant | A method that cannot be replayed on a fake site in real time. Email OTP is NOT phishing-resistant (the attacker can forward it). Passkeys are. |
| DriverRegistry | Laravel Rebel step-up component that holds all registered step-up drivers. |
| AuditLogger | Core service that writes security events to rebel_auth_events (DB), never to session or log files. |
How it works
User clicks "Confirm wire transfer"
│
▼
RebelStepUp manager finds driver 'otpz'
│
▼
OtpzStepUpDriver::start(context)
├─ OtpzBroker::issue(subject)
│ └─ CreateOtp::handle(user) ← benbjurstrom/otpz
│ ├─ generates 10-char code
│ ├─ hashes + stores in DB (otps table)
│ └─ emails code to user
└─ returns UUID of Otp record (opaque reference)
└─ emits stepup.otpz.started audit event
│
▼
User reads email, types code into your UI
│
▼
OtpzStepUpDriver::verify(context, code, reference)
├─ OtpzBroker::verify(reference, code)
│ ├─ loads Otp by UUID
│ ├─ checks: status=ACTIVE, not expired, attempts < 3
│ ├─ Hash::check(code, stored_hash)
│ └─ marks USED on success
└─ emits stepup.otpz.verified OR stepup.otpz.failed
│
▼
RebelStepUp grants (or denies) the action
Why not use AttemptOtp::handle()?
The otpz package's AttemptOtp action validates an HTTP signed URL and the browser session ID.
Neither exists in a step-up flow (the challenge/response is managed by the Rebel layer, not HTTP).
This bridge therefore calls CreateOtp for issue and re-implements the same business rules
(status, expiry, attempt limits, Hash::check) in OtpzBrokerImpl::verify() — all in-process,
no HTTP coupling.
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.3 |
| Laravel | ^12.0 or ^13.0 |
| padosoft/laravel-rebel-core | ^0.1 |
| padosoft/laravel-rebel-step-up | ^0.1 |
| benbjurstrom/otpz | ^0.7 (optional — see install) |
Installation
1. Install this bridge
composer require padosoft/laravel-rebel-bridge-otpz
2. Install and configure otpz
composer require benbjurstrom/otpz
php artisan vendor:publish --tag="otpz-migrations"
php artisan migrate
3. Make your User model implement Otpable
// app/Models/User.php use BenBjurstrom\Otpz\Models\Concerns\HasOtps; use BenBjurstrom\Otpz\Models\Concerns\Otpable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable implements Otpable { use HasOtps, Notifiable; // ... }
4. Publish the bridge config (optional)
php artisan vendor:publish --tag="rebel-bridge-otpz-config"
That's it. The OtpzStepUpDriver registers itself automatically on boot.
Configuration
File: config/rebel-bridge-otpz.php
| Key | Default | Effect |
|---|---|---|
drivers.otpz |
true |
Register the OtpzStepUpDriver into DriverRegistry. Set false to disable without uninstalling the bridge. |
options.max_attempts |
3 |
Maximum failed verification attempts before the OTP is invalidated (mirrors otpz's hard-coded value; for reference). |
The otpz package's own config (config/otpz.php) controls expiry time, rate-limit thresholds,
mailable class, and user resolver.
Usage examples
Example 1 — Trigger step-up before a destructive action
use Padosoft\Rebel\StepUp\RebelStepUp; use Padosoft\Rebel\StepUp\StepUpContext; use Padosoft\Rebel\Core\Context\SecurityContext; public function deleteAccount(Request $request, RebelStepUp $stepUp): JsonResponse { $context = new StepUpContext( subject: $request->user(), purpose: 'account-deletion', security: SecurityContext::fromRequest( $request, app(\Padosoft\Rebel\Core\Contracts\KeyedHasher::class) ), ); // start() emails the OTP; returns the opaque reference to store in session $reference = $stepUp->driver('otpz')->start($context); session(['stepup_ref' => $reference]); return response()->json(['status' => 'otp_sent']); }
Example 2 — Verify the submitted code
public function confirmDelete(Request $request, RebelStepUp $stepUp): JsonResponse { $context = new StepUpContext( subject: $request->user(), purpose: 'account-deletion', security: SecurityContext::fromRequest( $request, app(\Padosoft\Rebel\Core\Contracts\KeyedHasher::class) ), ); $ok = $stepUp->driver('otpz')->verify( context: $context, input: $request->input('code'), reference: session('stepup_ref'), ); if (! $ok) { return response()->json(['error' => 'Invalid or expired code'], 422); } // proceed with deletion… $request->user()->delete(); return response()->json(['status' => 'deleted']); }
Example 3 — Check availability before starting
$driver = app(\Padosoft\Rebel\StepUp\DriverRegistry::class)->get('otpz'); if ($driver && $driver->isAvailableFor($context)) { $ref = $driver->start($context); // store $ref and redirect to code-entry screen }
Example 4 — Testing with FakeOtpzBroker (offline, no email)
use Padosoft\Rebel\Bridge\Otpz\Contracts\OtpzBroker; use Padosoft\Rebel\Bridge\Otpz\Testing\FakeOtpzBroker; beforeEach(function (): void { $fake = new FakeOtpzBroker(capable: true); app()->instance(OtpzBroker::class, $fake); $this->fake = $fake; }); it('accepts the correct code', function (): void { $driver = app(\Padosoft\Rebel\Bridge\Otpz\Drivers\OtpzStepUpDriver::class); $user = User::factory()->create(); $ctx = new StepUpContext($user, 'checkout', new SecurityContext('r')); $ref = $driver->start($ctx); expect($driver->verify($ctx, $this->fake->defaultCode, $ref))->toBeTrue(); });
Example 5 — Sanctum API (mobile/SPA)
// routes/api.php Route::middleware('auth:sanctum')->group(function () { Route::post('/step-up/start', [StepUpController::class, 'start']); Route::post('/step-up/verify', [StepUpController::class, 'verify']); });
The step-up driver is transport-agnostic: start() and verify() work identically over a
Sanctum-protected API endpoint. Store the returned reference (UUID) in the response body; the
mobile client submits it back with the code.
Audit events
All events are written to rebel_auth_events by the core AuditLogger. They are never stored
in the session or PHP log files.
| Event type | When emitted | Key fields |
|---|---|---|
stepup.otpz.started |
start() succeeds (OTP sent) |
channel=email, aal=aal2, amr=['otp'], subjectType, subjectId, purpose |
stepup.otpz.verified |
verify() returns true |
same |
stepup.otpz.failed |
verify() returns false (wrong/expired/consumed code, null reference, or broker error) |
same |
The OTP code itself is never present in any audit event.
Security notes
- Fail closed: any
\Throwableinstart()returnsnull; any\Throwableinverify()returnsfalse. The driver never propagates exceptions to the caller. - Single-use: once verified, the OTP record is marked
USEDand cannot be redeemed again. - Attempt limiting: after 3 failed attempts the record is marked
ATTEMPTED(invalidated). - Expiry: default 5 minutes (configurable in
config/otpz.php). - Hashed at rest: otpz uses Laravel's
hashedcast on thecodecolumn — the plaintext code is never persisted. - Reference is opaque: the step-up layer stores only the OTP UUID as its reference. Even if an attacker reads the reference from a compromised session, they still need to know the code.
Competitor card-battle
| Feature | laravel-rebel-bridge-otpz | Laravel Fortify | Laravel Sanctum | Shopify Multipass |
|---|---|---|---|---|
| Email OTP step-up (post-auth) | ✅ Native driver | ❌ No step-up concept | ❌ No step-up concept | ❌ Login-only, no step-up |
| AAL2 assurance declared | ✅ Aal::Aal2 |
❌ No assurance model | ❌ No assurance model | ❌ No assurance model |
| AMR metadata in audit | ✅ ['otp'] per event |
❌ | ❌ | ❌ |
| Offline-testable seam | ✅ FakeOtpzBroker |
❌ HTTP-coupled | ❌ | ❌ |
| Fail-closed on Throwable | ✅ Always false/null |
❌ Throws | ❌ | ❌ |
| Audit trail to DB | ✅ rebel_auth_events |
❌ No audit | ❌ No audit | ❌ No audit |
| Single-use + attempt limit | ✅ otpz enforces | ❌ | ❌ | ❌ |
| Code hashed at rest | ✅ otpz hashed cast |
❌ | ❌ | ❌ |
| PSD2/SCA compliance hooks | ✅ via core purpose binding | ❌ | ❌ | ❌ |
| No HTTP coupling in verify | ✅ Bypass AttemptOtp |
❌ | ❌ | ❌ |
🔋 Vibe coding with batteries included
This package ships with:
CLAUDE.md— AI-readable working guide: what it is, non-negotiable conventions, security rules, seam pattern, Definition of Done.AGENTS.md— Operational rules for AI agents and humans: branching, DoD, guardrails, security design-lock..claude/skills/rebel-package-dev/— Invocable skill for Claude Code agents: dev loop commands, PHPStan-max recipes, key design decisions, test patterns.FakeOtpzBroker— Deterministic in-memory broker for offline tests.- CI matrix — PHP 8.3/8.4/8.5 × Laravel 12/13 via GitHub Actions.
AI agent? Start with /rebel-package-dev and read CLAUDE.md. Your first composer test should
be green before you open a PR.
Contributing
PRs welcome. Follow AGENTS.md. All contributions must pass:
composer test && composer phpstan && composer pint -- --test
License
MIT — see LICENSE.
