Most credential-stuffing attacks originate from infrastructure in a different country than the account owner. This recipe detects that mismatch — comparing where the user is signing in now against where they signed in last time — and requires a second factor before issuing a session.
Require MFA when event.request.geo.country differs from the country recorded on the user's previous successful login. When the countries match, the login proceeds without an extra step. When they differ, the user is challenged before a session is issued, and the recorded country is updated for next time.
When to use it
Section titled “When to use it”This pattern fits B2B and B2C tenants alike. It pairs well with IP reputation checking: IP reputation flags known-bad networks; geo-mismatch catches accounts where an attacker has obtained valid credentials and is logging in from their own infrastructure, which may not be flagged.
Use it any time the expected geographic distribution of your users is bounded. A company whose employees all work from one or two countries, or a consumer app where 95% of logins come from three countries, will see high signal-to-noise from this check. A globally distributed product with users who travel frequently will see higher false-challenge rates.
What you'll build
Section titled “What you'll build”A Login flow where a custom action in the Post-Auth stage compares the current login's country against event.user.app_metadata.last_login_country. If they differ, the action signals the downstream Decision block to route to MFA. After the check — regardless of outcome — the action updates last_login_country so the next login has a fresh baseline.
Login flow Stage: Pre-Login Block: Identity Lookup Block: Password Check Stage: Post-Auth Block: Run Custom Action: geo-mismatch-check <-- new Block: Decision <-- new Branch: step-up (require_step_up == true) Block: MFA Branch: proceed (default) Stage: Post-Login Block: Issue SessionConfigure it
Section titled “Configure it”Step 1 — Create the custom action
Section titled “Step 1 — Create the custom action”Go to Custom Actions, click + New Action, and name it geo-mismatch-check. Set the trigger to Login. Paste the code from The code section below, then click Save and enable the action.
Step 2 — Add the action to the Login flow
Section titled “Step 2 — Add the action to the Login flow”Go to Flows, click Login, then Edit. In the Post-Auth stage, drag a Run Custom Action block onto the canvas and select geo-mismatch-check from the list.
Step 3 — Add a Decision block
Section titled “Step 3 — Add a Decision block”Drag a Decision block immediately below the Run Custom Action block.
Configure two branches:
| Branch label | When expression | Route to |
|---|---|---|
step-up | step.geo-mismatch-check.require_step_up == true | MFA block |
proceed | (default) | Issue Session |
The slug geo-mismatch-check is what the action's api.state.set('require_step_up', true) call is namespaced under at runtime. If you give the Run Custom Action block a different slug in the editor, update the expression to match.
Step 4 — Add MFA on the step-up branch
Section titled “Step 4 — Add MFA on the step-up branch”On the step-up branch, drag an MFA block. After a successful verification the flow continues to Issue Session at AAL2.
Step 5 — Save and publish
Section titled “Step 5 — Save and publish”Click Save, review the diff, and click Publish.
The code
Section titled “The code”// geo-mismatch-check// Compare the current login's country to the last recorded login country.// If they differ, signal the downstream Decision block to require step-up MFA.// Always update last_login_country after the check so the next login has a fresh baseline.
const currentCountry = event.request.geo?.country;const lastCountry = event.user.app_metadata?.last_login_country;
if (!currentCountry) { // GeoIP lookup produced no result — skip the check rather than challenging every // login from IPs with no geo record (common for IPv6 prefixes not yet in the feed). api.log('warn', 'geo-mismatch-check: no country resolved for IP; skipping comparison'); return;}
if (!lastCountry) { // Cold start: this user has never been through this check before. // Write the baseline without challenging — we have nothing to compare against. api.log('info', `geo-mismatch-check: no prior country on record; writing baseline: ${currentCountry}`); api.user.setAppMetadata({ last_login_country: currentCountry }); return;}
if (currentCountry !== lastCountry) { // Country changed — signal step-up and update the baseline. api.log('warn', `geo-mismatch-check: country changed from ${lastCountry} to ${currentCountry}`); api.state.set('require_step_up', true);}
// Always update the baseline, whether or not we required step-up.// This means the next login from a new country will also be challenged.api.user.setAppMetadata({ last_login_country: currentCountry });A few design choices worth noting:
- The cold-start case writes the baseline without challenging. A user's very first login through this flow has no prior country to compare against, so demanding MFA would challenge every new user unnecessarily.
- The
currentCountryguard is a deliberate fail-open. If the GeoIP feed has no record for this IP, the action exits without challenging. The alternative — challenging every IP with no geo record — creates a support burden with no meaningful security gain. api.user.setAppMetadatais called unconditionally at the end. This updates the baseline whether or not step-up was required, ensuring the comparison stays accurate over time.
Test it
Section titled “Test it”Open the Test tab in the Custom Actions editor for geo-mismatch-check.
Test 1 — geo mismatch, expect step-up:
// Set last_login_country to US, current country to RU.// Expected: api.state.set('require_step_up', true) appears in the API calls panel.// api.user.setAppMetadata({last_login_country: 'RU'}) also appears.{ "event.user.app_metadata": { "last_login_country": "US" }, "event.request.geo": { "country": "RU" }}Test 2 — countries match, no step-up:
// Last and current country are both IN.// Expected: no api.state.set call; setAppMetadata({last_login_country: 'IN'}).{ "event.user.app_metadata": { "last_login_country": "IN" }, "event.request.geo": { "country": "IN" }}Test 3 — cold start, no prior country:
// No last_login_country in app_metadata.// Expected: no step-up; setAppMetadata({last_login_country: 'GB'}).{ "event.user.app_metadata": {}, "event.request.geo": { "country": "GB" }}Test 4 — no geo resolved:
// GeoIP returned nothing.// Expected: warning log, no state.set, no setAppMetadata.{ "event.user.app_metadata": { "last_login_country": "US" }, "event.request.geo": { "country": null }}Verify on a live flow
Section titled “Verify on a live flow”Sign in to your tenant from your normal network. Confirm no MFA prompt appears and that last_login_country has been written to your user's app_metadata (visible on the user's detail page in the admin console under Metadata).
Connect to a VPN that exits in a different country and sign in again. Confirm the MFA prompt appears. Disconnect the VPN and sign in once more — you should be challenged again this time, because the baseline was updated to the VPN country on the previous login. Sign in one more time from your normal network — no challenge, because your home country now matches the baseline again.
This recipe writes last_login_country to the user's app_metadata on every flow execution, including Test pane runs. Test pane executions persist. Run your tests against a scratch identity — not against your own account or a production user — to avoid polluting their baseline.
Cautions
Section titled “Cautions”Geo is from IP — travelers and roamers will be challenged. A user on cellular roaming, staying in a hotel abroad, or connected to their company's VPN will frequently trigger this check. Consider whether your user base travels often before enabling this for all users. One mitigation: check continent rather than country by mapping event.request.geo.country to a continent code in the action, so a user moving from France to Germany is not challenged.
Write last_login_country Post-Auth, not Pre-Auth. The action runs in the Post-Auth stage after Password Check has already verified the credential. If you moved this logic to a Pre-Auth stage and a failed password attempt updated the baseline, an attacker could reset the baseline by submitting a login attempt with a wrong password from the target's home country. The Post-Auth placement means only verified logins update the record.
The cold-start window is a real gap. A user's first login through this flow establishes the baseline without any challenge. If an attacker has the credentials and logs in before the real user, they set the baseline to their country. The real user then gets challenged. This is an inherent limitation of any "last seen" approach; you can narrow the window by combining this recipe with the Step-up MFA on risk score recipe, which independently evaluates IP reputation and brute-force signals.
MFA enrollment is a prerequisite. The MFA block on the step-up branch will fail if the user has no enrolled factor. Combine this recipe with a require_mfa_enroll block on the Registration flow so users arrive with a factor enrolled before they could ever be challenged.