SEC-004
Two-factor authentication in plain PHP, no library
TOTP is HMAC-SHA1 over a time counter, truncated to six digits. About forty lines, and it works with every authenticator app.
Time-based one-time passwords look like something that needs a package. They do not. The algorithm is a counter of thirty second intervals since the epoch, an HMAC-SHA1 of that counter with a shared secret, and a documented way of pulling six digits out of the result.
The secret is shared with the user's app as base32, usually through an otpauth URI in a QR code. The app derives the same code from the same clock, so nothing travels between you after setup.
Accept one interval either side of now. Phone clocks drift, and a user whose device is twenty seconds fast will otherwise be permanently locked out with no way to diagnose it.
function base32_decode(string $b32): string
{
$map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$b32 = rtrim(strtoupper($b32), '=');
$bits = '';
for ($i = 0, $n = strlen($b32); $i < $n; $i++) {
$pos = strpos($map, $b32[$i]);
if ($pos === false) {
continue;
}
$bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$out .= chr(bindec($byte));
}
}
return $out;
}
function totp_code(string $secretBase32, ?int $counter = null): string
{
$counter = $counter ?? (int) floor(time() / 30);
$binCounter = pack('N*', 0, $counter); // 64-bit big endian
$hash = hash_hmac('sha1', $binCounter, base32_decode($secretBase32), true);
$offset = ord($hash[19]) & 0x0F; // dynamic truncation
$value = ((ord($hash[$offset]) & 0x7F) << 24)
| ((ord($hash[$offset + 1]) & 0xFF) << 16)
| ((ord($hash[$offset + 2]) & 0xFF) << 8)
| (ord($hash[$offset + 3]) & 0xFF);
return str_pad((string) ($value % 1000000), 6, '0', STR_PAD_LEFT);
}
function totp_verify(string $secretBase32, string $entered, int $window = 1): bool
{
$entered = preg_replace('~\D~', '', $entered) ?? '';
if (strlen($entered) !== 6) {
return false;
}
$now = (int) floor(time() / 30);
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(totp_code($secretBase32, $now + $i), $entered)) {
return true;
}
}
return false;
}
Using it
Hand the user an otpauth URI to scan:
otpauth://totp/YourSite:username?secret=BASE32SECRET&issuer=YourSite
Store the secret encrypted at rest if you can, and record the last accepted counter per user so a code cannot be replayed inside its own thirty second window.
Issue recovery codes at setup. Without them, a lost phone means a manual identity check.
What bites people
A clock that is wrong on the server breaks every code at once and looks like a code bug. Check the server time first when nothing verifies.
The window is a trade: each step of window is thirty more seconds during which an intercepted code still works. One is the normal answer.