WordPress 2FA Without a Third-Party Service
Most WordPress two-factor plugins send your login events to somebody else's server. Here is how to do it entirely on your own, and what actually has to be right for it to be worth having.
Two-factor authentication is the single highest-value security change you can make to a WordPress site. Strong passwords get reused. Password managers get skipped. But a stolen password on its own stops being enough the moment a second factor is in place.
So most people install a 2FA plugin, tick the box, and move on. That is usually the right instinct. What almost nobody checks is where the second factor is actually being verified.
A surprising number of popular options route verification through the vendor's own cloud service. Your users' login attempts, their email addresses, the timing of every session, all of it leaves your server. For a personal blog that might be an acceptable trade. For a client's WooCommerce store, a membership site, or anything touching regulated data, you have just added a third party to your authentication path and, in most cases, told nobody about it.
I recently released FactorGuard, a 2FA plugin that does none of that. Everything happens on the server the site already runs on. Writing it forced me to be precise about a handful of things that are easy to get wrong, and those are worth writing down whether or not you ever use my plugin.
TOTP is simpler than it looks
Time-based one-time passwords, the six digits your authenticator app shows, are not magic and do not require a network call. The whole thing is RFC 6238, and it works like this.
The server and the app share one secret, exchanged once when the user scans the QR code. To produce a code, both sides take the current Unix time, divide it by 30 to get a time step, and compute an HMAC-SHA1 of that number using the shared secret. A few bits of the result select an offset, four bytes are read from that offset, and the value is reduced modulo 10^6 to give six digits.
That is it. The phone is not talking to anything. The server is not talking to anything. They independently arrive at the same number because they share a secret and roughly agree on the time. Clock drift is handled by accepting the adjacent time steps as well as the current one.
Once you see it written out, the idea that verification needs an external API stops making sense.
The secret is the whole ballgame
If TOTP secrets sit in your database in plain text, your 2FA is decorative. Anyone with a database dump, and database dumps are exactly what happens in the breaches 2FA is meant to survive, can generate valid codes for every user forever.
So secrets have to be encrypted at rest, with the key held outside the database. In FactorGuard that is libsodium's XSalsa20-Poly1305 through sodium_crypto_secretbox, which is authenticated encryption: tampering is detected rather than silently decrypted into garbage. PHP has shipped libsodium in core since 7.2, so there is no excuse to hand-roll anything here or to reach for openssl_encrypt with a mode you have not thought carefully about.
Backup codes are a related trap. They are credentials, so they get hashed exactly like passwords, never stored in a form you could print back out for the user later. If your plugin can show you an old backup code, it is storing it wrong.
Replay protection is the part people forget
A TOTP code stays valid for its full 30 second window, and usually the adjacent windows too. That means a code captured in that window, over the shoulder, from a phishing page, out of a proxy log, can be replayed.
The fix is small and almost always missing: once a code is accepted, invalidate it for its time slice. One code, one login. It costs a single transient and closes a real hole.
Rate limiting, or you have built a slower password
Six digits is a million possibilities. Left unthrottled, that is a weekend's work for a script. Every login form protected by OTP needs a lockout: a configurable number of failed attempts, then a cooling-off period, tracked per user and per address rather than globally so one attacker cannot lock out your entire member base.
This is the difference between two-factor authentication and two-factor theatre.
Do not break the machines
The mistake that generates the most angry support tickets is applying OTP to requests that no human is making. REST API calls, XML-RPC, WP-CLI, your uptime monitor, your backup plugin, your mobile app. None of them can type a code from a phone.
Authentication for those paths has to be handled separately, usually with application passwords, and the OTP layer needs to step aside cleanly rather than returning a login form to a machine expecting JSON. Get this wrong and 2FA looks like it works right up until a scheduled job silently stops running.
What this buys you
None of the above is exotic. It is a shared secret, an HMAC, authenticated encryption, a transient, and some care about which requests are human. All of it fits comfortably inside a WordPress plugin, and all of it runs on hardware you already control.
The reason to care is not purity. It is that every external dependency in your authentication path is a service that can go down, change its pricing, get acquired, get breached, or quietly start logging more than you expected. Authentication is the last place you want a surprise.
If you want the version I wrote, FactorGuard is free on WordPress.org: email OTP, TOTP apps, encrypted secrets, hashed backup codes, replay protection, and rate limiting, with no external calls. If you would rather build your own, the RFC is short and the standard library has everything you need.
Either way, check where your current plugin verifies codes. You may be surprised who is in the loop.
Working on a WordPress project?
Let's talk about what you're building.
I'm available for custom plugin development, performance optimization, and headless WordPress projects.
