← Back to blog

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:

EndpointUsed by
get-2fa-code-by-emailthe customer-facing page
get-2fa-code-by-idinternal 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.

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