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.

10 min readUpdated 6 August 20262fa, security, escrow, release, rollout

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

  1. Confirm the preconditions above. Platform 2FA on, at least one method available, and you are signed in as Super Admin.

  2. 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.
  3. 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.

  4. Watch the refusals. Backend log, module P2P_2FA. A trade blocked for a missing factor logs Trade blocked: two-factor authentication not enabled. When those stop arriving, your seller base has enrolled.

  5. 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:

  • twoFactorStatus is 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:

  1. POST /api/p2p/verification delivers a one-time code over the seller's enrolled channel. Authenticator-app users receive nothing — their app is already generating codes — and the response says delivered: false. Rate limited to 10 per 15 minutes per user.
  2. POST /api/p2p/verification/verify checks 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.
  3. The trade room sends that token as twoFactorToken on POST /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 in PAYMENT_SENT and 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.