Withdrawal approval and two-factor policy
The seven settings that decide whether money can leave without a human or a second factor — their precedence, the two that fail open, the four doors that enforce them, and why one save can 403.
Seven settings decide whether a customer's money can leave your platform without a human looking at it and without a second factor. They live on two different tabs, they interact, two of them fail open by design, and no settings table conveys any of that. This page is the policy those seven rows add up to.
| Key | Tab | What it decides |
|---|---|---|
withdrawAutoApprove |
Wallet → Security | Whether a spot withdrawal executes with no admin review |
withdrawApproval |
(no longer on the panel) | The legacy form of the same question |
withdrawTwoFactorRequired |
Security → Withdrawal Security | Whether the customer must hold an accepted second factor |
withdrawTwoFactorChallenge |
Security → Withdrawal Security | Whether each withdrawal needs a fresh one-time code |
withdrawTwoFactorAppAllowed |
Security → Withdrawal Security | Authenticator app counts |
withdrawTwoFactorEmailAllowed |
Security → Withdrawal Security | Email codes count |
withdrawTwoFactorSmsAllowed |
Security → Withdrawal Security | SMS codes count |
All seven are on Admin → System → Platform Settings, and all seven are Super-Admin-only. Changes take effect immediately — there is no restart.
Approval: who decides that money leaves
Two keys answer one question, and the newer one wins.
-
withdrawAutoApprove— if this key exists in the settings table at all, its value decides."true"auto-approves, anything else requires review. -
withdrawApproval— consulted only whenwithdrawAutoApprovehas never been saved. The legacy key, whose name reads backwards: the row stores"true"to mean auto-approve, i.e. no approval required. -
Neither set → manual review. The default is the safe one.
Only one switch appears on the Wallet tab: Auto-Approve Withdrawals. Saving it writes the legacy row to the same value, so the two can never disagree, and the fallback in step 2 can only ever be reached on a database nothing has saved through. The legacy row is never deleted — it simply stops being able to say anything different.
Until 6.6.4 the legacy key had a switch of its own, labelled Auto-Approve Withdrawals (Legacy), sitting beside the current one with nothing tying the two together. A key with no settings row renders its default (off) rather than reporting that it is unset — so an install could show Legacy on, Auto-Approve Withdrawals off while withdrawals were, in fact, being auto-approved. The switch bearing the plain name was the one telling you the wrong thing.
cd backend
node scripts/migrate-withdraw-auto-approve.mjs # report
node scripts/migrate-withdraw-auto-approve.mjs --apply # writeIt copies whatever your platform is already doing into the visible switch, so it never changes withdrawal behaviour — it only makes the panel agree with the route. Restart the backend afterwards so the settings cache reloads. Running it twice is a no-op.
If you cannot run it, open Wallet → Security, set Auto-Approve Withdrawals to the position you actually want, and save. That writes both rows.
With withdrawAutoApprove on, a spot withdrawal is marked PROCESSING and
handed to the exchange the moment the customer submits it. There is no queue,
no review and no undo — an on-chain send is irreversible. A compromised account
drains immediately.
If you turn it on, turn on withdrawTwoFactorChallenge as well. Between them,
that is the difference between "a stolen session can take everything" and "a
stolen session needs the phone too".
Auto-approval applies to spot withdrawals only. Fiat withdrawals are always
created PENDING, and on a stock install they then wait for an operator,
because someone has to actually make the bank transfer.
That is not unconditional any more. As soon as the debit commits, the route
checks the withdrawal method for a payout-gateway binding (gatewayAlias). If
that gateway row exists, is enabled, and has auto-dispatch on, the
payout is handed straight to the provider with no admin step — TransFi is the
only provider with an adapter today. With auto-dispatch off (the default on
every gateway) the withdrawal is tagged as awaiting approval and an admin
dispatches it from the withdrawal queue, and a method with no binding at all —
which is all of them by default — stays fully manual. So "fiat always waits for
a human" holds only until you enable a payout gateway with auto-dispatch, and
withdrawAutoApprove has nothing to do with that decision.
Ecosystem withdrawals are handed to the ecosystem's own on-chain withdrawal queue and are never settled from the admin panel at all.
Two-factor: enrollment versus step-up
The five 2FA keys split into two decisions and a list.
withdrawTwoFactorRequired is enrollment. The customer must hold a 2FA
method that is enabled and is one of the accepted types. It is a property of
the account, checked on every withdrawal. A customer with no 2FA, or with a
disabled enrollment, is refused — the second sentence names your accepted list,
so with authenticator app the only accepted type it reads:
Withdrawals require two-factor authentication. Enable authenticator app 2FA in your profile security settings to withdraw.
A customer who is enrolled, in a method you have not accepted, gets a different message that names both sides:
Withdrawals require authenticator app two-factor authentication. Your account uses email 2FA — switch method in your profile security settings to withdraw.
withdrawTwoFactorChallenge is step-up. Holding the factor is no longer
enough — the customer must prove they still have it, for this withdrawal.
They request a code, submit it, and the platform mints a short-lived token that
the withdrawal request must carry. This is what a hijacked session cannot do.
The two are independent. Challenge on its own still implies enrollment in practice, because a customer with no accepted method cannot obtain a code.
The three *Allowed keys are the accepted list. An unset key means yes —
an operator who turns the requirement on without narrowing the methods gets
"any method the platform offers", which is the sane default. The list is then
intersected with what the platform actually supports:
| Type | Also requires |
|---|---|
APP |
Two-Factor Authentication and Authenticator App 2FA on, under Security → Two-factor |
EMAIL |
Two-Factor Authentication and Email 2FA on |
SMS |
Two-Factor Authentication and SMS 2FA on, and an SMS provider actually configured for withdrawal OTPs |
The legacy NEXT_PUBLIC_2FA_APP_STATUS, NEXT_PUBLIC_2FA_EMAIL_STATUS and
NEXT_PUBLIC_2FA_SMS_STATUS environment variables are honoured as a fallback
for each type, because the login flows do the same. A type is available if
either source says so.
The two ways this fails open
The withdrawal 2FA policy reports itself inactive — and blocks nothing — in two cases:
- Two-factor authentication is off platform-wide.
twoFactorStatusis the master switch, and while it is off,withdrawTwoFactorRequiredandwithdrawTwoFactorChallengeare ignored entirely. - Every accepted type is unavailable. You narrowed the list to SMS and never configured an SMS provider; or you narrowed it to a type that is switched off under Security → Two-factor. The intersection is empty, so there is nothing a user could possibly enrol in.
In both cases withdrawals go through unchallenged. The reasoning is that a
requirement no user on the platform can meet is a misconfiguration, and
bricking every withdrawal for every customer is worse than the setting quietly
not applying — so it is logged loudly instead, under the WITHDRAW_2FA
module, with a message naming what to switch on.
Because of this, "I turned on Require 2FA to Withdraw" is not the same statement as "withdrawals require 2FA". The way to check is the customer-facing policy endpoint, which reports the effective policy including the accepted types after the intersection:
If that returns no accepted types, your policy is not being enforced.
Once at least one accepted type is live, enforcement is strict. There is no partial mode.
The four doors
One function, assertWithdrawTwoFactor, is called before any balance is
touched, from exactly four places:
| Door | Route |
|---|---|
| Spot withdraw | POST /api/finance/withdraw/spot |
| Fiat withdraw | POST /api/finance/withdraw/fiat |
| Ecosystem withdraw | POST /api/ecosystem/withdraw |
| TransFi offramp | POST /api/finance/ramp/transfi/offramp |
All four pass the customer's step-up token through under the same field name, so a policy change applies to all four at once. If you add a money-out path through an addon, it needs this call or it is a hole in the policy.
The step-up token
When withdrawTwoFactorChallenge is on, the flow is:
-
The customer asks for a code —
POST /api/finance/withdraw/verification. It is delivered by whichever method they have enrolled. -
They submit it —
POST /api/finance/withdraw/verification/verify. A wrong code falls through to their recovery codes, and consumes one if it matches. -
On success the platform mints a single-use token valid for 10 minutes and returns it.
-
The withdrawal request carries that token. The door verifies it, burns it, and proceeds.
Three properties are worth knowing:
- Single use, burned atomically. The token's identifier is held in Redis and
consumption is a
DELthat must return 1. Redis is single-threaded, so of two requests racing on the same captured token, exactly one wins. A captured token cannot fund a second withdrawal. - It fails closed. If Redis is unreachable, the burn cannot be proved and the withdrawal is refused, not approved. This is the deliberate opposite of the policy resolution above: an unsatisfiable configuration fails open, an unverifiable token fails closed.
- The token is domain-scoped. It carries a
withdrawpurpose claim and lives in its own Redis namespace, so a code verified for a P2P escrow release can never authorise a withdrawal, and vice versa.
Attempts are limited per user, on a Redis counter keyed
withdraw-2fa-attempts:<user id>: 5 attempts in a 10-minute window, then a
429. It sits on top of the ordinary per-route rate limiter, which is keyed per
IP — rotating IPs does not buy extra guesses against one account's codes. A
successful verification clears the counter.
Saving these settings
All seven keys are in backend/src/utils/protected-settings.ts, the
Super-Admin-only list.
An admin who is not a Super Admin and changes any one of these gets a 403 for the whole save — none of the other changes on that page are written either. The message names the key.
What makes this survivable is that unchanged keys are dropped before the check runs. The settings PUT only submits keys whose value actually differs from what is stored, so a non-Super-Admin can save the rest of the Security or Wallet tab freely; only touching one of the seven trips the gate.
Key matching is case-insensitive, so renaming a key to WithdrawApproval does
not get around it.
If you need a second person able to change withdrawal policy, they need the Super Admin role — which bypasses every other permission check on the platform as well. See Roles and permissions before you hand it out.
Recommended starting position
For an install taking real customer money:
| Setting | Position |
|---|---|
Two-Factor Authentication (twoFactorStatus) |
On — nothing below works without it |
| Authenticator App 2FA | On |
| Auto-Approve Withdrawals | Off. Work the queue |
| Require 2FA to Withdraw | On |
| Verify 2FA on Every Withdrawal | On |
| Accept Authenticator App | On |
| Accept Email 2FA | Off, unless you accept that a compromised inbox is a compromised withdrawal |
| Accept SMS 2FA | Off unless you have an SMS provider configured and tested |
Then confirm it took: open the effective-policy endpoint as an ordinary customer account and check that it names the methods you expect. A policy that reports no accepted types is not protecting anything.
Related
- Working the withdrawal queue — what happens after a withdrawal is created, and what Approve actually does per wallet type.
- Settings reference — every setting on the same screens, in one table.
- Environment variables — the
NEXT_PUBLIC_2FA_*fallbacks and the SMS provider configuration.