Repository navigation
Conversation
- Bearer tokens from other apps on the WorkOS environment no longer act for users with MFA; only sessions that signed in here (VerifiedSessions). - Admin MFA routes and tools never apply to your own account (edit_user is granted on yourself to every role). - Adding a second authenticator needs a current code from an existing one, on the account panel and the legacy TOTP/SMS routes. - Wrong codes capped per account (5/15 min, 30/day) under a lock (CodeGuard); a code must come from its challenge's factor. - Sign-in /challenge and /verify need an mfa_token; WorkOS mfa_challenge is parked too, so the pending token never reaches the browser. - Policy reads factors from WorkOS at every sign-in; the enabled flag is sticky, so vanished factors mean enroll, not password-only. - Users with MFA are never re-linked by email; app-password exemption is per user (XML-RPC multicall); guard follows an 'always' default profile. - Parked sign-ins: fixed expiry, encrypted response, one-time resume code in the hosted return URL. - Factor changes email the account and sign out other sessions; admin reset signs out everywhere and revokes the WorkOS session. - Headless refuses before storing tokens; admin panel applies after save; MFA errors drop WorkOS response bodies.
bordoni
added this pull request to stack #87
October 18, 2026 20:39
WorkOS is the source of truth for a user's factors. When WorkOS answers with none, list_factors() clears _workos_mfa_enabled instead of keeping it, logs mfa.removed_in_workos, fires workos_mfa_removed_in_workos and emails the user. A failed WorkOS call never clears the flag, and the require flag and roles are untouched, so required users enroll again.
bordoni
marked this pull request as ready for review
October 19, 2026 05:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes from a security review of the MFA stack before 1.1.0. Stacked on #90. Part of #28.
The review looked for three kinds of problem: ways to get a session without passing MFA, weak spots in the sign-in code step, and ways a stolen session could change MFA. Each finding below was checked against the code. The WorkOS behavior it depends on was tested against the staging environment.
High
REST\TokenAuthaccepted any valid WorkOS access token for the environment. That includes tokens from another site, a staging copy, or hosted AuthKit, none of which ran this site's MFA. For users who have or need MFA, REST now accepts only tokens whose session (sid) signed in on this site (Mfa\VerifiedSessions). New filters:workos_mfa_accept_bearer_tokenand the generalworkos_bearer_token_user.edit_useron itself, so the admin routes (/mfa/required,/mfa/reset) let a hijacked session wipe its factors and clear the require flag. The admin routes and tools now never apply to your own account.did_action()for the whole request. One multicall could authenticate an attacker's application password, then the victim's password, and the guard stepped aside. The exemption now covers only the user whose application password it was, in that sign-in.Mfa\CodeGuard: 5 wrong codes per 15 minutes and 30 a day per account, across sign-in, the account panel and the legacy routes. Only one code check per account runs at a time, under a lock (wp_cache_add, orINSERT IGNOREwithout an object cache). Right codes are refunded._workos_mfa_enabled. A factor created on hosted AuthKit, in the dashboard, by another app or before this version gotnone(a password-only sign-in) orenroll(an attacker adds their own app). The policy now reads factors from WorkOS at every sign-in. The flag follows WorkOS both ways: a factor removed in WorkOS turns MFA off in WordPress (activity-logmfa.removed_in_workos, actionworkos_mfa_removed_in_workos, an email to the user), and users who must use MFA enroll again. A failed WorkOS call never clears it. Checked live on iwos110: factor added and verified in WorkOS → WordPress picked it up → deleted in WorkOS → next password sign-in went straight to wp-admin with MFA off.Medium
mfa_token. WorkOS's ownmfa_challengeis parked like the plugin's./auth/mfa/challengeand/auth/mfa/verifyrefuse requests without a token, so nobody can challenge an arbitrary factor or pair a victim's pending token with their own challenge. A code must come from the factor its challenge belongs to (challenge.authentication_factor_id). That also fixes verifying against the wrong factor when a user has more than one.factor_id+code, else403 workos_mfa_code_required). This covers the account panel and the legacy/auth/mfa/totp/enrolland/auth/mfa/sms/enroll, which also respectworkos_mfa_can_managenow. The sign-in enroll step refuses if a factor has appeared since the policy decided.workos_relink_protected).wp workos user link --forcestill works.alwaysprofile. It now also refuses users linked to WorkOS whom the default profile would ask for a code. Unlinked local admins aren't affected.history.replaceStateand sendsReferrer-Policy: no-referrer.Mfa\AccountChanges, filterworkos_mfa_notify_user).Low
workos_user_authenticated.profile_update, after core validates the form, and shows a reset WorkOS refused as a notice.status()returnsmust_use(flag, role or filter), and the panel's "only app" warning uses it.data.Checked against WorkOS staging (a throwaway factor on the demo user, deleted afterwards)
one_time_code_too_many_attempts), after which even the right code fails.authentication_challenge_previously_verified), and a used code fails on a new challenge.challenge.authentication_factor_id.Not changed (follow-ups)
TokenAuth::is_rest_request()matches/wp-json/anywhere in the URI, andTokenAuthdoesn't check deactivation. Neither is specific to MFA.mfa.*events aren't recorded unless it's turned on.docs/mfa.md.Contract changes (all inside unreleased 1.1.0 work, apart from the two routes)
/auth/mfa/challengeand/auth/mfa/verifyneedmfa_token. Before this stack they never worked end to end against WorkOS.PUT /users/{id}/mfa/requiredandPOST …/mfa/resetrefuse your own id.POST /users/{id}/mfa/enrollneedsfactor_id+codewhen a factor exists.Verified
MfaSecurityTest: 20 tests, at least one per fix, written as the attack.tscand the webpack build pass.docs/mfa.md, plusdocs/rate-limiting.mdand CHANGELOG.