,

I didn’t build a 2FA plugin. I filed bugs, then built around the best one.

WordPress’s Two Factor plugin already checks the codes. Instead of writing yet another 2FA plugin, I filed bugs against it, fixed some, and built two small add-ons beside it: one for policy, one for onboarding. Here’s why separating mechanism from policy works.

Two inputs meet in one authentication module, with separate policy and onboarding modules connected beside it.

WordPress doesn’t ship two-factor authentication in core, at least not yet. What it has instead is Two Factor, a feature plugin maintained by WordPress contributors, which is the nearest thing to an official answer. It’s solid, and it’s widely used: WordPress.org counts more than 100,000 active installations. It’s a wonderful community-supported asset that needs contributors. Consider thanking, helping, and/or supporting one of its maintainers.

This year I could have AIed my own 2FA plugin, if I were a bigger idiot than I am. There are plenty already. Instead I spent eight months filing issues and bugs for Two Factor, fixing some of them, and helping a bit in other areas. I also built two small plugins that sit beside Two Factor rather than replacing it. This post is about why I did this, and what I learned.

Where it started: Sudo needed a bridge

In February I was building Sudo, a step-up reauthentication concept plugin I’ve since retired, and it needed to ask for a second factor as well as a password. That meant understanding how WordPress’s 2FA plugins actually work: where they keep secrets, how they decide a user has 2FA turned on, how they check a code, etc.

So I surveyed seven of them (a great task for AI) and wrote it up as wordpress-2fa-ecosystem, a developer reference with example bridges for WP 2FA, Wordfence and All-In-One Security. Reading (some of) Two Factor’s code closely for that survey is where the first bugs came from:

  • #796: when a TOTP key was missing, the login silently fell back to recovery codes.
  • #797: the profile screen saved TOTP as enabled without checking that a key existed.

Both were closed on March 19, fixed together by maintainer Kaspars Dambis’s #798.

Security fixes, and how long they can take

The next two took longer, and one of them shows how upstream work actually goes.

The resend that skipped the delay. I filed #847 on March 23: the email provider’s “resend code” path bypassed the retry delay meant to slow down guessing. My first fix, #848, was narrow, and I closed it five days later in favour of a better one. That fix, #859, moved the rate-limit check ahead of each provider’s own preprocessing, so no provider could skip it. It was closed by accident in June, so I opened it again in July as #917, which was merged on September 26. In its final form it keeps the emailed code during a lockout instead of deleting it, because deleting it made the login screen send a fresh code on every blocked attempt, which was the very flood the fix was meant to stop. The original issue, #847, closed the same minute. Three pull requests, one accidental close, and six months from report to fix. That’s normal for upstream work, and it’s worth knowing before you start.

The nonce that failed open. #860: if PHP couldn’t get cryptographically secure random bytes, Two Factor fell back to a weaker nonce instead of stopping. When a maintainer asked for a pull request, I opened #877 on April 12 and closed the issue in favour of it. The PR, which makes nonce generation fail closed, was merged on June 29.

