Turning on two-factor for sellers and escrow release
Rolling out the two P2P two-factor switches in the right order, what each one gates, the three per-method keys no screen carries, and how to rescue a seller who cannot answer the challenge.
P2P ships two two-factor controls, both off by default. They live at
/admin/p2p/settings on the Platform tab, under Security:
| Setting key | Label on the screen | What it gates |
|---|---|---|
p2pTwoFactorRequired |
Require 2FA to Sell | The seller of a trade must already have an accepted second factor enabled before the trade can start |
p2pTwoFactorChallenge |
Require a 2FA Code to Release Crypto | Every escrow release must carry a freshly verified one-time code |
Turning them on is not a toggle, it is a rollout. The order matters, and the wrong order strands sellers who are already holding a buyer's paid escrow.
p2pTwoFactorChallenge demands a code at Release. p2pTwoFactorRequired is
the only thing that guarantees the person who will have to answer that
challenge actually has a second factor.
Arm the challenge alone and every seller who never enrolled reaches Release — already holding a buyer's payment — and cannot proceed. Nothing is lost: the trade auto-disputes after 24 hours and an admin settles it. But each one costs somebody's time and a support conversation, and you will get one per seller, all at once.
Before you start
Three preconditions, all of them things that make the P2P switches silently do nothing if they are wrong.
You need a Super Admin account. Both keys are in PROTECTED_SETTING_KEYS
on the platform settings endpoint (PUT /api/admin/system/settings), the same
class as withdrawTwoFactorRequired and withdrawTwoFactorChallenge. An admin
with edit.settings but not the Super Admin role gets a 403 naming the key. The
P2P settings screen writes through that endpoint, so the refusal happens
regardless of which page you are standing on.
Platform 2FA must be on. Admin → System → Settings → Security → Two-Factor Authentication, the twoFactorStatus switch. With it off, the P2P
switches are not even drawn on the settings screen — and if the rows are already
"true" from an earlier configuration, the policy engine ignores them
rather than blocking everybody. See When the policy silently does not
apply.
At least one method must be deliverable. Under the same heading:
twoFactorAppStatus, twoFactorEmailStatus, twoFactorSmsStatus. SMS
additionally requires a configured SMS provider — the engine probes
deliverability for the P2P_OTP message kind, so an install with the SMS switch
on and no provider does not count SMS as available.
The rollout
-
Confirm the preconditions above. Platform 2FA on, at least one method available, and you are signed in as Super Admin.
-
Turn on Require 2FA to Sell —
/admin/p2p/settings→ Platform → Security. Save. From this moment:- Creating a SELL offer is refused for a maker with no accepted factor, at the offer form, before any collateral is locked.
- Starting a trade is refused when the resolved seller has no accepted factor.
- Trades already running are unaffected. Existing SELL offers stay on the board — the gate is at offer creation, not at publication.
-
Tell your sellers, and give them time. Anyone who sells on your board and has not enrolled will hit a 403 the next time they post a SELL offer or the next time somebody takes their BUY offer. The refusal message names the accepted methods and points at profile security settings.
-
Watch the refusals. Backend log, module
P2P_2FA. A trade blocked for a missing factor logsTrade blocked: two-factor authentication not enabled. When those stop arriving, your seller base has enrolled. -
Turn on Require a 2FA Code to Release Crypto. The control only appears once step 2 is on. From this moment every release must carry a verified step-up token.
What each switch actually checks
| Door | Reads | Refuses when |
|---|---|---|
POST /api/p2p/offer (SELL offers only) |
p2pTwoFactorRequired |
the maker has no enabled, accepted factor |
POST /api/p2p/offer/{id}/initiate-trade |
p2pTwoFactorRequired |
the resolved seller has no enabled, accepted factor |
POST /api/p2p/trade/{id}/release |
p2pTwoFactorChallenge |
the seller has no accepted factor, or no valid step-up token was sent |
Drafts are deliberately above the offer gate — a draft trades against nobody, so there is nothing yet to be unable to release. BUY offers are not gated at creation either, because their maker is the buyer.
Which party is gated, and why it is checked at trade start
Releasing escrow is a seller-only action. But which party is the seller depends on the offer:
| Offer type | The maker is | The taker is |
|---|---|---|
| BUY offer | the buyer | the seller |
| SELL offer | the seller | the buyer |
So gating whoever calls initiate-trade would not work: on a SELL offer the
caller is the buyer, and the seller is a maker who is not in the request at all.
The enrollment check is therefore asserted on the resolved sellerId, whichever
side that turns out to be, and it runs after the offer type is known rather than
at the top of the handler.
The refusal wording changes with who is blocked. When the blocked party is the caller they get an actionable instruction naming the accepted methods; when it is the counterparty they get "This offer is not currently available for trading. Please choose another offer." A stranger is not told which second factors somebody else's account holds.
Why release does not enforce enrollment
assertP2PReleaseTwoFactor returns immediately unless p2pTwoFactorChallenge
is on. It deliberately ignores p2pTwoFactorRequired, unlike the withdrawal
door, which blocks on either control.
A release refused purely for missing enrollment would demand no code — so it stops nobody holding a stolen session, while stranding a seller who is already holding a buyer's paid escrow. That is a failure mode with a cost and no benefit. The enrollment control does its work before anyone's money is committed.
The three keys with no control on any screen
The policy engine reads five keys. Only two of them have a control:
| Key | Control exists | Unset behaviour |
|---|---|---|
p2pTwoFactorRequired |
yes | off |
p2pTwoFactorChallenge |
yes | off |
p2pTwoFactorAppAllowed |
no | accepted |
p2pTwoFactorEmailAllowed |
no | accepted |
p2pTwoFactorSmsAllowed |
no | accepted |
The engine treats a missing row as yes, so out of the box the requirement is satisfied by whichever method the user has enabled, intersected with what the platform offers. That is deliberate: it is what keeps a blocked seller able to unblock themselves with any method your install supports.
If you need to narrow this — accept the authenticator app only, say — there is
no screen for it. The row has to be written directly, and the key is not in
PROTECTED_SETTING_KEYS, so a hand-written row is not privilege-checked either.
Narrow with care: setting all three to false makes the policy unsatisfiable,
and the engine responds by ignoring the requirement entirely rather than
blocking everyone.
When the policy silently does not apply
The engine fails open on a configuration nobody could satisfy. A requirement no user can meet is a misconfiguration, and stranding every seller's escrow is a worse outcome than the setting quietly not applying — so it logs loudly instead and enforces nothing.
Two conditions do this. Both write a warning under the P2P_2FA log module:
twoFactorStatusis off. "P2P 2FA is switched on but two-factor authentication is disabled platform-wide (twoFactorStatus). The P2P requirement is being IGNORED…"- None of the accepted methods is available on this install — every allowed type switched off platform-wide, or SMS-only with no SMS provider configured. "P2P 2FA is switched on but none of the accepted methods (…) is available platform-wide…"
If you turned the switches on and nothing changed, grep the backend log for
P2P_2FA before anything else.
The invariant is enforced at the write, not by hiding the control
The challenge control is only rendered while p2pTwoFactorRequired is on.
That is showIf, and showIf decides drawing and nothing else — the settings
page saves changed keys only.
So backing the feature out by switching the top control off would leave
p2pTwoFactorChallenge sitting at "true" in the settings table, still
enforced by the release door, with no control left on any screen to turn it off.
The release door would keep demanding codes from sellers the enrollment gate had
stopped vetting.
The P2P settings page closes that with an onBeforeSave hook that forces
p2pTwoFactorChallenge to false whenever p2pTwoFactorRequired is off. The
hook runs before the changed-keys comparison and receives the whole draft, so
the forced false genuinely differs from the stored "true" and is actually
sent. When the key is already off it matches and is correctly omitted.
Practically: switching Require 2FA to Sell off also switches the release challenge off, in the same save. That is intended. If you want the challenge back, turn both on again in the order above.
The platform-wide twoFactorStatus hides both controls too, but that state is
self-healing rather than a trap — the policy fails open while it is off, and
both controls reappear together when it comes back. Nothing is forced there,
because forcing would throw away your setting silently.
Domain isolation, and the Redis dependency
The P2P release policy is a second consumer of the shared step-up engine, and it carries its own identity:
| Property | Value |
|---|---|
JWT purpose claim |
p2p-release |
| Redis key prefix | p2p-2fa-step-up: |
| Log module | P2P_2FA |
| SMS message kind | P2P_OTP |
Both the purpose and the prefix are unique on purpose. The purpose is compared with a strict inequality at verification, so a token minted to approve a withdrawal cannot approve an escrow release, and vice versa. The prefix keeps the single-use burn namespaces apart.
The single-use guarantee is a Redis DEL returning 1. If Redis is unreachable
the error propagates and the release is refused, rather than approved on a
token nobody could verify.
With the challenge armed and Redis down, no seller can release. Escrow is untouched and the trades stay open, so nothing is lost — but the board stops settling until Redis is back. Weigh that before arming the challenge on an install where Redis is not monitored.
Checking what a seller is actually facing
GET /api/p2p/verification returns the effective policy for the calling
user — the intersection of the P2P keys, the platform master switch, per-type
availability, the legacy NEXT_PUBLIC_2FA_* env fallbacks and SMS provider
presence. This is the endpoint to reason from, not the raw settings rows.
| Field | Meaning |
|---|---|
requireEnrollment |
p2pTwoFactorRequired, after fail-open resolution |
requireChallenge |
p2pTwoFactorChallenge, after fail-open resolution |
acceptedTypes |
APP / EMAIL / SMS, already intersected with platform availability |
userType |
the factor this user has enrolled, or null |
userEnabled |
whether that factor is enabled |
satisfied |
whether this user can release right now |
satisfied answers the question the release door asks, not the enrollment
one. When the challenge is off there is nothing to satisfy, so it reports true
— a seller part-way through a trade that started before the requirement existed
is not told they are stuck when they are not.
The seller's challenge flow, and its limits
Worth knowing because the limits are where support tickets come from:
POST /api/p2p/verificationdelivers a one-time code over the seller's enrolled channel. Authenticator-app users receive nothing — their app is already generating codes — and the response saysdelivered: false. Rate limited to 10 per 15 minutes per user.POST /api/p2p/verification/verifychecks the code and mints a single-use step-up token. Recovery codes are accepted, the same way login accepts them, so a seller who lost their device is not left holding a buyer's paid escrow with no way out. Rate limited to 30 per 10 minutes, with an independent per-user OTP counter of 5 attempts per 10 minutes.- The trade room sends that token as
twoFactorTokenonPOST /api/p2p/trade/{id}/release. The token lives 10 minutes and is burned on use.
A release refused for a spent or expired token returns "Your two-factor verification has expired or was already used." The escrow is untouched and the trade stays open — the seller verifies again and releases.
When the challenge is armed and a release succeeds, a TRADE_2FA_VERIFIED row
is written to the P2P activity log at LOW risk, so the trail records that the
release was step-up verified. It is deliberately LOW: classifying a routine
passing challenge as HIGH would fire a security alert to every admin on every
trade. See The activity log.
When a seller genuinely cannot release
This is the rescue path, and it is the reason step 3 of the rollout exists.
A seller who cannot answer the challenge — no device, no recovery code, an
unavailable method — leaves the buyer's payment sitting in a PAYMENT_SENT
trade. That trade auto-disputes 24 hours after payment was confirmed, and
the case lands on /admin/p2p/dispute for an admin to settle. Nothing is lost
and no escrow is stranded, but it costs an operator's time and a ruling made on
someone else's evidence.
Two things you can do before it gets there:
- Settle it yourself from the trade case desk at
/admin/p2p/trade/<id>, which accepts a trade inPAYMENT_SENTand does not require a dispute. See Working a trade case. - Help the seller enrol. The block is on their profile security settings, not on anything in P2P — once they have an accepted factor enabled, Release works with no admin involvement.
Turning the challenge off to unblock one seller works, but it is a Super Admin edit that disarms the control for everyone, and turning it back on re-arms it for sellers who were mid-trade when you did. Prefer settling the individual trade.
Related
- P2P settings — the field reference for every key on the screen.
- The trade lifecycle and escrow — what Release does once the challenge is satisfied.
- Resolving a dispute — the door the 24-hour auto-dispute lands on.