Skip to content

What to store

To check a code you must rebuild the same object the user's application holds. Which means your database has to remember not only the secret, but every parameter that went into it. There are two ways to do that, and the easy one has a cost you pay later.

The columns approach

Store the secret, and keep the parameters in your code:

use OTPHP\TOTP;

$totp = TOTP::createFromSecret($user->getOtpSecret(), $clock)
    ->withDigest('sha1')
    ->withDigits(6)
    ->withPeriod(30);

$totp->verify($code);

This works, for exactly as long as those three values never change. The day you decide that six digits should become eight, or that sha1 should become sha256, or that thirty seconds should become twenty, every existing account stops authenticating — their applications keep producing codes for the old configuration, and your server now computes the new one. Custom parameters set at enrolment are lost the same way.

If you take this route, store the parameters as columns rather than constants, and read them back per account. That is the whole of the problem: the parameters belong to the enrolment, not to the current version of your code.

The provisioning URI approach

Store the provisioning URI itself. It holds the secret and every parameter, custom ones included, in one text column:

use OTPHP\Factory;

$otp = Factory::loadFromProvisioningUri($user->getProvisioningUri(), $clock);

$otp->verify($code);

A change of policy now affects only the accounts enrolled after it. Old URIs keep working, and you can re-provision them at your own pace — the next time each user signs in, for instance.

This is the approach to take.

For HOTP, write it back

A successful verify() advances the counter, and the URI you stored still carries the old one. Save the new URI after every successful verification, or the counter never moves and the replay protection is not there:

if ($otp->verify($code, null, 5)) {
    $user->setProvisioningUri($otp->getProvisioningUri());
    $entityManager->flush();
}

A TOTP has no such state and needs no write.

Either way

Encrypt the column. Secret or URI, it is a credential that yields valid codes to anyone who reads it, and unlike a password hash it cannot be made useless by a one-way function. Encrypt at rest, with a key that does not live in the same database.

Keep a little state of your own, which no column above gives you:

What Why
The last accepted timecode, or code So a TOTP cannot be replayed within its period
A failure counter, per account So six digits are not brute-forced at leisure
Whether enrolment was confirmed So a QR code nobody scanned does not lock the account
When the factor was added For the security page, and for support

Security recommendations covers each of these.