Skip to content

Two-factor authentication

A time-based one-time code (TOTP) from an authenticator app, plus recovery codes for the day the phone goes in the river.

On by default. LARASPRING_AUTH_TWO_FACTOR=false removes every route below and stops challenging anyone, including users who had already enrolled.

Enrolment, and why it has two steps

A user who is signed in enrols at /settings/two-factor:

  1. Enable. The package mints a secret and eight recovery codes, and shows a QR code plus the codes. The codes are shown once; reloading the page does not bring them back.
  2. Confirm. The user enters a code from their app. Only now is the credential marked confirmed.

Between those two steps the second factor is not asked for at sign-in. That is the whole reason confirmation exists: someone who scans the QR into the wrong app, mistypes the manual key, or closes the tab halfway is never locked out of their own account. Enabling again before confirming replaces the secret and the codes, which is exactly what a user who lost the QR code needs.

The challenge

Once confirmed, a correct password is no longer a session. The login form parks the account in the session and redirects to /two-factor-challenge, and so does a magic link: a link proves an address, not a second factor.

The visitor is a guest for the whole of this. Nothing is written to the guard until the code is in; the session holds only who is waiting and whether they asked to be remembered. "Remember me" survives the challenge.

The challenge accepts either:

  • a code from the authenticator app, which is consumed as it is used: the window it came from is recorded against the credential, and any code from that window or an earlier one is refused afterwards, or
  • one of the recovery codes, which is consumed as it is used and never works again.

That first point is what stops a replay. A TOTP code stays arithmetically valid for its whole 30 second window plus the drift allowance either side, so without the verifier remembering what it has accepted, anyone who has seen the six digits, over a shoulder, in a phished form, on a screen share, in a screenshot attached to a support ticket, has the best part of a minute to use them again. The next code the app produces still works, so nobody is locked out.

Guessing is rate limited, keyed on the pending account rather than the session, so clearing cookies does not hand an attacker a fresh budget of guesses.

Routes

Route nameMethodPathGuard
two-factor.challengeGET/two-factor-challengeguest
two-factor.verifyPOST/two-factor-challengeguest
two-factor.showGET/settings/two-factorauth
two-factor.enablePOST/two-factorauth
two-factor.confirmPOST/two-factor/confirmauth
two-factor.disableDELETE/two-factorauth + password.confirm
two-factor.recovery-codesPOST/two-factor/recovery-codesauth + password.confirm

Turning the factor off and reissuing the codes that bypass it both sit behind Laravel's password.confirm middleware. A borrowed session is not enough to undo someone's second factor.

Screens

Two, both named by the package and drawn by your edition:

  • two-factor-challengeauth/two-factor-challenge in the reference edition.
  • two-factor-settingssettings/two-factor, because enrolment is a settings screen and not a sign-in screen.

That second mapping is the one place InertiaAuthScreenRenderer does not simply prefix with auth/, and it is the pattern to copy when a package screen belongs somewhere else in your application.

The enrolment screen receives confirmed, pending, secret, qrCodeSvg, recoveryCodes and status. qrCodeSvg is inline SVG rather than an image URL, on purpose: a secret should not travel through a URL that ends up in a proxy log or a browser history.

Where the settings screen lives

php
'two_factor' => [
    'settings_path' => 'settings/two-factor',
],

A path rather than a route name, because the package registers the route itself. Move it if your application keeps its settings somewhere other than /settings.

Configuration

php
'two_factor' => [
    'window' => 1,               // LARASPRING_AUTH_TWO_FACTOR_WINDOW
    'recovery_codes' => 8,
    'settings_path' => 'settings/two-factor',
    'throttle' => [
        'max_attempts' => 5,
        'decay_seconds' => 60,
    ],
],

window is how many 30 second periods either side of now a code is still accepted, which is what absorbs a device clock that has drifted. Raising it widens the slot an intercepted code stays usable in, though only until the code is used: a spent window is refused whatever the drift allowance says. Leave it at 1 unless support tickets tell you otherwise.

Storage

two_factor_credentials, one row per enrolled user, with a unique user_id. The secret and the recovery codes are encrypted with your APP_KEY through Eloquent casts, so a database dump on its own does not let anyone mint valid codes. last_verified_counter holds the last TOTP window the credential was verified against, which is what makes a code single-use.

Rotating APP_KEY orphans every enrolment

The secrets are encrypted with it. Rotate the key and no enrolled user can be challenged successfully, or reach their settings screen to fix it. Plan a key rotation as a two-factor reset.

It is a table of its own rather than columns on your users table. Your users table belongs to you: a package that adds columns to it collides with whatever else you have put there, and cannot be uninstalled cleanly. A row here is also absent for most of your users, which columns never are.

Turning it off as an escape hatch

LARASPRING_AUTH_TWO_FACTOR=false does not just hide the screens. Users who had enrolled sign in with a password alone again, and their credentials stay in the table for when you turn it back on. That is deliberate: a kill switch that locks out the people who took your security advice is not a kill switch.

The library underneath

TOTP comes from pragmarx/google2fa, QR rendering from bacon/bacon-qr-code, the same pair Laravel Fortify uses. Both are direct dependencies of laraspring/auth, so there is nothing to install.

Laraspring is a commercial starter kit. Buying it gets you the source.