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.
"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.
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.
What it does, precisely:
- Hard-deletes the account's
two_factorrow, so a stale or broken secret cannot linger and a fresh enrolment starts clean. - Writes a
security.2fa_disabledentry at warning severity into that customer's own activity timeline, recording which administrator did it. - Answers
User does not have 2FA configuredand 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
-
"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. -
"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 country — CDN_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
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:
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.
Related
- The user desk — the full account screen: statuses, the two block dialogs compared, CSV import and export, and what each tab holds.
- Roles and permissions — how
edit.useris derived and the five places a key has to exist. - The scheduler and
Scheduled jobs reference —
processExpiredUserBlocksand everything else that stops whencrondoes. - The admin panel — where these screens sit and the daily routine around them.