PHPackages                             padosoft/laravel-rebel-bridge-otpz - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. padosoft/laravel-rebel-bridge-otpz

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

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-\*.

v0.1.0(1mo ago)02[1 PRs](https://github.com/padosoft/laravel-rebel-bridge-otpz/pulls)MITPHPPHP ^8.3CI failing

Since Jun 4Pushed 1mo agoCompare

[ Source](https://github.com/padosoft/laravel-rebel-bridge-otpz)[ Packagist](https://packagist.org/packages/padosoft/laravel-rebel-bridge-otpz)[ Docs](https://github.com/padosoft/laravel-rebel-bridge-otpz)[ RSS](/packages/padosoft-laravel-rebel-bridge-otpz/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (1)Dependencies (11)Versions (4)Used By (0)

> Official documentation:

[![Laravel Rebel banner](resources/screenshoots/Laravel-Rebel-banner.png)](resources/screenshoots/Laravel-Rebel-banner.png)

laravel-rebel-bridge-otpz
=========================

[](#laravel-rebel-bridge-otpz)

[![Latest Version on Packagist](https://camo.githubusercontent.com/9d5eaea88a8e55a05e9723af5b0c9a565d193b9a7092275f7e9c4700c2beb707/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7061646f736f66742f6c61726176656c2d726562656c2d6272696467652d6f74707a2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/padosoft/laravel-rebel-bridge-otpz)[![CI](https://camo.githubusercontent.com/fd5b7d956e4ddff85a0554e412380bbf1cd66b41002ccf18156413988fca1f90/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f7061646f736f66742f6c61726176656c2d726562656c2d6272696467652d6f74707a2f63692e796d6c3f6272616e63683d6d61696e266c6162656c3d4349267374796c653d666c61742d737175617265)](https://github.com/padosoft/laravel-rebel-bridge-otpz/actions)[![License: MIT](https://camo.githubusercontent.com/942e017bf0672002dd32a857c95d66f28c5900ab541838c6c664442516309c8a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e7376673f7374796c653d666c61742d737175617265)](LICENSE)

> Step-up driver that bridges **[benbjurstrom/otpz](https://github.com/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
--------

[](#glossary)

TermMeaning**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
------------

[](#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()`?

[](#why-not-use-attemptotphandle)

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

[](#requirements)

DependencyVersionPHP`^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
------------

[](#installation)

### 1. Install this bridge

[](#1-install-this-bridge)

```
composer require padosoft/laravel-rebel-bridge-otpz
```

### 2. Install and configure 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

[](#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)

[](#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
-------------

[](#configuration)

File: `config/rebel-bridge-otpz.php`

KeyDefaultEffect`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
--------------

[](#usage-examples)

### Example 1 — Trigger step-up before a destructive action

[](#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

[](#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

[](#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)

[](#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)

[](#example-5--sanctum-api-mobilespa)

```
// 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
------------

[](#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 typeWhen emittedKey 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)sameThe OTP code itself is **never** present in any audit event.

---

Security notes
--------------

[](#security-notes)

- **Fail closed**: any `\Throwable` in `start()` returns `null`; any `\Throwable` in `verify()`returns `false`. The driver never propagates exceptions to the caller.
- **Single-use**: once verified, the OTP record is marked `USED` and 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 `hashed` cast on the `code` column — 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
----------------------

[](#competitor-card-battle)

Feature**laravel-rebel-bridge-otpz**Laravel FortifyLaravel SanctumShopify MultipassEmail OTP step-up (post-auth)✅ Native driver❌ No step-up concept❌ No step-up concept❌ Login-only, no step-upAAL2 assurance declared✅ `Aal::Aal2`❌ No assurance model❌ No assurance model❌ No assurance modelAMR 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 auditSingle-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
-------------------------------------

[](#-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
------------

[](#contributing)

PRs welcome. Follow `AGENTS.md`. All contributions must pass:

```
composer test && composer phpstan && composer pint -- --test
```

---

License
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance91

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity41

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

51d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/10467699?v=4)[Lorenzo](/maintainers/lopadova)[@lopadova](https://github.com/lopadova)

---

Top Contributors

[![lopadova](https://avatars.githubusercontent.com/u/10467699?v=4)](https://github.com/lopadova "lopadova (4 commits)")

---

Tags

laravelotpAuthenticationone-time-passwordpadosoftRebelstep-up

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/padosoft-laravel-rebel-bridge-otpz/health.svg)

```
[![Health](https://phpackages.com/badges/padosoft-laravel-rebel-bridge-otpz/health.svg)](https://phpackages.com/packages/padosoft-laravel-rebel-bridge-otpz)
```

###  Alternatives

[defstudio/telegraph

A laravel facade to interact with Telegram Bots

813336.8k3](/packages/defstudio-telegraph)[harris21/laravel-fuse

Circuit breaker for Laravel queue jobs. Protect your workers from cascading failures.

45955.7k](/packages/harris21-laravel-fuse)[rawilk/profile-filament-plugin

Profile &amp; MFA starter kit for filament.

3914.8k](/packages/rawilk-profile-filament-plugin)[masterix21/laravel-licensing

Laravel licensing package with polymorphic assignment to any model, activation keys, expirations/renewals, and seat control via LicenseUsage. Supports offline verification with public-key–signed tokens, a CLI to generate/rotate/revoke keys, and an extensible architecture via config and contracts.

1613.3k4](/packages/masterix21-laravel-licensing)[simplestats-io/laravel-client

Server-side analytics for Laravel that follows the full funnel from visit to registration to payment, attributed to the channel that drove it. Revenue, MRR, churn and ad-spend profit (ROAS/CAC) per channel. GDPR compliant, ad-blocker proof.

5022.6k](/packages/simplestats-io-laravel-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
