Serving rotating 2FA codes to a shop without leaking the secret
- Published on
- • 5 mins read•--- views
Some businesses fulfil orders on accounts that are protected by two-factor authentication. Digital goods resale is the obvious one, but the shape is general: an operator has to sign in to something on a customer's behalf, and the sign-in demands a code that is valid for thirty seconds.
Done manually, this is a person reading digits off a phone and pasting them into a chat. It fails in the predictable ways: the code expires mid-handover, two operators work the same account at once, and nobody can serve a customer at three in the morning.
The instinct is to store the TOTP secrets in the shop database and generate codes on demand. Do not. Here is a better decomposition.
The secret belongs in exactly one place
A TOTP secret is a permanent credential: whoever holds it can generate valid codes forever, without any further access. That makes it categorically different from a session or a token, which expire and can be revoked.
The moment you copy that secret into the shop database, you have given it to everything that can read the shop database: a plugin, a backup, a staging clone, an SQL injection in unrelated code, a contractor with a database dump. All of the ordinary WordPress risks now escalate into permanent account compromise.
So the plugin holds no secrets and no generation logic. It is a client of a dedicated service:
class CustomTwoFaService extends TwoFaService
{
public function __construct()
{
$endpoint = get_option('tfa_endpoint');
$token = get_option('tfa_token');
if (empty($endpoint) || empty($token)) {
throw new Exception('2FA service endpoint or token is not set');
}
parent::__construct($endpoint, $token);
}
}
Two options and a thrown exception. The shop knows an address and a token; the secrets stay behind the service. Rotating the backing service becomes a settings change, and a compromised shop database yields codes valid for thirty seconds rather than accounts compromised forever.
Pair by QR, not by typing
Registering an account means transferring a secret, and asking a customer to retype a base32 string is how you get support tickets. Read the pairing QR code instead, in the browser, with jsQR, from either the camera or an uploaded screenshot:
POST /wp-json/2fa/v1/auth-user-by-qr (multipart: qr)
→ decode → register with the service → { account_id, email, code }
The uploaded image is deleted immediately after processing. It contains the secret, so it must not survive the request. A QR image left in an uploads directory is the same leak as storing the secret, with the added charm of being publicly served by the web server.
Two lookups, one source
Codes are exposed by two endpoints, one by account address and one by internal account id:
| Endpoint | Used by |
|---|---|
get-2fa-code-by-email | the customer-facing page |
get-2fa-code-by-id | internal tooling |
Both read from the same service. That is the point: the alternative is a copy of the pairing data in the internal tool, which is a second place to leak from and a second place to go stale.
Gate the public endpoint, and know what the gate is worth
A code lookup keyed by an email address is a lookup anyone can attempt. The forms render behind a bot check, which stops casual scraping.
Be clear-eyed about what that buys. A bot check in front of a form does not protect an endpoint, which is still callable directly. If the endpoint must be public, the honest options are rate limiting per address, requiring an authenticated session for the by-address lookup, or accepting that anyone who knows an account address can read its current code and designing the rest of the system around that.
Verify at payment, not at fulfilment
The subtler win has nothing to do with codes. When an order reaches processing, check that a code can actually be produced for the account on it:
if ($new_status !== 'processing') return;
if ($order->get_meta('_billing_generate_psn') == 'yes') return; // shop supplies the account
$psn_id = $order->get_meta('_billing_psn_id');
if (empty($psn_id)) throw new Exception('PSN ID is empty');
$code = $twoFaService->get2faCodeByEmail($psn_id);
if (empty($code['password'])) throw new Exception('2FA password is empty');
Without this, an unfulfillable order looks exactly like a fulfillable one until an operator picks it up, which is after the customer has been charged. With it, the failure is written onto the order as a note the moment payment lands.
Note what the check does not do: it does not halt the order. Annotating rather than blocking is the right default while you are still learning the false-positive rate of a new check. Blocking orders on a check that is wrong five percent of the time costs more than the problem it solves.
The general rule
A permanent credential should live in one system, and everything else should ask that system for short-lived answers. The moment a secret is copied for convenience, every consumer's security becomes the security of the whole. What you want to spread around is the answer, valid for thirty seconds, rather than the thing that produces it.
Related
Projects

CactusPlay - Game shop run by a vetted contractor network
Before: CIS buyers compared PlayStation and Xbox prices by hand across scattered sellers, and every order had to be filled by the founders themselves. After: a shop with a live catalogue and a CRM any vetted contractor with Turkish payment details could join to take orders and earn on them. Result: 20,000+ positions grabbed hourly, price changes reaching the shop and Yandex.Market untouched, and fulfilment no longer capped by one pair of hands.
Posts
Make delivery a consequence of payment, not a task someone performs
Digital goods delivered by hand are limited by working hours and protected only by attention. Moving delivery onto order events changes what the system guarantees, and the API is what stops a second channel becoming a second implementation.
Pricing a catalogue against a store you do not control
Reselling means your prices depend on someone else changing theirs. Fetching on page load takes your shop down with theirs; a nightly export goes stale. The middle path is a cache with a pricing strategy behind it.
A support widget that already knows who it is talking to
Pasting a chat widget into a theme template works until the theme updates, the settings need changing, or the widget adds a database query to every page. Three small decisions fix all of it.
Open for contract collaboration
I am available for contract-based collaboration. If you have an interesting project idea, schedule a call via Calendly.
Schedule a 30-min call