There’s a difference between a fix being merged and reaching the sites that run the plugin. As of October 20, the latest release is 0.17.0. The TOTP setup fixes (#796 and #797), the nonce fix (#860), and the revised failure notice (#919) have shipped. The resend fix (#847), rate-limit UX fixes (#918 and #920), and backup-code fixes (#956 and #957) are merged on master but aren’t in that release.

“fail closed?” When a security check can’t do its job, it has two options: allow the action anyway (fail open) or refuse it (fail closed). Failing open is friendlier on a bad day and catastrophic on a worse one, because it turns “I couldn’t check” into “check passed.” For anything protecting a login, you want to fail closed.

The layer split

By summer the pattern was clear. Two Factor is good at one thing: presenting a challenge and checking the answer. It’s deliberately unopinionated about who must use 2FA, which method they must use, and what happens to someone who hasn’t set it up. Those are policy questions, and every site answers them differently.

So the answers to those questions went into separate plugins, designed for specific websites or for general agency use on existing sites and users.

Two Factor owns the challenge. Providers, codes, validation. I contribute fixes upstream rather than forking it.

Require-Email-2FA owns policy. It’s a single-purpose plugin with no admin screen. It enforces Two Factor with emailed codes as the floor, so every covered user has at least email-grade 2FA from the moment it’s activated. It supports per-role exclusions and an allowlist for XML-RPC service accounts. (The allowlist governs XML-RPC only. REST requests authenticated with Application Passwords bypass this gate; the README lists REST hardening as future work.) There’s an optional must-use loader so it can’t be deactivated from the dashboard, which the README strongly recommends whenever you use exclusions or the allowlist. Its blocking mode, off by default, is designed so a user can’t be dead-ended: there’s always a path forward to a working factor. The use case for this plugin is a quick and aggressive 2FA rollout on existing sites where users will find themselves required to use the simplest, universal, email-based 2FA method. Also good for new sites with a default email 2FA policy.

two-factor-enrollment owns enrollment and onboarding. For a covered user who hasn’t configured a factor, it adds Email at runtime and lets Two Factor’s existing challenge prove mailbox control before issuing an auth cookie. Only a successful challenge makes that enrollment permanent. That closes the enrollment race: a stolen password alone can’t create a logged-in session while the account is waiting for 2FA setup. After login, a one-time onboarding screen provides backup codes and offers a link to set up an authenticator app. The plugin implements the flow proposed in #813, which remains open upstream. Its README says it “owns no enforcement policy.” Its coverage and deferral filters default to false, so installing it alone changes nothing. You decide who’s covered, ideally in a must-use plugin.

Why bother splitting these features in two plugins? Because each piece can be wrong without the others being wrong. If my enforcement rules don’t suit your site, replace that plugin; the challenge and the onboarding still work. And none of it competes with Two Factor itself, so fixes land in one place where everyone gets them.

Login proceeds from password to Two Factor’s code challenge, then an auth cookie. Require-Email-2FA supplies enforcement; two-factor-enrollment adds an email challenge for covered, unenrolled users and onboarding after login. Of twelve reported issues, four have shipped, five are merged but unreleased, and three remain open.
Two Factor validates the challenge. The add-ons supply enforcement, enrollment, and onboarding. Issue status as of October 20, 2026: four shipped, five merged but unreleased, three open.

What the UX told me

Working on the enforcement layer surfaced a cluster of smaller problems, mostly about what users see when something goes wrong:

  • #918: the rate-limit error was alarming, and its countdown was stale for legitimate users.
  • #919: one confusing failure notice was written for two audiences at once.
  • #920: the “Resend Code” button remained visible and interactive while a user was rate-limited and locked out, so they could keep sending themselves auth codes that were technically valid but unusable. Worse, each of those retries could keep them rate-limited!

All three fixes are merged upstream. #922, merged September 24, rewords the mixed-audience notice and closed #919; it shipped in 0.17.0. #921, merged September 28, calms the rate-limit message and hides the resend button during lockout, closing #918 and #920. That change is still awaiting a release. Earlier, #923, merged July 16, added tests for the fallback notices.

Two backup-code bugs I filed in August were fixed in faithcoder’s #958 and #964, both merged September 28: #956, where appending codes for a user who had none stored an extra, empty code, so the remaining-codes count was always one too high; and #957, where the downloadable recovery-codes file identified the site by its full URL rather than its host. Both fixes are awaiting a release after 0.17.0.

Account safety

The most recent batch (filed but not yet fixed) is about what happens around 2FA rather than inside it:

  • #945: password managers capture the TOTP setup QR code. That sounds convenient until the user’s authenticator and their password manager disagree, and they’re locked out. An open fix proposal, #1021, would generate the TOTP secret on demand instead of when the profile loads.
  • #953: enrolling a new factor destroys the user’s other sessions without telling them. A maintainer has since opened a fix, #1001, which emails the account owner when their 2FA methods change.
  • #954: personal-data export and erasure for 2FA data. Separate proposals for an exporter (#997) and eraser (#1026) are open.

What I’d tell someone about to write a 2FA plugin

Don’t, probably. Or at least, don’t start there.

Read the one that already exists. File what you find. Fix what you can. Pitch in and help out! If what you need is a policy that dictates who must use 2FA and how, build that as its own small thing that sits beside the mechanism instead of inside it. The mechanism is the hard part to get right, and it’s better maintained in one place than reimplemented in twenty.

Help fix the things you use

You don’t have to build a new plugin to make WordPress better. Use what’s there. Notice what breaks. Search the existing tickets, then report what you found with steps someone else can follow, what you expected, and what actually happened. A clear report or a test of someone else’s patch is a contribution too. The core bug-reporting guide is a good place to start.

Want to try a fix? Look at good first bugs in core or good first issues in Gutenberg. Read the project’s contributing guide, ask on the ticket whether it still needs work, and test your change. For Studio, Two Factor and other WordPress projects, start in that project’s own tracker. Small fixes count. So does sticking around to answer questions and help get them over the line.


How this article was researched & written

This is an account of my own contributions, so the risk was misremembering rather than misunderstanding. Every issue and pull request named in the original draft was checked against GitHub on October 6, 2026, and rechecked on October 19: who opened it, when, whether it’s open or closed, and when and by whom it was merged. On October 20 I checked the release status of the fixes against the 0.17.0 tag, verified the newer proposals linked here, and rechecked the enrollment sequence against the enrollment plugin’s README. The descriptions of Require-Email-2FA and two-factor-enrollment draw on their own READMEs, and the phrases in quotation marks are quoted from them.

As with the rest of this blog, I wrote and revised it in dialogue with the code and the issue trackers through my AI tool of choice, Claude, which also helped with drafting, verification and copy editing. Most of the fixes described here were reviewed and merged by Two Factor’s maintainers, and several were finished by them. Thanks to Kaspars Dambis in particular.

Codex helped prepare the October 20 release-status check, diagram, and editorial revisions.

The judgement calls, the framing, and the errors that remain are mine.

Leave a Reply

Your email address will not be published. Required fields are marked *