padosoft/laravel-rebel-step-up
Step-up authentication for Laravel Rebel: confirm an action/purpose with AAL/AMR assurance, risk-based, and PSD2/SCA dynamic linking. Part of padosoft/laravel-rebel-*.
Maintainers
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-email-otp: ^0.1
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- 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
None
Provides
None
Conflicts
None
Replaces
None
README
Official documentation: https://doc.laravel-rebel.padosoft.com
Ask for a strong re-confirmation only when it truly matters. The user is already logged in, but is about to perform a sensitive action (change their email, download an invoice, confirm a credit order): Rebel Step-Up asks them for a targeted second factor (email OTP, passkey, TOTPβ¦), with the AAL/AMR security level chosen for that action and β for payments β PSD2/SCA dynamic linking (the confirmation is bound to amount+payee). It is part of the
padosoft/laravel-rebel-*suite.
π Laravel 12|13
π PHP 8.3+
π PHPStan max
π Pest 4
π PSD2 SCA
π MIT
Table of contents
- What it is (and what it is NOT)
- Quick glossary (read it, it takes 1 minute)
- Why Rebel Step-Up β the moats
- Rebel Step-Up vs the "do-it-yourself"
- How it works (the flow, step by step)
- Installation (junior-proof)
- Configuration (every option)
- Usage examples
- Validating the config in CI
.env.example- Security (what it guarantees you)
- Testing & License
What it is (and what it is NOT)
It is the "control plane" that decides when an already-authenticated user must re-prove who they are before a sensitive action, with what strength (assurance), and binding that confirmation to the specific action (for payments: to amount and payee). You declare a policy for each purpose (action) and Rebel enforces the rule β via middleware or via API.
It is NOT:
- a login system (to sign in there is
laravel-rebel-email-otp, or Fortify vialaravel-rebel-bridge-fortify); step-up assumes a user already logged in; - a standalone OTP generator: for email OTP it uses the engine of
laravel-rebel-email-otp; for passkey/TOTP it uses the drivers oflaravel-rebel-bridge-fortify. Step-Up orchestrates them, it does not reimplement them.
It depends on padosoft/laravel-rebel-core (assurance, contracts, keyed hashing) and on padosoft/laravel-rebel-email-otp (default OTP driver). For the big picture of the ecosystem, start from the core README.
Quick glossary (read it, it takes 1 minute)
| Term | In plain words |
|---|---|
| Step-up | "You're already in, but for THIS thing I'm asking you for one more proof." |
| Purpose | The name of the protected action, e.g. change-email, download-invoice, checkout-credit-order. You associate a rule with each purpose. |
| AAL (Authenticator Assurance Level) | How "strong" the proof is, per the NIST standard. aal1 = one factor (e.g. email OTP); aal2 = two factors / more robust. |
| AMR | Authentication Methods References: the list of methods used, e.g. ['otp','email'], ['webauthn']. |
| Phishing-resistant | A proof that phishing cannot steal: typically passkey/FIDO2. An email OTP is not. |
| Driver | The "way" the proof is performed: email_otp, fortify_passkey_confirm, fortify_totp⦠Each one declares its own assurance. |
| Binding / Dynamic linking | The confirmation is glued to the details of the operation (amount, currency, payee, order). If they change, the confirmation lapses: this is mandated by the European PSD2/SCA for payments. |
| Challenge | The open step-up "case": it has an id, an expiry, attempts, a status. |
| Confirmation window (TTL) | How long a confirmation stays valid after success (then it must be redone). |
Why Rebel Step-Up β the moats
| β | What | In short |
|---|---|---|
| β β β | Per-purpose policy | Decide for each action the required level, the allowed drivers, the TTL. No ifs scattered through the code. |
| β β β | Assurance enforcement | A driver below the threshold is rejected upfront. And if you raise the policy, the older, weaker confirmations lapse immediately. |
| β β β | PSD2/SCA dynamic linking | Confirmation bound to amount+payee with a keyed hash; anti-injection canonicalization (no collisions from separators). |
| β β | Pluggable drivers | Email OTP included; passkey/TOTP via bridge-fortify; your own custom drivers by implementing an interface. |
| β β | Atomic & anti-replay | Verification in a transaction with lockForUpdate, single-use, max attempts, expiry. |
| β β | Device binding | The confirmation can be bound to the device: no cross-device reuse. |
| β β | Multi-tenant & audit | Everything is scoped per tenant; every step (StepUpRequired/Verified/Failed) is audited. |
| β | Config validated in CI | php artisan rebel:validate-config blocks insecure configurations before deploy. |
Rebel Step-Up vs the "do-it-yourself"
| Rebel Step-Up | Shopify | Laravel's password.confirm middleware |
Fortify-native password confirmation | Hand-rolled "re-enter password" | |
|---|---|---|---|---|---|
| Configurable strength per action (AAL/AMR) | β | β | β (password only) | β (password only) | β |
| Passkey / TOTP / email OTP interchangeable | β | β | β | β | β |
| PSD2/SCA dynamic linking (amount+payee) | β | β | β | β | β |
| Confirmation that lapses if the amount changes | β | β | β | β | β |
| Device binding | β | β | β | β | β |
| Per-purpose, multiple protected actions | β | β | β (single global window) | β (single global window) | β |
| Multi-tenant + audit trail | β | β | β | β | β |
| Config validation in CI | β | β | β | β | β |
Legend: β built-in Β· β partial / hosted-only / not exposed to you Β· β not available. Note on Shopify: it is a hosted, closed commerce platform you can neither self-host nor extend β it exposes none of these step-up primitives to your own Laravel app, so it's a black box you don't control.
How it works (the flow, step by step)
Logged-in user β wants to perform a "purpose" action (e.g. checkout-credit-order)
β
βΌ
[1] The middleware rebel.stepup:checkout-credit-order intercepts
β
ββ is there already a VALID confirmation (within TTL, binding ok, device ok,
β assurance β₯ CURRENT policy)? ββ yes βββΊ pass through, run the action
β
ββ no βββΊ responds with 423 (JSON) or redirects to the confirmation page,
listing the drivers available for that purpose
βΌ
[2] The client starts the challenge: RebelStepUp::start($ctx)
β - picks the best driver allowed by the policy
β - for payments, computes binding_hash = HMAC(amount|currency|payee|order)
β - the driver sends the factor (e.g. email with OTP) β creates the challenge
βΌ
[3] The user enters the code: RebelStepUp::confirm($challengeId, $code, $ctx)
β - transaction + lockForUpdate (atomic, single-use)
β - re-verifies the binding (amount/payee MUST NOT have changed)
β - delegates factor verification to the driver
β - if ok: status=verified, saves the ACHIEVED assurance, audits
βΌ
[4] Now isConfirmed($ctx) = true for the TTL window β the middleware lets it through
What happens ifβ¦
- the user gets the code wrong too many times β the challenge goes to
failed(max attempts configurable); - the amount changes between
startandconfirmβbinding_mismatch, you start over (the SCA mandates it); - you raise the policy from
aal1toaal2after a confirmation β the oldaal1confirmation no longer counts; - the factor provider goes down during
startβ the challenge is cancelled (no orphan "pending" entries).
Installation (junior-proof)
Prerequisites: Laravel 12 or 13, PHP 8.3+, with
padosoft/laravel-rebel-coreandpadosoft/laravel-rebel-email-otpalready installed (they are pulled in as dependencies).
1) Require the package
composer require padosoft/laravel-rebel-step-up
2) Publish config and migration
php artisan vendor:publish --tag="rebel-step-up-config" php artisan vendor:publish --tag="rebel-step-up-migrations" php artisan migrate
3) Configure the pepper (if you haven't already done so for the core)
Step-up uses the core's keyed hashing for the SCA binding. In your .env:
REBEL_PEPPER_CURRENT=1 REBEL_PEPPER_1=put-a-long-and-random-secret-here
4) Define your protected actions in config/rebel-step-up.php (see below) and protect a route:
use Illuminate\Support\Facades\Route; Route::middleware(['auth', 'rebel.stepup:change-email']) ->post('/account/email', [AccountController::class, 'updateEmail']);
Done: the route now requires a step-up for the change-email purpose.
Configuration (every option)
File config/rebel-step-up.php. Global keys:
| Key | Default | What it does | When to change it |
|---|---|---|---|
default_ttl_seconds |
600 |
Default duration of the confirmation window (how long a successful confirmation stays valid). | Very sensitive actions β lower it (e.g. 120). |
challenge_ttl_seconds |
300 |
Expiry of the single challenge (how long you have to enter the code). | Align it with the channel's OTP duration. |
max_attempts |
5 |
Wrong attempts before marking the challenge failed. |
Stricter β lower it to 3. |
redirect_route |
null |
For web (non-JSON) requests: the route name of the confirmation page. null β abort(423). |
Set your own challenge route. |
purposes |
see below | Your protected actions and their respective rules. | Always: this is where you declare what to protect. |
Each purposes entry accepts:
| Purpose key | Default | What it does |
|---|---|---|
required_assurance |
aal1 |
Minimum required AAL level (aal1 / aal2). |
require_phishing_resistant |
false |
If true, allows only phishing-resistant drivers (e.g. passkey). |
reject_restricted |
false |
If true, rejects NIST "restricted" authenticators (e.g. SMS). |
drivers |
['email_otp'] |
Allowed drivers, in order of preference. The first available and eligible one is chosen. |
ttl_seconds |
default_ttl_seconds |
Override of the confirmation window for THIS purpose. |
always_require |
true |
Reserved for the risk-based hook (coming soon): today step-up is always required. Setting false does not yet skip verification β it will once the risk evaluator is wired up. |
sca.dynamic_linking |
false |
If true, enables binding to amount+payee (for payments). |
Example:
'purposes' => [ 'change-email' => [ 'required_assurance' => 'aal1', 'drivers' => ['email_otp'], ], 'download-invoice' => [ 'required_assurance' => 'aal1', 'drivers' => ['email_otp'], 'ttl_seconds' => 900, // a quarter of an hour, it's low-sensitivity ], 'checkout-credit-order' => [ 'required_assurance' => 'aal2', 'require_phishing_resistant' => true, // demand a passkeyβ¦ 'drivers' => ['fortify_passkey_confirm', 'email_otp'], // β¦with OTP fallback 'sca' => ['dynamic_linking' => true], // PSD2: bind to amount+payee ], ],
β οΈ If a purpose requires
aal2+require_phishing_resistantbut lists onlyemail_otp(which isaal1, not phishing-resistant), the config is insecure:rebel:validate-configfails in CI before deploy (see below).
Usage examples
1. Protect a route with the middleware
// routes/web.php Route::middleware(['auth', 'rebel.stepup:change-email'])->group(function () { Route::post('/account/email', [AccountController::class, 'updateEmail']); });
- JSON / API request without a valid confirmation β
423 Locked:
{
"error": "step_up_required",
"purpose": "change-email",
"required_assurance": "aal1",
"drivers": ["email_otp"]
}
- Web request without a confirmation β redirect to
redirect_route(if set) orabort(423).
2. Manual control (without middleware)
When you want to handle the flow yourself in a controller:
use Padosoft\Rebel\Core\Context\SecurityContext; use Padosoft\Rebel\StepUp\RebelStepUp; use Padosoft\Rebel\StepUp\StepUpContext; public function updateEmail(Request $request, RebelStepUp $stepUp) { $ctx = new StepUpContext( subject: $request->user(), purpose: 'change-email', security: SecurityContext::fromRequest($request), ); if (! $stepUp->isConfirmed($ctx)) { // start the challenge and tell the client to show the code form $start = $stepUp->start($ctx); return response()->json([ 'step_up' => 'required', 'challenge_id' => $start->challengeId, 'driver' => $start->driver, ], 423); } // valid confirmation: proceed $request->user()->update(['email' => $request->input('email')]); return response()->json(['ok' => true]); }
3. Payment with PSD2/SCA dynamic linking
The confirmation is bound to amount+currency+payee+order. If the user confirms β¬100 and then someone tries to push the order through at β¬999, the confirmation does not count.
use Padosoft\Rebel\StepUp\Sca\TransactionContext; $ctx = new StepUpContext( subject: $request->user(), purpose: 'checkout-credit-order', security: SecurityContext::fromRequest($request), transaction: new TransactionContext( amount: 1250.00, currency: 'EUR', payee: 'ACME Srl', orderRef: 'ORD-2026-0042', ), ); $start = $stepUp->start($ctx); // computes and freezes the binding_hash // β¦the user enters the code / uses the passkeyβ¦ $result = $stepUp->confirm($start->challengeId, $code, $ctx); if (! $result->success) { // $result->reason may be 'binding_mismatch' if amount/payee changed return back()->withErrors(__('The transaction changed, please re-confirm.')); }
4. Start and confirm a challenge (API/mobile)
A two-endpoint pattern, perfect for mobile apps (Sanctum tokens):
// POST /api/step-up/start $start = $stepUp->start($ctx); return ['challenge_id' => $start->challengeId, 'driver' => $start->driver]; // POST /api/step-up/confirm { challenge_id, code } $result = $stepUp->confirm($request->string('challenge_id'), $request->string('code'), $ctx); return $result->success ? response()->json(['confirmed' => true]) : response()->json(['error' => $result->reason], 422);
5. Choose the driver (passkey-first, OTP fallback)
The policy lists the drivers in order of preference; you can also force one:
// use the preferred available driver (e.g. passkey if the user has one) $start = $stepUp->start($ctx); // or explicitly force the email OTP fallback $start = $stepUp->start($ctx, driverKey: 'email_otp'); // which drivers are usable RIGHT NOW for this user/purpose? foreach ($stepUp->availableDrivers($ctx) as $driver) { echo $driver->key(); }
6. Bind the confirmation to the device
Pass a deviceId (e.g. derived from the Sanctum token or from hash(ip|user-agent)): the confirmation will count only for that device.
$ctx = new StepUpContext( subject: $request->user(), purpose: 'checkout-credit-order', security: SecurityContext::fromRequest($request), deviceId: $request->user()->currentAccessToken()?->id ? 'tok-'.$request->user()->currentAccessToken()->id : null, );
A confirmation made on device A does not unlock the action on device B.
Validating the config in CI
Step-up extends the core's command:
php artisan rebel:validate-config
It exits with a code β 0 if a purpose is configured insecurely, for example:
- it requires an assurance that none of the listed drivers can reach;
- it demands
phishing_resistantbut lists only non-phishing-resistant drivers; - it points to an unregistered driver.
Put it in your CI pipeline so you don't ship rules to production that can't be satisfied:
- name: Validate the Rebel config run: php artisan rebel:validate-config
.env.example
The package commits an .env.example with all the variables used. The essential ones:
# --- Keyed hashing (shared with the core): needed for the SCA binding --- # The pepper version currently in use. REBEL_PEPPER_CURRENT=1 # The pepper secret(s) (one per version). Long, random, NEVER committed. REBEL_PEPPER_1=change-this-with-a-long-and-random-secret # --- Step-up (optional: they have sensible defaults in the config) --- # Default confirmation window, in seconds. REBEL_STEPUP_TTL=600 # Expiry of the single challenge, in seconds. REBEL_STEPUP_CHALLENGE_TTL=300 # Maximum attempts before locking the challenge. REBEL_STEPUP_MAX_ATTEMPTS=5 # (optional) Route name of the confirmation page for web requests. REBEL_STEPUP_REDIRECT_ROUTE=
Security (what it guarantees you)
- Atomic & single-use verification:
confirmruns in a transaction withlockForUpdate; two concurrent confirmations don't both pass. - Assurance enforcement against the CURRENT policy: a successful confirmation saves the achieved assurance; if the policy is raised, the "old", weaker confirmation lapses.
- PSD2/SCA dynamic linking: keyed
HMACbinding (withkey_versionfor rotation) over amount+currency+payee+order; anti-injection JSON canonicalization (no collisions from separators in the fields). - Symmetric device binding: a context without a device β only deviceless confirmations; with a device β only that device. No cross reuse.
- Tenant isolation: every query is scoped per tenant (null-safe).
- Fail-closed: missing/corrupted assurance data β the confirmation is not valid; an invalid amount (NaN/β/negative) β an immediate exception.
- Audit:
StepUpRequired,StepUpVerified,StepUpFailedrecorded via the core'sAuditLogger.
π Vibe coding with batteries included
This package ships AI batteries β so you (and your AI agent) can extend it correctly on the first try:
CLAUDE.mdβ a concise AI working guide (purpose, conventions, architecture, how to extend, Definition of Done). Plain Markdown, so Claude Code, Cursor, Copilot and Codex all read it.AGENTS.mdβ the agent/workflow contract (branch β PR β CI β tag/release, the gates)..claude/skills/β invocable skills (at leastrebel-package-dev) encoding the suite's TDD loop, the PHPStan-level-max recipes, the security/telemetry rules, and the release discipline.
Open the repo in your AI editor and just start β the rules, guardrails and extension recipes come
with it. PRs that follow the shipped CLAUDE.md pass CI (PHPStan max + Pest + Pint) and review the
first time around.
Testing & License
composer test # Pest (manager flows, SCA, TTL, middleware, config, real OTP driver) composer phpstan # static analysis, max level composer pint # code style
The suite covers: start/confirm, wrong code + max attempts, no eligible driver, dynamic linking (amount change, separator collision), TTL expiry, policy raising, device binding, cancellation on driver crash, middleware 423βOK, config validation, and the real integration with the email_otp driver.
License: MIT β see LICENSE. Part of the padosoft/laravel-rebel suite.
