What this is
Nova Two-Factor adds a complete second-factor implementation to Laravel Nova: authenticator applications, passkeys, email codes and recovery codes, backed by an enforcement policy, step-up re-authentication for dangerous actions, trusted devices, an audit trail, and three administration pages.
Do you need it?
Nova already ships two-factor authentication of its own, through Laravel Fortify. If authenticator applications and recovery codes for users who opt in are all you need, you should use it:
use Laravel\Fortify\Features;
Nova::fortify()->features([
Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]),
]);
This package exists for what that cannot do.
| Capability | Nova + Fortify | This package |
|---|---|---|
| Authenticator apps (TOTP) | yes | yes |
| Recovery codes | yes | yes |
| Passkeys / WebAuthn | — | yes |
| Email one-time codes | — | yes |
| More than one method per user | — | yes |
| Mandatory enrollment, with grace | — | yes |
| Per-role targeting | — | yes |
| Step-up re-auth | — | yes |
| Trusted devices | — | yes |
| Audit trail | — | yes |
| Admin oversight and compliance | — | yes |
| TOTP replay protection | partial | yes |
| Recovery codes hashed individually | — | yes |
Requirements
- PHP 8.2+ 8.3+ for Laravel 13
- Laravel 11, 12 or 13
- Nova 5.7+ 5.11+ for Laravel 13
httpsin production — passkeys require a secure context;localhostis exempt
Install the package
Install it, publish its migrations and configuration, and publish the assets used by the screens that render outside Nova.
composer require gabrielesbaiz/nova-two-factor php artisan vendor:publish --tag=nova-two-factor-migrations php artisan migrate php artisan vendor:publish --tag=nova-two-factor-config php artisan vendor:publish --tag=nova-two-factor-assets
The challenge, enrollment and step-up screens render outside Nova's shell, so their bundle has to live under public/. Re-publish with --force after every upgrade, or the pre-authentication screens run stale JavaScript against a new backend.
Add the trait
On each authenticatable model that may hold a second factor.
use Gabrielesbaiz\NovaTwoFactor\Concerns\HasTwoFactorAuthentication;
class User extends Authenticatable
{
use HasTwoFactorAuthentication;
}
Register the tool
In your NovaServiceProvider.
use Gabrielesbaiz\NovaTwoFactor\NovaTwoFactor;
public function tools(): array
{
return [
NovaTwoFactor::make(),
];
}
Keep the Fortify features enabled
Ensure Fortify's updatePasswords and twoFactorAuthentication features remain enabled. This package renders its own card on Nova's user security page, and Nova only registers that page when at least one security feature is active. updatePasswords registers nova.password.confirm, which guards every destructive route in this package.
use Laravel\Fortify\Features;
Nova::fortify()->features([
Features::updatePasswords(),
Features::twoFactorAuthentication(['confirm' => false, 'confirmPassword' => false]),
]);
Set confirm and confirmPassword to false. Enrollment confirmation is handled by this package rather than by Fortify's own endpoint.
This is a requirement, not a recommendation. With every Fortify security feature disabled, /user-security does not exist: it returns 404 or 403, and the enrollment screen's links dead-end there.
Superseding Fortify's challenge
With the feature enabled, Fortify's RedirectIfTwoFactorAuthenticatable would divert any user holding a users.two_factor_secret to Fortify's own challenge. This package supersedes that action, so the challenge stays here — where the method choice, trusted devices and the audit trail live. Credential validation is untouched.
You may turn this off if you would rather Fortify owned the Nova login challenge.
// config/nova-two-factor.php
'fortify' => [
'supersede_challenge' => true,
],
With supersede_challenge off and a two_factor_secret on the row, users reach Fortify's challenge and never see this package's methods — a login that works and logs nothing. doctor checks for it explicitly.
Middleware
Middleware is registered for you, on both nova.middleware and nova.api_middleware. There is nothing to add by hand — and if a custom middleware stack has displaced it, doctor says so.
Confirm the installation
php artisan nova-two-factor:doctor
Run doctor in your deploy pipeline. It exits non-zero on a misconfiguration, and it checks the things that otherwise fail silently: unregistered middleware, stale published assets, a relying party derived from the wrong domain, and exempt patterns that reach the Nova API.
Report every line that is not PASS. A green doctor is the difference between a package that is installed and one that is actually protecting something.
Installing with an AI assistant?
Point it at AGENTS.md, which carries the keys, the pages, and the constraints that keep an agent from “fixing” a failing test by switching the protection off.
The failure mode that matters here is not a broken build — it is a working build with the protection quietly turned off, which nothing in CI will catch and nobody notices until it is needed.
Overview
Publish the configuration file, then drive it from .env. Nothing here requires editing the published file, and an application that never publishes it still gets every default.
php artisan vendor:publish --tag=nova-two-factor-config
The options that typically differ between environments read from your .env first, so vendor:publish --force can pick up new keys without flattening how a given environment is tuned.
NOVA_TWO_FACTOR_ENABLED=true NOVA_TWO_FACTOR_MODE=required # optional | encouraged | required NOVA_TWO_FACTOR_GRACE_DAYS=7 NOVA_TWO_FACTOR_REMIND_DAYS=7 NOVA_TWO_FACTOR_ENFORCED_FROM=2026-10-01 NOVA_TWO_FACTOR_GATE= # who is targeted NOVA_TWO_FACTOR_ADMIN_GATE= # who may administer NOVA_TWO_FACTOR_TOTP_ENABLED=true NOVA_TWO_FACTOR_WEBAUTHN_ENABLED=true NOVA_TWO_FACTOR_EMAIL_ENABLED=true # Must match the host users browse, or be a registrable parent of it. NOVA_TWO_FACTOR_WEBAUTHN_RP_ID=admin.example.com NOVA_TWO_FACTOR_WEBAUTHN_RP_NAME="Example Admin" NOVA_TWO_FACTOR_WEBAUTHN_ORIGINS=https://admin.example.com NOVA_TWO_FACTOR_EMAIL_TTL=300 NOVA_TWO_FACTOR_EMAIL_RESEND_AFTER=60 NOVA_TWO_FACTOR_STEP_UP_TTL=300 NOVA_TWO_FACTOR_PASSWORD_CONFIRMATION_TTL=900 NOVA_TWO_FACTOR_TRUSTED_DEVICES=true NOVA_TWO_FACTOR_TRUSTED_DEVICE_DAYS=30 NOVA_TWO_FACTOR_SHOW_TRADEOFFS=true NOVA_TWO_FACTOR_SETTINGS=false # policy editable from the panel NOVA_TWO_FACTOR_LOCKOUT_ALERT=0 # 0 disables the burst alert NOVA_TWO_FACTOR_LIMIT_CHALLENGE=5 NOVA_TWO_FACTOR_LIMIT_STEP_UP=5 NOVA_TWO_FACTOR_LIMIT_RECOVERY=10 NOVA_TWO_FACTOR_LIMIT_ENROLL=10 NOVA_TWO_FACTOR_LIMIT_OTP_SEND=3 NOVA_TWO_FACTOR_LIMIT_WEBAUTHN=30
Options marked env only cannot be edited from the Settings page. They are read once at boot, so a value written to the settings table would be ignored — and the panel does not offer them.
Factors
Each method can be turned off independently. Disabling every method is refused at runtime — an enforcement policy with nothing to enrol would lock everyone out.
Offer authenticator applications.
Code length and rotation. Changing either invalidates every enrolled application, so treat them as install-time settings.
Steps of clock skew accepted either side. 1 is ±30 seconds, and raising it widens the guessing window proportionally.
Entropy of the shared secret.
Seconds an unconfirmed secret stays valid.
Offer passkeys. Requires https.
The domain credentials bind to. It must equal the host users browse, or be a registrable parent of it; a mismatch makes every passkey fail.
Name shown in the browser prompt.
Exact origins accepted, compared scheme-host-port with no prefix matching.
Whether the authenticator must verify the human. Always forced to required for step-up.
Ask for a discoverable credential, which lets a passkey sign in without a username.
Attestation conveyance.
Allow-list of authenticator models. Empty means any.
Seconds the browser prompt stays open.
What to do when a signature counter goes backwards, which may indicate a cloned key. log accepts and records instead.
Offer email codes.
Code length.
Seconds a code lives.
Wrong guesses before the challenge is burned.
Seconds before another code may be requested.
Queue the mail. Disabled by default, since a queue that is not running turns a login into a dead end.
Recovery codes
Generated on first enrollment, shown once, hashed individually behind a unique index.
How many are issued.
Characters per code.
Remaining count at which the interface offers regeneration.
Enforcement
Who must enrol, by when, and what happens when they have not.
optional, encouraged or required. Only required blocks.
Whether required grants any runway before it blocks.
days for a runway per account; date for one deadline everybody shares.
With grace_mode: days, the runway each account gets from its created_at.
With grace_mode: date, the deadline for everyone.
Days that “don't remind me again” lasts under encouraged. The preference is stored against the account rather than the browser.
Queue the reminder mail an administrator sends.
Hours before the same recipient may be sent another reminder. The bulk action skips and reports rather than failing the batch.
Gate ability deciding who is targeted. null targets everyone.
Extra request patterns that stay reachable for a non-compliant user.
Step-up
A fresh factor in front of one dangerous action.
Seconds a grant stays fresh.
Scopes requiring a fresh factor, matched against Nova actions and routes.
Trusted devices
“Don't ask again on this device”, and how long that lasts.
Offer “don't ask again on this device”.
How long that lasts. Never offered when a recovery code was used.
Cookie name. Only the token hash is stored.
Rate limits
Each limiter is keyed on both the user and the IP address, and whichever trips first wins. Rejections carry a Retry-After header, which the interface countdown reads.
Decay 60s. Lockout 60s, escalating to 900s.
Decay 60s. Lockout 60s, escalating to 900s.
Decay 1 hour. Lockout 1 hour, flat. A wrong shape is never a typo, so malformed submissions get a much tighter budget.
Decay 10 min. Lockout 10 min, escalating to 1 hour.
Decay 15 min. Lockout 15 min, escalating to 1 hour.
Decay 1 hour. Lockout 30 min, escalating to 1 hour.
Assertions accepted per minute.
Gives a browser that has cleared a challenge before its own budget, so one user's compromised session does not lock out their other devices.
Names the cookie carrying that marker.
Other options
Everything else, including the switches that are deliberately not editable from the panel.
Turns the whole package off — routes, middleware, card and all. A codebase serving several panels may use this per domain.
Seconds a password confirmation stays fresh for destructive operations.
Force the secure flag. Null follows the session config.
Distinct accounts locked out inside the window before LockoutBurstDetected fires. 0 disables it.
The window that count is measured over.
Write the audit trail.
Queue audit writes; retention for nova-two-factor:prune.
Path segment under Nova's own path. Never hardcode it — use Support\Routing::prefix().
Take over Nova's login-pipeline divert.
Let administrators change policy from the panel.
Longest pause the panel will offer.
Register the compliance dashboard.
Populations compliance is measured against. Empty means the model behind Nova's guard.
Add the menu group automatically.
How that group reads.
Show the overdue count on the group.
Gate guarding the administration pages and the reset action. Undefined abilities deny, so these start closed. null opens them to any Nova user.
Substitute this package's card for Nova's own.
Show each factor's trade-off where a user picks one.
Where the tables live, for applications keeping auth data on their own connection.
No option matches that search.
enforcement.except is the most dangerous option in this package. Patterns are matched with Str::is, so * crosses slashes: ['nova-api/*'] leaves every resource, action and metric endpoint open while the pages still redirect. Nothing errors and nothing is logged. nova-two-factor:doctor prints the effective list and fails on any pattern that reaches the dashboard, a resource page or the Nova API.
NOVA_TWO_FACTOR_ENABLED=false is legitimate — a multi-domain install turns the package off per domain — so doctor reports it as valid and no test fails. An application with it set is indistinguishable from one without the package. To unblock local work, pause from the Settings page instead.
Configuration builder
Every setting above that has an environment variable — all 44 of them — is here. The .env is generated live and contains only the values that differ from the defaults, because everything else is already what the package ships.
A user may hold more than one method. The challenge screen offers whatever they actually have, strongest first, so losing a phone does not mean losing the account.
Where users manage their methods
Users manage their own methods on Nova's existing User Security page. This package replaces Nova's two-factor card in place, so there is nothing extra to link to and it inherits Nova's user-menu entry.
The challenge, step-up and enrollment screens are server-rendered Blade pages rather than Inertia pages, and work with JavaScript disabled.
A tool-registered Inertia page never resolves on a cold load, and a challenge is always a cold load — Nova resolves its initial component before any tool script runs.
Authenticator applications
Enabled by default. The QR code is generated locally as an inline SVG, and the setup key is always offered as text — so enrollment works on a machine with no camera and with a screen reader.
Secrets are 160-bit, stored under an encrypted cast, and never written to a URL or a log.
Each accepted code is recorded and refused on reuse. Replay protection lives in the database rather than the cache, because a cache flush must not reopen a window an attacker is already inside.
Passkeys
Passkeys require https and a relying-party ID matching your host.
// config/nova-two-factor.php
'methods' => [
'webauthn' => [
'enabled' => true,
'relying_party' => ['id' => null], // defaults to the host of app.url
'origins' => [], // defaults to [app.url]
],
],
The relying-party ID is derived from config('app.url'), never from the request Host header, and is validated at boot as a registrable parent of your application host.
Credentials are stored encrypted:json and looked up by SHA-256 of the credential ID. Install ext-sodium if you want to accept Ed25519 authenticators.
Where Nova has a domain of its own, APP_URL names the customer application rather than the panel — which silently produces a relying party your users cannot assert against. nova-two-factor:doctor prints the one it derived; if that is the customer domain, set NOVA_TWO_FACTOR_WEBAUTHN_RP_ID and NOVA_TWO_FACTOR_WEBAUTHN_ORIGINS.
Email codes
Email codes require a working mailer and are labelled in the interface as the weakest option, since anyone with the user's inbox has their second factor.
Adding an email method sends a code to the address and only counts it once that code comes back — so an attacker holding the password cannot point a second factor at their own inbox.
Codes are HMAC-SHA256 keyed on APP_KEY, single-use, with a short TTL. The notification is not queued by default: a login code that arrives two minutes late is a support ticket, not a security feature.
SMS is not included. The OtpTransport contract is the documented extension point — see Extending.
Recovery codes
Generated on first enrollment, shown once, and stored hashed individually with SHA-256 behind a unique index. The package cannot show them again, and says so where they are displayed; the user may copy, download or print them at that moment.
The security card tracks them as a row of ticks, struck through as they are spent, and offers regeneration at recovery_codes.warn_at remaining.
Using a recovery code signs the user in. It does not turn two-factor authentication off — which is the behaviour most implementations get wrong, and the one that turns a lost phone into an unprotected account.
Links in mail
Mail sent by this package links to Nova's user security page, and that link is not built from APP_URL. Where Nova has a domain of its own, APP_URL names the customer application instead of the panel.
| Order | Source | Available when |
|---|---|---|
| 1 | The host the mail was sent from | There is a request behind it |
| 2 | nova.domain | Always, if you set it |
| 3 | APP_URL | Last resort |
Set nova.domain if you send reminders from a job. A queued notification has no request behind it, so step 1 is unavailable and the link falls through to APP_URL — the customer site.
Trade-offs, shown to the user
Wherever a user picks a factor, each option carries a one-line description and, on its own line, its trade-off — because a list without it reads as three equivalent options when it is not.
| Method | Trade-off shown |
|---|---|
| Passkey | Cannot be phished. |
| Authenticator app | Works offline. |
| Email code | Weakest option — anyone with your inbox has your second factor. |
These are shown by default. If you would rather not put them in front of your users, you may disable them.
// config/nova-two-factor.php
'ui' => [
'show_method_tradeoffs' => false,
],
The descriptions remain; only the trade-off line disappears. Both come from MethodType, so they are translatable and identical across every screen.
Modes
| Mode | Behaviour | Blocks? |
|---|---|---|
optional | Nothing required, nothing shown. | no |
encouraged | A dismissible prompt. | never |
required | Nova is unreachable until enrolled, once grace expires. | yes |
NovaTwoFactor::make()->enforce('required', graceDays: 14);
Grace
Set grace_enabled to false and the wall appears at the next request. Otherwise grace_mode decides the deadline: days measures each account's created_at plus grace_days; date uses enforced_from for everyone at once.
Try it
Pick a policy and an account, and see which screen that user would get.
Targeting
NovaTwoFactor::make()->requireFor(fn ($user) => $user->hasRole('admin'));
Or name a gate, keeping role logic in the authorization layer you already use: 'enforcement' => ['gate' => 'require-two-factor'].
What it is
A second challenge immediately before a dangerous action, even though the user is already signed in.
You may demand a fresh factor in front of a single action:
NovaTwoFactor::make()->protectWithStepUp('users.destroy', [
'DELETE nova-api/users/*',
]);
Or per route:
Route::delete('/danger', DangerController::class)
->middleware('nova.2fa.step-up:danger');
How a grant works
A grant is HMAC-signed over the session, user, scope and expiry, so proving yourself for one scope never unlocks another. Re-authenticating to delete a user does not silently authorise exporting the database.
Grants expire after step_up.ttl seconds — 300 by default.
XHR requests receive a 423 response with step_up_required, mirroring Laravel's own RequirePassword, and the original request is replayed once the grant is issued.
Passkey users have user verification forced to required here, whatever the configuration says. A dangerous action deserves the biometric or PIN, not merely possession of the key.
Configuration
// config/nova-two-factor.php
'step_up' => [
'ttl' => 300,
'protect' => ['nova-api/users/*/delete'],
],
Three pages
| Page | URL | Answers |
|---|---|---|
| Overview | /dashboards/two-factor-compliance | Are we covered, and who do I chase? |
| Settings | /dashboards/two-factor-settings | What are the rules, and can I change them? |
| Activity | /resources/two-factor-audits | What happened, and who did it? |
They start closed
nova.admin_gate points at nova-two-factor:admin, an ability no fresh application defines, and Gate::allows() denies an undefined ability. Until you say who may see them, nobody can — and the menu entry does not render.
Gate::define('nova-two-factor:admin', fn ($user) => $user->isAdmin());
You may point the option at an ability you already have, or set it to null to open the pages to every user who can reach Nova.
The Overview dashboard
One card, read from top to bottom.
- An enrollment ring and a coverage strip.
- The method mix and failed attempts over the last thirty days.
- Six resilience figures: single-factor accounts, recovery-code health, time to enrol, stale enrollments, trusted devices in force, and lockouts.
- The phishing-resistant trend and recent recovery-code sign-ins.
- The queue of users to chase, overdue first.
- Recent administrator actions: reminders, resets and exemptions.
Each figure carries a ? describing what it answers, and clicking a resilience tile filters the queue to the accounts it counts. Each row's … menu opens the user's Nova resource, sends a reminder, or resets their two-factor — the last requiring their email address typed out, a written reason, and your password.
The sections rendered depend on your enforcement mode: figures that cannot be true in the current mode are neither sent nor computed. Each mode adds one figure of its own — blocked at the door under required, reminders sent under encouraged, and new enrollments under optional.
You may disable the dashboard with nova.compliance.enabled, and the menu badge alone with nova.menu.badge.
Choosing which users are measured
Every figure is measured against the model behind Nova's guard. On a panel only administrators can reach, that counts people who can never sign in to Nova. You may name the populations instead.
'nova' => [
'compliance' => ['models' => [App\Models\Admin::class]],
],
Class names only, since config:cache cannot serialise a closure. To narrow a population, register it on the tool:
NovaTwoFactor::make()
->audit(Admin::class)
->audit(User::class, fn ($query) => $query->where('active', true));
Both sources are merged, and a runtime registration wins over a configured one for the same class.
The Settings page
Disabled by default. A fresh installation should not hand everyone who reaches Nova the ability to weaken two-factor policy.
NOVA_TWO_FACTOR_SETTINGS=true
With it enabled, an administrator may change the enforcement mode and grace, the methods that may be enrolled, the email-code lifetime, trusted devices, the step-up window, and two interface options. Every write is password-confirmed and audited with both values, and the page shows the last five changes.
The form reshapes as you answer it: settings that do nothing in the chosen mode are not shown, and settings that depend on another appear with it. Switching to required displays what it would cost before you save — “would block 13 of 14 administrators” — and at least one method must remain enabled, which is refused in the interface and again in the endpoint.
Panel values take precedence over .env, which takes precedence over the shipped defaults. Where the panel and your environment disagree, the field is marked, and Restore defaults clears every stored value at once.
Some options are deliberately not editable from the panel, among them enabled, nova.admin_gate, webauthn.relying_party.*, database.* and the rate limits. The allow-list lives in Settings\SettingSchema.
Pausing enforcement
The Settings page can pause enforcement: nobody is challenged, while the administration pages keep working. This is the intended way to fix a misconfigured factor, since a broken second factor is exactly when an administrator cannot reach the page that fixes it.
Every pause carries a duration, capped by settings.pause_max_minutes, and expires on its own. It is stored in the database rather than the cache, so a cache flush cannot silently re-arm the gate, and it records who paused it and why. Pausing does not clear anyone's verified session.
Use the pause rather than NOVA_TWO_FACTOR_ENABLED=false to unblock local work. A pause is bounded, attributed and audited; the env switch is none of those.
The Activity log
Every audited event is available as a read-only Nova resource at /resources/two-factor-audits, reached from the two pages above. It is read-only in all four directions: an audit trail an administrator can edit is not an audit trail.
The log is filtered to administrator actions by default. Worth a second look narrows it to suspicious events, and Everything removes the filter. Each row reads as a sentence — “Reminder sent to Priya Raman by Alex Morgan” — and the badge is coloured by severity.
The menu
Registering the tool adds a Two-factor group displaying the number of users past their grace period as a badge. Settings appears only when settings.editable is enabled, and Activity is otherwise hidden from navigation. You may rename the group with nova.menu.label, change its icon with nova.menu.icon, or remove it with nova.menu.show; the pages stay registered and reachable by URL.
If your application calls Nova::mainMenu(), none of this appears. A custom main menu replaces Nova's default one entirely, and tool menus are only collected for the default — so the group is dropped with no error.
Placing the menu entry yourself
Switch the automatic entry off and place it wherever you like. The pages stay registered either way.
'nova' => ['menu' => ['show' => false]],
use Gabrielesbaiz\NovaTwoFactor\NovaTwoFactor;
Nova::mainMenu(fn ($request) => [
MenuSection::dashboard(Main::class)->icon('chart-bar'),
MenuGroup::make(__('Two-factor'), NovaTwoFactor::menuItems()),
MenuItem::resource(User::class),
NovaTwoFactor::menuSection(), // or the whole group as a top-level entry
]);
menuItems() returns Overview, Settings (when editing is enabled) and Activity; menuItem() returns the first alone. Every helper carries its destination's own authorization, so placing an entry by hand cannot expose it to someone the gate would have refused. NovaTwoFactor::make()->withoutMenu() is the fluent equivalent of the configuration option.
Resources, fields and actions
use Gabrielesbaiz\NovaTwoFactor\Nova\Actions\ResetTwoFactorAuthentication;
use Gabrielesbaiz\NovaTwoFactor\Nova\Actions\RevokeTrustedDevices;
use Gabrielesbaiz\NovaTwoFactor\Nova\Actions\SendEnrollmentReminder;
use Gabrielesbaiz\NovaTwoFactor\Nova\Fields\TwoFactorStatus;
use Gabrielesbaiz\NovaTwoFactor\Nova\Metrics\TwoFactorAdoption;
use Gabrielesbaiz\NovaTwoFactor\Nova\Metrics\TwoFactorFailures;
use Gabrielesbaiz\NovaTwoFactor\Nova\Metrics\TwoFactorMethodMix;
public function fields(NovaRequest $request): array
{
return [
// Sortable via subquery, so it does not N+1 a large index.
TwoFactorStatus::make(),
];
}
public function actions(NovaRequest $request): array
{
return [
ResetTwoFactorAuthentication::make(), // requires a written reason
SendEnrollmentReminder::make(), // mails whoever has not enrolled
RevokeTrustedDevices::make(),
];
}
public function cards(NovaRequest $request): array
{
return [
new TwoFactorAdoption(User::class),
new TwoFactorMethodMix,
new TwoFactorFailures,
];
}
SendEnrollmentReminder mails the users who have not enrolled, skipping anyone who has. The mail carries their own grace deadline under required, quotes an optional note as coming from the administrator who sent it, and every send is audited.
Events
Every meaningful transition fires an event you can listen for.
| Event | Fired when |
|---|---|
| EnrollmentReminderSent | A reminder mail goes out. |
| ReminderSnoozed | A user silences the prompt. |
| SettingChanged | A policy value is edited from the panel. |
| EnforcementPaused | Enforcement is stood down temporarily. |
| EnforcementResumed | A pause ends, by hand or by expiry. |
| LockoutBurstDetected | Distinct lockouts cross the alert threshold. |
Register your own factor type
use Gabrielesbaiz\NovaTwoFactor\Facades\TwoFactor;
TwoFactor::extend('yubico-otp', fn ($app) => new YubicoDriver(...));
Implement Contracts\TwoFactorMethodDriver.
Enrollment must be idempotent for the lifetime of a pending enrollment, or refreshing the setup page will invalidate the QR code the user has just scanned.
Other OTP transports
SMS is not included. Implement the OtpTransport contract to send codes over another channel — the reasoning and the full contract are in DESIGN.md.
Building URLs
Never hardcode /two-factor/…. The segment is routes.prefix. Use Support\Routing::prefix() and Support\PanelUrl; the SPA reads it from card meta.
What is stored, and how
| Control | Implementation |
|---|---|
| TOTP secrets | encrypted cast, 160-bit, never in a URL or log |
| Recovery codes | SHA-256, individually, unique index, single-use |
| Email codes | HMAC-SHA256 keyed on APP_KEY, single-use, short TTL |
| Passkey credentials | encrypted:json; lookup by SHA-256 of the credential ID |
| Trusted devices | 64-character token, only its hash stored |
| Step-up grants | HMAC over session, user, scope and expiry |
| QR codes | Generated locally. There is no remote code path. |
Verified sessions
Routes that issue or destroy a factor require a verified session — a cleared challenge, not merely a confirmed password. The only exception is an account with nothing enrolled yet, which has no second factor to prove.
A password confirmation is not a substitute. The attacker this defends against is holding the password already; without the second check they could enrol a factor of their own and lock the real owner out.
Rate limiting
Two independent ceilings apply to every guarded route: one keyed on the user, one on the IP. The user limit stops a targeted attack; the IP limit stops one host spraying many accounts. Lockouts double up to a ceiling.
The audit trail
Every enrollment, challenge, lockout, replay, reset and device change is recorded. Codes, secrets, credentials and destinations never are — a guard throws outside production if a payload even looks like it carries one.
Commands
| Command | Purpose |
|---|---|
| nova-two-factor:doctor | Check the configuration. Non-zero exit on failure. |
| nova-two-factor:reset {user} | Break-glass reset: methods, codes, devices, lockouts, sessions and reminder snooze. Accepts --force and --ip=. |
| nova-two-factor:prune | Remove expired challenges, devices and old audit rows. |
| nova-two-factor:upgrade | Port 1.x nova_twofa data. See the upgrade guide. |
Schedule the prune
Schedule::command('nova-two-factor:prune')->daily();
Testing
composer test # Pest composer analyse # PHPStan composer format # Pint
The suite includes a regression test for every vulnerability found in 1.x.
Everyone is locked out at once
Several lockouts at once usually means somebody is working through a list of stolen passwords. They cannot get in; under required they can keep others out.
- Trusted devices keep working. A browser that has already cleared a challenge is unaffected.
- Lockouts expire after
rate_limits.challenge.lockoutseconds, rising tolockout_ceiling. Nobody has to clear anything. - An administrator may free one account now by resetting its second factor from the Overview dashboard.
php artisan nova-two-factor:reset user@example.comdoes the same from the command line, and also clears that user's rate-limit buckets.
To hear about it rather than discover it, set a threshold and listen for LockoutBurstDetected:
'alerts' => ['lockout_burst' => ['accounts' => 5, 'window_minutes' => 15]],
Codes are always invalid
Clock skew, nearly always. TOTP codes are derived from the current time, so a phone whose clock has drifted generates codes the server rejects. Set the device clock to automatic. You may widen the window if you must — each step is 30 seconds either way:
'methods' => ['totp' => ['window' => 2]],
Passkeys aren't offered
Run php artisan nova-two-factor:doctor. Almost always one of:
app.urlis nothttps(localhostis exempt)- the relying-party ID is not a registrable parent of the host you are browsing
- the browser has no platform authenticator
I'm locked out of my own admin panel
php artisan nova-two-factor:reset admin@example.com
This clears every method, recovery code and trusted device, every rate-limit bucket keyed to that user, every session predating the reset, and any reminder snooze. An audit row is written and attributed.
There is deliberately no way to do this from inside the panel you cannot reach.
# No prompt, for scripts and support tooling. php artisan nova-two-factor:reset admin@example.com --force # Clear an address the audit trail does not know about. php artisan nova-two-factor:reset admin@example.com --ip=203.0.113.7
The UI looks unstyled after upgrading
Republish the assets:
php artisan vendor:publish --tag=nova-two-factor-assets --force php artisan vendor:publish --tag=nova-assets --force
The menu group does not appear
Your application almost certainly calls Nova::mainMenu(). A custom main menu replaces Nova's default one entirely, and tool menus are only collected for the default — so the group is dropped silently. Place the entries yourself with NovaTwoFactor::menuItems().
Clicking a destructive button does nothing
The route returned 423 Locked — it needs a password confirmation, and Nova has no global interceptor for that status. Wrap the control in <ConfirmsPassword>.
Strings render in English
Translations register during Nova::serving(), because the locale at boot is still app.locale. Queued notifications capture the locale at construction for the same reason — a worker has no request to read one from.
The challenge screen spins forever
Publish the assets: php artisan vendor:publish --tag=nova-two-factor-assets --force. The pre-authentication screens render outside Nova's shell and load their own bundle from public/.
Drag to compare
The package ships every screen in both themes. Drag the handle — or use the switch in the header to flip the whole page and the gallery with it.

