Skip to content

feat: Optional 2FA - #8285

Open
distantnative wants to merge 3 commits into
v6/developfrom
v6/feat/auth-2fa-optional
Open

feat: Optional 2FA#8285
distantnative wants to merge 3 commits into
v6/developfrom
v6/feat/auth-2fa-optional

Conversation

@distantnative

@distantnative distantnative commented Jul 10, 2026

Copy link
Copy Markdown
Member

Review

  • Design & concept
  • Deep read
  • Security check

Timing: No rush, but ideally before the v6-beta.4

Description

Adds optional, per-user two-factor authentication and makes it the default behaviour.

Previously, 2FA was all-or-nothing per install (2fa => true). This branch lets individual users opt into a second factor (passkey, TOTP or email code) without an admin enforcing it for everyone.

It also adds a new sub drawer in the security drawer for the "Code via email" second factor that users can activate themselves.

PasswordMethod::has2FA() is now public and resolves per user:

  • 2fa => true: enforced for every user (unchanged)
  • otherwise a user is only challenged if they have set up a factor themselves, (checked via Challenges::hasAvailable($user, '2fa'))

EmailChallenge becomes opt-in as a second factor, so simply having an email address no longer forces it. It stays available for every other mode (password reset, code login method), and when 2FA is enforced it stays the baseline factor for users who haven't set up anything else, so nobody is locked out. Users activate it from their security settings (having to enter the emailed code once when activating it doubles as a deliverability check).

HTTP Basic auth can't run a challenge, so BasicAuthMethod::authenticate() now defers to PasswordMethod::has2FA() and rejects any user who would be challenged, while letting factor-less accounts (e.g. dedicated API users) keep working.

Also in this branch:

  • UserCredentialDrawerController::authorizeCurrentUser(): the pending challenge stored in the session now expires with the challenge timeout, failed attempts count against the shared auth rate limit, single-use challenges (WebAuthn) are invalidated after a failed attempt, and the pending is consumed on success so a code or nonce can't be replayed for a second change. Reusable codes deliberately survive a typo, so the account owner isn't locked out by one wrong digit.

Deliberately out of scope:

  • enforcing 2FA per role
  • a UI to set up 2FA for a user when it is enforced globally, but the user yet has none (onboarding extension)

Changelog

🎉 Features

  • Two-factor authentication can now be enabled per user: everyone can enable a second factor for their own account (passkey, authenticator app or "Code via email") from their account security settings, without an admin having to enforce it install-wide.
  • auth.methods.password.2fa is now opt-in by default: leave it unset and only users who set up a second factor are challenged. Set it to true to keep requiring a second factor from every user.

☠️ Deprecated

  • Kirby\Cms\System::is2FA(): use $kirby->auth()->methods()->hasAnyRequiring2FA() instead.
  • Kirby\Cms\System::is2FAWithTOTP(): use $kirby->auth()->methods()->hasAnyRequiring2FA() together with $kirby->auth()->enabledChallenges() instead.

Docs

On most sites, forcing 2FA on every editor isn't realistic, but the admins still want it for themselves (and/or anyone that is willing to use it). Until now that wasn't possible: 2fa => true hit everyone or nobody. Now every user decides for their own account, and admins can still enforce it site-wide when they need to.

Update the 2FA / login methods page:

// site/config/config.php
return [
	'auth' => [
		'methods' => [
			// default: every user can opt into a second factor themselves
			'password',

			// or: require a second factor from every user
			'password' => ['2fa' => true]
		]
	]
];

How a user enables a second factor: Account → Security → pick "Passkey", "Authenticator app" or "Code via email".

For "Code via email": press Activate, Kirby sends a one-time code to the account's email address, entering it activates the challenge. That step is intentional: it proves the address is actually reachable before it becomes a login requirement. Disabling works the same way. An admin disabling it for someone else confirms with their own admin password instead.

Worth documenting as a gotcha: HTTP Basic auth (api.basicAuth) can't run a challenge, so a user with a second factor can no longer authenticate with it. API accounts should stay without a second factor (and 2fa => true disables basic auth entirely, as before).

For review team

  • Add changes & docs to release notes draft in Notion

@distantnative distantnative self-assigned this Jul 10, 2026
Comment thread src/Auth/Method/BasicAuthMethod.php Outdated
Base automatically changed from v6/feat/auth-passkeys to v6/develop July 10, 2026 17:28
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch 2 times, most recently from 634a083 to e470aee Compare July 10, 2026 17:53
@distantnative
distantnative marked this pull request as ready for review July 10, 2026 18:30
@distantnative
distantnative requested review from a team July 10, 2026 18:30
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch from e470aee to 28183ca Compare July 10, 2026 19:40
Comment thread src/Auth/Method/PasswordMethod.php Outdated
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch 4 times, most recently from 70cd6c9 to ee1e676 Compare July 23, 2026 15:18
@distantnative distantnative added this to the 6.0.0-alpha.4 milestone Jul 23, 2026
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch from ee1e676 to 3042961 Compare July 23, 2026 15:45
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch from 3042961 to 7b75e86 Compare July 23, 2026 15:52
Comment thread panel/src/components/Drawers/UserEmailChallengeDrawer.vue
Comment thread src/Panel/Controller/Drawer/UserEmailChallengeDrawerController.php
Comment thread src/Panel/Controller/Drawer/UserEmailChallengeDrawerController.php
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch 2 times, most recently from a961ea1 to 0e2bd42 Compare August 3, 2026 15:28
@distantnative
distantnative force-pushed the v6/feat/auth-2fa-optional branch from 0e2bd42 to b245ca0 Compare August 6, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants