A customer cannot sign in — 2FA, blocks and geo, in that order

The three things that stop a sign-in, the order to rule them out, where Reset 2FA actually lives, and the two refusals you will hit when you try to use it.

8 min readUpdated 6 August 2026users, two-factor, blocking, geo, runbook

"I can't get into my account" arrives with no diagnosis attached. Three different mechanisms produce it, they live on three different screens, and only one of them tells the customer anything useful. Work them in this order — blocked, then second factor, then geography — because each step is cheaper than the one after it and the first two account for almost every ticket.

Before any of that, ask what the sign-in page actually said. The messages are distinct and they hand you the answer for free:

What the customer sees What it is Where to go
Your account is suspended. Please contact support. A temporary block Step 1
Your account has been banned. Please contact support. A permanent block, or a hand-set status Step 1
Your account is inactive. Please verify your email or contact support. Status INACTIVE — usually unverified email Step 1
Too many failed login attempts, account is temporarily blocked The login lockout, not a block See below
Incorrect email or password Exactly that Password reset
A compliance notice naming their country A geo rule Step 3
The password is accepted and the code is refused A lost authenticator Step 2

Five failed password attempts inside five minutes trip a lockout keyed on the account's failedLoginAttempts and lastFailedLogin. It expires five minutes after the last failure and the counter is reset to zero by the next successful sign-in. There is no unblock button for it and it writes no block row — waiting is the fix.

The current count is on the customer's page (/admin/crm/user/{id}) in the Quick Stats card as Failed logins, and the badge turns red past three. A number in the dozens is a credential-stuffing signal, not a support ticket.

Step 1 — is the account blocked

Users → Users (/admin/crm/user, access.user plus view.user). Search by email. The per-row menu carries exactly one operational entry, and which one it is tells you the answer immediately: Block User when the status is ACTIVE or INACTIVE, Unblock when it is SUSPENDED or BANNED.

Blocks an account with a recorded reason.
Lifts every active block on the account and restores it to ACTIVE.
The whole block history for one account, newest first, each with the administrator who placed it.

The history is the record that answers the ticket, and it is rendered on the customer's own page at /admin/crm/user/{id}: every block ever placed, its reason, whether it was temporary, the duration, blockedUntil, whether it is still active, and which administrator placed it by name and email. Read it before you lift anything — "nobody knows why this account was banned" is a different conversation from "Compliance banned it on the 14th".

A reason is mandatory, and the list depends on the screen

The block dialog on the user list offers seven reasons:

Reason
Suspicious Activity
Terms of Service Violation
Security Concerns
Fraud Investigation
Compliance Review
Customer Request
Other

Choosing Other reveals a free-text box, and whatever you type is stored instead of the word "Other". The reason is stored as text on the block row (1–1000 characters) and it is what you will be reading back to the customer in three months.

/admin/crm/user/{id} has its own Block dialog with Security Concern, Fraud Prevention, Money Laundering, Multiple Account Violation and Admin Request instead of some of the above — and its duration list reaches 1 year where the list dialog stops at 1 month. Only Suspicious Activity, Terms of Service Violation and Other are common to both. Decide as a team which screen your desk blocks from, or your block reasons will not group. The full comparison is in The user desk.

A temporary block sets SUSPENDED; a permanent one sets BANNED. Both revoke every session the account holds, because login refuses a non-ACTIVE status but an existing session never goes back through login.

A temporary block that never lifted is a scheduler problem

Temporary blocks are cleared by one scheduled job and nothing else.

processExpiredUserBlocks runs every 15 minutes, takes up to 500 expired blocks per tick, oldest expiry first. For each it deactivates the block row and — only if the account is still SUSPENDED and has no other active block — puts it back to ACTIVE.

The website keeps serving pages perfectly while every suspended customer stays suspended, indefinitely. This is the single most common cause of "my week-long suspension never lifted", and nothing in the product volunteers it.

Check System → System Monitoring → Scheduled Tasks (/admin/system/cron) — the heartbeat banner first, then the job's own last-run time.

Two more consequences of that batch limit worth knowing: after a stretch of downtime the backlog is drained over successive ticks rather than all at once, so a large install may take several ticks to catch up; and an account moved to BANNED or INACTIVE out of band is left where it is, because the reactivation only fires on SUSPENDED.

You never have to wait for the cron. Unblock lifts it immediately, and it deactivates every row still flagged active for that user, not just the one it happened to find.

Step 2 — the second factor

If the account is ACTIVE and the password is accepted but the code is refused, the customer has lost their authenticator. There is exactly one remedy and it is not on the user table.

Open /admin/crm/user/{id} and use Reset 2FA in the header. The row menu on /admin/crm/user deliberately carries no 2FA control: the edit dialog's old "Disable 2FA" switch posted a field the handler never read, so it reported success and did nothing — and even wired correctly it would have failed, because disabling 2FA is a sensitive change and the backend demands a current password the dialog does not collect. The row menu now defers to the dedicated route.

Clears the account's 2FA configuration so the customer can enrol again.