All screens




























Why this is a rewrite
1.x was audited as unsafe to run: unauthenticated XHR endpoints, TOTP secrets posted to a third-party QR service, no rate limiting, and two-factor removable without re-authentication. None of its code survives in 2.0, and every one of those flaws has a regression test.
Treat every 1.x TOTP secret as compromised. They were transmitted to an external service to render the QR image. Users should re-enrol rather than carry a secret forward.
Checklist
Your progress is saved in this browser.
Releases
2.1.1
Fixed
- The user security page now uses the full width of Nova's content area. Nova's page container applies
max-w-7xl mx-auto; a:has()rule intool.cssclearsmax-widthon the container holding this package's card. CSS only — no configuration key, no API change.
2.1.0
Added
- Laravel 13 support —
illuminate/supportnow allows^13.0alongside 11 and 12. Laravel 13 requires PHP 8.3, so an application on 8.2 resolves to Laravel 12 as before. - Nova 5.11 permitted, the first release supporting Laravel 13. The
>=6.0.0conflict stands.
Changed
- Test matrix moved to Testbench 11 and Pest 4 for the Laravel 13 path.
Inertia\ServiceProviderregistered explicitly in the test harness — Nova renders through Inertia but does not register it, leavingInertia\Ssr\Gatewayunbound under Inertia 3.
2.0.0
Security
- Unauthenticated XHR endpoints closed.
- TOTP secrets no longer sent to a third-party QR service — codes render locally.
- Rate limiting on every challenge, step-up and recovery route, per user and per IP.
- Removing a factor now requires a verified session, not just a password.
- TOTP replay protection moved into the database.
- Recovery codes hashed individually behind a unique index.
Added
- Passkeys, email codes, and several factors per user.
- Enforcement policy with three modes, grace, and per-role targeting.
- Step-up re-authentication, trusted devices, audit trail.
- Compliance overview, Settings and Activity pages.
doctor,resetandupgradecommands.
1.0.0
Note
- Superseded. See the upgrade guide, and treat 1.x secrets as compromised.
How it works
Every user-facing string passes through __() — including the strings the pre-authentication JavaScript writes into the page after load, which are handed to it as a translated payload rather than translated in the browser.
English and Italian ship with the package. A test fails the build if a string appears in the source without a matching key in resources/lang/en.json, so the catalogue cannot silently fall behind the code.
Adding a language
Copy the package catalogue into your application and translate the values. Laravel merges yours over the package's, so you only need the keys you actually change.
cp vendor/gabrielesbaiz/nova-two-factor/resources/lang/en.json \ lang/vendor/nova-two-factor/de.json
The file is a flat JSON map of English source string to translation.
{
"Verify your identity": "Bestätige deine Identität",
"Use another method": "Andere Methode verwenden",
"Each code works once.": "Jeder Code funktioniert einmal."
}
Words your application also translates
This is the one thing that bites. Laravel resolves a plain JSON key from your application's lang/{locale}.json before it ever reaches a package catalogue. A single-word key like Required, Settings or Activity will silently pick up whatever your application already means by it.
Enforcement mode names live in a namespaced PHP catalogue — nova-two-factor::enforcement.modes.* — precisely for this reason. If you add strings of your own, prefer a namespaced key over a plain English one for any word an application is likely to own.
Queued mail
Notifications capture the locale at construction, not at send.
public function __construct()
{
$this->locale(App::getLocale());
}
Reminders and reset notices are queued, and a worker has no request behind it — so without this they would render in app.locale rather than the recipient's language.
Why strings appear in English inside Nova
Translations register during Nova::serving() rather than at boot, because the locale at boot is still app.locale. If you see English in the Nova SPA while the rest of your panel is translated, a published catalogue is shadowing the package's — check lang/vendor/nova-two-factor/ for a stale file left by an older version.
Contributing a language
Translations are welcome as pull requests. Add resources/lang/{locale}.json alongside the existing ones, keep the key order, and leave any string you are unsure of in English — a partial catalogue falls back cleanly, a wrong one does not.
Contributing
Thank you for considering contributing. The guide is in CONTRIBUTING.md.
Security vulnerabilities
Please review SECURITY.md for reporting a vulnerability. Please do not open a public issue.
Credits
Written and maintained by Gabriele Sbaiz.
This package builds on Laravel, Nova, Fortify, web-auth/webauthn-lib, pragmarx/google2fa, bacon/bacon-qr-code, spatie/laravel-package-tools, and the WebAuthn and TOTP specifications.
Support this package
If it is useful to you:
- ⭐ Star the repo. Free, thirty seconds, and it is the first signal other developers look at.
- ❤️ Become a sponsor. From $5 a month. Company tiers get your logo on this site.
- 🐛 Open a good issue. A clear reproduction is worth more than you think.
- 🗣️ Tell another Laravel developer. Word of mouth is how packages survive.
Disclaimer
This package is provided as is, without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, title and non-infringement. To the fullest extent permitted by applicable law, in no event shall the authors, copyright holders or contributors be liable for any claim, damages or other liability — whether in an action of contract, tort or otherwise — arising from, out of or in connection with this package or its use, including without limitation any direct, indirect, incidental, special, exemplary, consequential or punitive damages, loss of data, loss of profits, business interruption, account lockouts, unauthorised access, or failure of any authentication control.
Two-factor authentication is a security control: whoever deploys it is responsible for it. That responsibility includes, and is not limited to, choosing appropriate configuration, running nova-two-factor:doctor before relying on it, testing enforcement and recovery on your own infrastructure, keeping recovery paths available to your users, meeting whatever regulatory or contractual obligations apply to you, and reviewing the code yourself before putting it in front of an account you cannot afford to lose. Nothing here constitutes security, legal or compliance advice, and no claim is made that this package makes any system, application or organisation secure or compliant with any standard.
Use of this package is entirely at your own risk.
License
MIT. See LICENSE.md. The MIT licence's warranty disclaimer and limitation of liability apply in full, alongside the disclaimer above.