Skip to content

[6.x] Frontend Two-Factor Authentication#14525

Open
duncanmcclean wants to merge 17 commits into6.xfrom
frontend-two-factor
Open

[6.x] Frontend Two-Factor Authentication#14525
duncanmcclean wants to merge 17 commits into6.xfrom
frontend-two-factor

Conversation

@duncanmcclean
Copy link
Copy Markdown
Member

@duncanmcclean duncanmcclean commented Apr 21, 2026

This pull request introduces Antlers tags for building frontend two-factor authentication flows, allowing users to set up, challenge, and manage 2FA entirely outside the Control Panel.

Login Form

The existing {{ user:login_form }} tag now accepts two_factor_challenge_url and two_factor_setup_url parameters. These let you control where users are redirected when they need to verify a 2FA code or set up 2FA for the first time.

{{ user:login_form
    redirect="/dashboard"
    two_factor_challenge_url="/auth/two-factor-challenge"
    two_factor_setup_url="/auth/setup-2fa"
}}
    <input type="email" name="email" value="{{ old:email }}" />
    <input type="password" name="password" />
    <button type="submit">Log in</button>
{{ /user:login_form }}

The two_factor_setup_url value is encrypted to prevent tampering, since the user is only partially authenticated at that point.

2FA Challenge

After logging in, users with 2FA enabled are redirected to the challenge URL. The {{ user:two_factor_challenge_form }} tag renders a form for entering a TOTP code or recovery code.

{{ user:two_factor_challenge_form redirect="/dashboard" }}
    {{ if errors }}
        <div class="text-red-500">{{ errors }}{{ value }}{{ /errors }}</div>
    {{ /if }}

    <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" />
    <button type="submit">Verify</button>
{{ /user:two_factor_challenge_form }}

If no redirect is specified, the redirect from the original login form carries through.

2FA Setup

The {{ user:two_factor_setup_form }} tag renders a QR code and secret key for users to scan with their authenticator app, along with a confirmation code input.

{{ user:two_factor_setup_form redirect="/account/recovery-codes" }}
    {{ if errors }}
        <div class="text-red-500">{{ errors }}{{ value }}{{ /errors }}</div>
    {{ /if }}

    <div>{{ qr_code }}</div>
    <p>Or enter manually: <code>{{ secret_key }}</code></p>

    <input type="text" name="code" inputmode="numeric" maxlength="6" />
    <button type="submit">Enable 2FA</button>
{{ /user:two_factor_setup_form }}

Recovery Codes & Disable 2FA

  • {{ user:two_factor_recovery_codes }} — loops through the user's recovery codes ({{ code }})
  • {{ user:two_factor_recovery_codes_download_url }} — outputs a URL to download codes as a text file
  • {{ user:reset_two_factor_recovery_codes_form }} — renders a form to regenerate recovery codes
  • {{ user:disable_two_factor_form }} — renders a form to disable 2FA, with a setup_url parameter for users whose roles enforce 2FA

The reset recovery codes and disable forms require an elevated session.


Docs PR: statamic/docs#1891

Closes statamic/ideas#1398

@duncanmcclean duncanmcclean marked this pull request as ready for review April 21, 2026 10:10
jasonvarga and others added 10 commits April 21, 2026 14:29
Replaces the two_factor_challenge_url/two_factor_setup_url tag params and
hidden-field plumbing with statamic.users.two_factor_challenge_url and
two_factor_setup_url config keys. Eliminates the encrypt/decrypt dance
and the session-based URL storage. CP flows are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend form submissions without a _redirect param now redirect back
with a flash message instead of returning raw JSON. CP axios calls keep
their JSON response because they set the appropriate Accept header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the value instead of getting it, and caches the result in __invoke
so the early-return and the Inertia prop share the same resolved URL.
Prevents a stale redirect from lingering in the session after the setup
flow completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
login.id and login.remember were migrated through session regenerate
and never consumed. Cleaning them up prevents the challenge form tag
(which gates on login.id presence) from rendering for a user who has
already authenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds middleware tests for the configured two_factor_setup_url (redirect
target and loop bypass) and login-form tests covering when
login.redirect is or isn't stashed in the session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The enable and confirm routes had no auth enforcement; disable and the
recovery-codes routes relied on RequireElevatedSession which is a no-op
when elevated sessions are disabled. Wraps the whole action-routes
group in auth middleware so guests are redirected regardless of the
elevated-sessions config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the enable endpoint from GET to POST so it no longer mutates state
on a safe verb. Calling enable when a pending secret already exists is
now an idempotent no-op. Adds a {{ user:two_factor_enable_form }} tag
for frontend opt-in; the setup_form tag no longer auto-generates the
secret on render and only appears once setup is pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each 2FA form tag now reads its success/error session under a named bag
(user.two_factor_setup, user.two_factor_disable, etc.) and the matching
controllers flash to the same names. Previously every tag pulled from the
unscoped default bag, so a success flash from confirming setup would also
render inside disable and reset-recovery-codes forms on the redirect page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend Forms support for 2FA

2 participants