You are about to turn on passwordless login for a WordPress site that other people use. The security case is well covered elsewhere. This post is the part that goes wrong: the endpoint you just exposed, the recovery path you have not decided yet, and the fifteen minutes of testing that separates a rollout from an incident.
Order of operations first, because it is most of the value here:
- Enable the factor on one test account. Nobody else.
- Enrol administrators.
- Decide and document the recovery path.
- Widen to everyone, with the old factor still working in parallel. Turning a second factor on for every user at once, on a site where some fraction of members have a dead address on file, converts a support queue into a support incident. Everything below is the detail inside those four steps.
1. Verify the origin binding actually works before you trust it
The reason a passkey resists phishing is a string comparison the browser performs, not a policy you configure. When a credential is created, it is bound to the relying party ID, which is your registered domain. At authentication the browser compares the origin of the page requesting a signature against that RP ID and refuses to surface the credential on a mismatch.
You can watch this in the console rather than taking it on faith. On your real login page:
navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from(crypto.getRandomValues(new Uint8Array(32))),
rpId: location.hostname,
userVerification: "preferred",
timeout: 60000
}
}).then(c => console.log("credential offered:", c))
.catch(e => console.log("refused:", e.name, e.message));
Run the same snippet from a copy of the page served on any other hostname you control. The rpId will not match the enrolled origin and the call fails before the user is prompted. That is the whole anti-phishing mechanism, and it is worth confirming once on your own stack.
Two things to check while you are here:
-
The site must be HTTPS. WebAuthn is available only in a secure context.
localhostis exempt for development. -
Decide the RP ID deliberately if you use subdomains. A credential registered with
rpId: "example.com"is usable onshop.example.com. One registered withrpId: "shop.example.com"is not usable on the apex. Getting this wrong on a multisite or a subdomain store is the most common enrolment bug I have seen. Worth knowing what this control is worth: the Verizon 2025 Data Breach Investigations Report puts stolen credentials in 88% of Basic Web Application attacks, and credential stuffing at a median 19% of daily authentication attempts on affected applications. The origin check is aimed squarely at that traffic.
2. You just added an unauthenticated endpoint. Rate limit it
Magic link login means a route that accepts an email address from anyone and sends mail. That is a new unauthenticated write surface on your site, and it needs the same treatment as /wp-login.php.
nginx, limiting token requests to 1 per 20 seconds per IP with a small burst:
limit_req_zone $binary_remote_addr zone=magiclink:10m rate=3r/m;
location = /wp-json/your-namespace/v1/magic-link {
limit_req zone=magiclink burst=2 nodelay;
limit_req_status 429;
try_files $uri $uri/ /index.php?$args;
}
Apache 2.4 with mod_ratelimit is weaker for this; if you are on Apache, do it in PHP or at the edge. A minimal in-application throttle keyed on the submitted address rather than only the IP:
add_action( 'init', function () {
if ( ! isset( $_POST['magic_link_email'] ) ) {
return;
}
$email = sanitize_email( wp_unslash( $_POST['magic_link_email'] ) );
$key = 'ml_throttle_' . md5( $email . '|' . $_SERVER['REMOTE_ADDR'] );
if ( get_transient( $key ) ) {
status_header( 429 );
wp_die( 'Too many requests. Try again in a minute.', 429 );
}
set_transient( $key, 1, 60 );
} );
Key on both the address and the IP. IP alone is trivially rotated; address alone lets one attacker lock a specific user out of their own login.
Token rules worth enforcing regardless of implementation: single use, short lifetime (10 to 15 minutes is the usual compromise), invalidated on use and on a new issue, and never logged. A magic link is a bearer token. Whoever holds the URL is authenticated, which also means it should not end up in a Referer header or an analytics query string.
3. Decide the recovery path before it is an incident
Remove passwords and your account recovery flow becomes the weakest link in the chain. This is not a criticism of passkeys, it is arithmetic: the attacker moves to the cheapest remaining path.
Pick one and write it into your runbook:
| Recovery path | Cost | Failure mode |
|---|---|---|
| Second enrolled device | Zero, if enforced at enrolment | Users skip it unless the UI blocks them |
| Hardware key in a drawer | One key per admin | Only works if someone can physically reach it |
| Admin-initiated re-enrolment | Support time | Only as strong as your identity check on the requester |
| Emailed recovery link | Free | Collapses account security into mailbox security |
If your recovery is an unverified email request, you have relocated the vulnerability rather than removed it.
The one that catches teams out is the second row. A hardware key stored in an office nobody visits is not a recovery path, it is a story about one.
4. Get-back-in procedures, tested on staging first
Test these before you need them, on a copy, with the site's real plugin set active.
Deactivate the security plugin over SSH or WP-CLI, which restores the default login without touching core:
wp plugin deactivate your-security-plugin --path=/var/www/example.com
No WP-CLI, no shell, only SFTP? Rename the plugin directory. WordPress deactivates a plugin whose folder has disappeared:
mv wp-content/plugins/your-security-plugin wp-content/plugins/your-security-plugin.off
Confirm you can create an emergency administrator from the CLI, and delete it the moment you are done:
wp user create breakglass ops@example.com --role=administrator --user_pass="$(openssl rand -base64 24)"
# ... regain access, fix the enrolment, then:
wp user delete breakglass --reassign=1
Run all three on staging. A recovery procedure you have never executed is a hypothesis.
5. The end-to-end test that catches the real breakages
Second-factor prompts attach to the login form. They do not attach to checkout or to the block editor, so a normal customer purchase is unaffected. The failure mode is a custom or plugin-rendered login form that bypasses the standard hooks and therefore never shows the prompt.
Check the response and the redirect chain directly rather than eyeballing the page:
# Does the login form still return 200 and set the expected test cookie?
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' \
https://example.com/wp-login.php
# Is the token endpoint actually throttling?
for i in $(seq 1 6); do
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST -d 'magic_link_email=test@example.com' \
https://example.com/wp-json/your-namespace/v1/magic-link
done
That loop should return a 429 before it finishes. If it returns six 200s, your throttle is not wired up.
Then walk one real account through, in this order: WooCommerce account login, any custom front-end login form, password reset, the second-factor prompt itself, and recovery. Five clicks, one account, before anyone else is enrolled.
What this does not cover, and why that matters operationally
Everything above hardens the credential path. It is the highest-value change most WordPress sites can make to their login layer, and it is also the entire scope of what it does.
Three classes of attack are untouched by all of it, because the attacker never reaches the form:
- Pre-authentication plugin vulnerabilities. Exploited by an unauthenticated request. There is no login event in that path to strengthen. Patchstack's State of WordPress Security in 2026 reports 11,334 new WordPress-ecosystem vulnerabilities disclosed in 2025, a 42% year-on-year rise, with 91% of them in plugins rather than core.
- A compromised plugin update channel. WordPress fetches, unpacks and executes vendor code on a schedule, with no authentication event anywhere in it.
- Client-side skimmers on checkout. The attacker reads what the customer types. Your admin login was never involved. The same Patchstack report records 20% of heavily-exploited vulnerabilities under active attack within six hours of disclosure, 45% within twenty-four hours, and 70% within seven days. Against a six-hour window, the control that matters is reducing what is reachable and patching fast, not strengthening a form the attacker skipped. Two surfaces, two controls, and the mistake is finishing one and believing you finished both.
Before you enrol anyone, run the throttle loop in section 5 against your own token endpoint. I would like to know how many people get six 200s back, because that endpoint tends to ship without a limiter and nobody notices until the mail queue does.
Top comments (0)