What it does, precisely:

  • Hard-deletes the account's two_factor row, so a stale or broken secret cannot linger and a fresh enrolment starts clean.
  • Writes a security.2fa_disabled entry at warning severity into that customer's own activity timeline, recording which administrator did it.
  • Answers User does not have 2FA configured and stops if there was nothing to clear — which is itself diagnostic. If you get that message, 2FA is not why they cannot sign in.

What it does not do:

  • It does not touch the platform-wide Two-Factor Authentication switch in System → Platform Settings → Security, or any per-method switch. Those are settings; this is one row for one customer.
  • It does not exempt the account from policy. If Require 2FA to Withdraw is on, the customer must enrol a new method before they can withdraw — which is the point.

The two refusals you will hit

  1. "User not found" on an account you can see. assertCanAccessUser() runs first, and a non-Super-Admin pointed at a Super Admin account gets a 404, not a 403 — deliberately, because a 403 would confirm the UUID belongs to a Super Admin, which is the fact being withheld. Resetting a Super Admin's second factor is a complete account-takeover primitive; only another Super Admin can do it.

  2. "Use the password-protected security settings to disable your own 2FA." You cannot reset your own. Self-disable goes through the password-gated flow in your own security settings, so a hijacked admin session cannot strip it.

This action hands the account back to whoever asks next. Nothing in the platform verifies that the person in the ticket is the account holder, and the audit entry will have your name on it. Use whatever second channel you have — a known phone number, a KYC document already on file, a video call — and record what you did in the ticket.

Before resetting, the Activity tab on the customer's page is worth thirty seconds: it returns sign-ins, 2FA changes, API-key lifecycle and KYC events with the IP address and User-Agent recorded at the time. "Was this login from somewhere unusual" is exactly what a takeover attempt looks like, and it looks identical to a lost phone in the ticket text.

Step 3 — geography

Only reach this step if the account is ACTIVE, 2FA is not involved, and the customer is describing a refusal notice naming their country rather than a sign-in error.

Geo enforcement ships off (geoRestrictionEnabled defaults to false). If nobody has turned it on, this is not your problem and you can stop here.

System → Compliance → Geo Access Log (/admin/system/geo-restriction/log, access.geo.restriction.log) is the evidence trail, with CSV export. Search by the customer's IP address.

Column Answers
ip Where the request came from
countryCode / countryName The country the engine resolved
region, city The finer location, on the row's detail panel
source Which signal established the countryCDN_HEADER, IP_LOOKUP, KYC, PROFILE, MANUAL, NONE
decision BLOCKED, ALLOWED or BYPASSED
reasonCode Why — COUNTRY_BLOCKED, NOT_IN_ALLOWLIST, UNKNOWN_COUNTRY_BLOCKED, IP_BLOCKLIST, ANONYMIZED_IP, LOOKUP_FAILED_CLOSED, and the allowing counterparts
path, method, action What they were trying to do
hitCount How many identical decisions collapsed into this row
isProxy, isTor, isHosting Whether the address looked like a VPN, Tor exit or datacentre

source is the column that resolves the argument. A customer who insists they are in Germany while the log says KYC and PT is being placed by the country on their verified identity documents, not by their IP address — a completely different conversation from an IP_LOOKUP that put them behind a VPN exit node.

geoRestrictionLogMode defaults to BLOCKED, which records refusals and bypasses only. Absence of a row is therefore not evidence that the request was allowed — it may simply not have been logged. Set the mode to ALL while you investigate, and put it back afterwards.

Identical decisions are also collapsed for geoRestrictionLogDedupeSeconds (default 300) into one row with a rising hitCount, so a customer retrying six times in a minute produces one row, not six.

You will not be able to reproduce it

When on, every request under /api/admin walks past the geo gate. Defaults to on.

With this on — and it is on by default — /api/admin is exempt from geo blocking entirely, and the frontend applies the same bypass to admin pages. So an administrator sitting in the blocked country, testing from the same address as the customer, sees the admin panel load perfectly and concludes the rule is not firing.

It is firing. It is just not firing on you. The bypass exists so that an operator cannot lock themselves out of the screen that would undo a bad rule.

To actually test the rule, use the rule tester on /admin/system/geo-restriction (access.geo.restriction) rather than a browser:

Simulates a decision for a country code or IP, with an optional path, activity, and VPN or Tor flags. Answers what the engine would do, without waiting for a real visitor.

Give it the customer's country code or IP and the path they were on. It resolves the location the same way the live gate does and reports the decision, the reason code and which rule matched.

Everything here is on the audit trail

Block, unblock, status change and 2FA reset all declare logModule: "ADMIN_CRM", so each writes a row to System → System Monitoring → Audit Trail (/admin/system/audit) carrying your userId, the target's id, the method and path, success or error, the duration, the request id, your IP and the step-by-step narrative of what the handler did.

The block dialogs send their reason in the request body, so it lands in the audit row's reason column as well as on the block row. The trail is append-only — no create, edit or delete, and no write endpoint behind the screen.

That cuts both ways, and it is worth saying out loud to a new operator: the account-recovery actions on this page are the ones most likely to be examined after a fraud loss, and every one of them has your name on it.