TOTP in PHP without a library

Authenticator apps use a small standard, TOTP. A few lines of PHP can make and check the codes, and the RFC test values show that they are right.

On this page

What an authenticator app does

When you turn on two step sign in, a site shows you a QR code. Your authenticator app reads it and stores a secret. After that, the app shows a new six digit code every 30 seconds. The site holds the same secret, so it can make the same code and compare.

Two standards describe it. RFC 4226 (opens in a new tab) defines HOTP, a code made from a counter. RFC 6238 (opens in a new tab) defines TOTP, a code made from the time. TOTP is HOTP with the clock as the counter. PHP has all the parts in its core, so you do not need a library.

HOTP: a code from a counter

HOTP takes a secret and a counter. It follows the steps of RFC 4226, section 5.3 (opens in a new tab):

  1. Write the counter as 8 bytes, big endian.
  2. Compute HMAC-SHA-1 of those bytes, with the secret as the key. The result has 20 bytes.
  3. Use the last 4 bits of the result to choose a place, and read 4 bytes from there. The RFC calls this dynamic truncation. Clear the top bit, so the number is never negative.
  4. Take the number modulo 10 to the power of the digits. Add zeros on the left if you need them.

This file does it in a few lines. totp() at the end turns the time into a counter: the number of whole 30 second steps since 1 January 1970.

otp.php
<?php
// HOTP (RFC 4226) and TOTP (RFC 6238) in plain PHP. Other files in this folder load it.

/** The one-time password for a counter value. */
function hotp(string $secret, int $counter, int $digits = 6): string
{
    $message = pack('J', $counter); // 8 bytes, big endian
    $hash = hash_hmac('sha1', $message, $secret, true); // 20 raw bytes

    // Dynamic truncation: the last 4 bits pick where to read 4 bytes.
    $offset = ord($hash[19]) & 0x0F;
    $number = ((ord($hash[$offset]) & 0x7F) << 24)
        | (ord($hash[$offset + 1]) << 16)
        | (ord($hash[$offset + 2]) << 8)
        | ord($hash[$offset + 3]);

    return str_pad((string) ($number % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
}

/** The time step that a moment falls in. */
function timeStep(int $unixTime, int $period = 30): int
{
    return intdiv($unixTime, $period);
}

/** The one-time password for a moment. */
function totp(string $secret, int $unixTime, int $digits = 6, int $period = 30): string
{
    return hotp($secret, timeStep($unixTime, $period), $digits);
}

The RFC recommends a time step of 30 seconds. In section 5.2, it calls this value a balance between security and usability.

Prove that it works

A one time password is easy to get wrong, and a wrong code looks like a right one. Both RFCs publish test values for one secret, the ASCII text 12345678901234567890. This program compares my functions with those values.

check-vectors.php
<?php
// Checks hotp() and totp() against the test values in RFC 4226 (Appendix D) and RFC 6238 (Appendix B).
// Run with: php check-vectors.php

require __DIR__ . '/otp.php';

$secret = '12345678901234567890'; // the test secret of both RFCs

$hotpExpected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];
$hotpOk = 0;
foreach ($hotpExpected as $counter => $expected) {
    if (hotp($secret, $counter) === $expected) {
        $hotpOk++;
    }
}
echo "HOTP values that match RFC 4226: $hotpOk of ", count($hotpExpected), PHP_EOL;

$totpExpected = [
    59 => '94287082',
    1111111109 => '07081804',
    1111111111 => '14050471',
    1234567890 => '89005924',
    2000000000 => '69279037',
    20000000000 => '65353130',
];
$totpOk = 0;
foreach ($totpExpected as $time => $expected) {
    $got = totp($secret, $time, 8);
    if ($got === $expected) {
        $totpOk++;
    }
    echo str_pad((string) $time, 12), $got, $got === $expected ? '  ok' : '  WRONG', PHP_EOL;
}
echo "TOTP values that match RFC 6238 (SHA-1): $totpOk of ", count($totpExpected), PHP_EOL;
Output
HOTP values that match RFC 4226: 10 of 10
59          94287082  ok
1111111109  07081804  ok
1111111111  14050471  ok
1234567890  89005924  ok
2000000000  69279037  ok
20000000000 65353130  ok
TOTP values that match RFC 6238 (SHA-1): 6 of 6

All values match. The TOTP table in RFC 6238 uses 8 digits, so the program asks for 8 digits there. Apps normally show 6.

The secret and Base32

RFC 4226 says the secret must have at least 128 bits and recommends 160 bits. In PHP, random_bytes(20) gives you 160 bits from a secure source.

Authenticator apps read the secret as Base32 text, a form from RFC 4648 (opens in a new tab) that uses the letters A to Z and the digits 2 to 7. PHP has no built-in function for it, so here are two small ones.

base32.php
<?php
// Base32 (RFC 4648) is the form of the secret that authenticator apps read. Other files in this folder load it.

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

function base32Encode(string $bytes): string
{
    $bits = '';
    foreach (str_split($bytes) as $char) {
        $bits .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
    }
    $out = '';
    foreach (str_split($bits, 5) as $chunk) {
        $out .= ALPHABET[bindec(str_pad($chunk, 5, '0', STR_PAD_RIGHT))];
    }
    return $out; // no padding characters
}

function base32Decode(string $text): string
{
    $text = strtoupper(str_replace([' ', '-', '='], '', $text));
    if ($text === '' || !preg_match('/^[A-Z2-7]+$/', $text)) {
        return '';
    }
    $bits = '';
    foreach (str_split($text) as $char) {
        $bits .= str_pad(decbin((int) strpos(ALPHABET, $char)), 5, '0', STR_PAD_LEFT);
    }
    $out = '';
    foreach (str_split($bits, 8) as $byte) {
        if (strlen($byte) === 8) { // drop the last bits that do not fill a byte
            $out .= chr(bindec($byte));
        }
    }
    return $out;
}

This program tests them with the value from RFC 4648, then makes a new secret.

base32-check.php
<?php
// Tries the Base32 functions on the test value of RFC 4648 and on a new secret.
// Run with: php base32-check.php

require __DIR__ . '/base32.php';

echo 'RFC 4648 test value: ', base32Encode('foobar'), PHP_EOL;
echo 'Back again: ', base32Decode('MZXW6YTBOI'), PHP_EOL;

$secret = random_bytes(20); // 160 bits, the size that RFC 4226 recommends
echo 'New secret: ', base32Encode($secret), ' (', strlen(base32Encode($secret)), ' characters)', PHP_EOL;
echo 'Round trip works: ', base32Decode(base32Encode($secret)) === $secret ? 'yes' : 'no', PHP_EOL;
Output
RFC 4648 test value: MZXW6YTBOI
Back again: foobar
New secret: 66LMZIRBXL44BGWQHNW26BRWJYXUXZ4W (32 characters)
Round trip works: yes

The secret on the last two lines changes on every run.

The address behind the QR code

A QR code is an image of text. For authenticator apps, the text is an address that starts with otpauth://. Google describes the format on its Key URI Format (opens in a new tab) page. The address holds the secret and names the account and the site.

uri.php
<?php
// The address that goes into the QR code, so an authenticator app can add the account.
// Run with: php uri.php

require __DIR__ . '/base32.php';

function provisioningUri(string $issuer, string $account, string $base32Secret): string
{
    $label = rawurlencode($issuer) . ':' . rawurlencode($account);
    $query = http_build_query([
        'secret' => $base32Secret,
        'issuer' => $issuer,
        'algorithm' => 'SHA1',
        'digits' => 6,
        'period' => 30,
    ], '', '&', PHP_QUERY_RFC3986);
    return 'otpauth://totp/' . $label . '?' . $query;
}

echo provisioningUri('Example Site', '[email protected]', base32Encode('12345678901234567890')), PHP_EOL;
Output
otpauth://totp/Example%20Site:reader%40example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example%20Site&algorithm=SHA1&digits=6&period=30

The page says that Google Authenticator ignores the algorithm and period parameters, and that the Android app ignores digits. So I keep the defaults: SHA-1, 6 digits and 30 seconds. Other values might work in one app and fail in another.

To draw the QR code, you need code that turns text into a picture. Many small libraries do that. It is a separate job from the one in this post.

Check a code

Now the server side. A person types six digits, and the server must decide if they are right. Four rules apply.

  • Allow a small clock difference. The phone and the server do not show exactly the same time. RFC 6238 recommends that you allow at most one time step as the network delay. So you check the current step, the one before it and the one after it.
  • Accept each code once. RFC 6238 says the verifier must not accept a second attempt with the same code after a successful check. Remember the last step that a person used, and refuse every step up to it.
  • Compare in constant time. hash_equals() takes the same time for a right and a wrong code. A plain === can stop at the first wrong character.
  • Limit the attempts. A six digit code has one million values. RFC 4226 recommends a throttling or lockout scheme on the server side. Stop after a few wrong codes and make the person wait.

The program below does the first three rules. It checks all three steps even after it finds a match, so the running time does not show which step matched.

verify.php
<?php
// Checks a code from a person. It allows one step of clock difference and accepts each step once.
// Run with: php verify.php

require __DIR__ . '/otp.php';

/**
 * @return int|null The time step that matched, or null when the code is wrong or was used before.
 */
function verifyTotp(string $secret, string $code, int $now, int $lastUsedStep, int $window = 1): ?int
{
    if (!preg_match('/^\d{6}$/', $code)) {
        return null;
    }
    $current = timeStep($now);
    $found = null;
    for ($step = $current - $window; $step <= $current + $window; $step++) {
        // Check every step, so the time does not tell which one matched.
        $match = hash_equals(hotp($secret, $step), $code);
        if ($match && $step > $lastUsedStep && $found === null) {
            $found = $step;
        }
    }
    return $found;
}

$secret = '12345678901234567890';
$now = 1700000010;
$last = 0; // the last step that this person used

$show = static function (string $name, ?int $step): void {
    echo str_pad($name, 28), $step === null ? 'refused' : "accepted (step $step)", PHP_EOL;
};

$code = totp($secret, $now);
$step = verifyTotp($secret, $code, $now, $last);
$show('code of this moment', $step);
$last = $step ?? $last;

$show('the same code again', verifyTotp($secret, $code, $now, $last));
$show('code from 30 seconds later', verifyTotp($secret, totp($secret, $now + 30), $now, $last));
$show('code from 60 seconds later', verifyTotp($secret, totp($secret, $now + 60), $now, $last));
$show('code from 90 seconds ago', verifyTotp($secret, totp($secret, $now - 90), $now, 0));
$show('letters instead of digits', verifyTotp($secret, 'abcdef', $now, $last));
Output
code of this moment         accepted (step 56666667)
the same code again         refused
code from 30 seconds later  accepted (step 56666668)
code from 60 seconds later  refused
code from 90 seconds ago    refused
letters instead of digits   refused

The same code is refused the second time. A code from the next step is accepted, because a clock can run fast by one step. A code two steps away is refused. The last line shows that the program refuses input that is not six digits before it does any work.

The fourth rule, the limit on attempts, needs a place to count wrong codes. That depends on your site, so the example leaves it out. Do not skip it in a real system.

What I would add

The functions above are the core. A real system needs more around them. Ask for a first code before you turn the feature on, so you know the app holds the right secret. Then show the person a set of recovery codes, so a lost phone does not lock them out for good.

  • Keep the secret encrypted in the database. A stolen database should not give an attacker working codes.
  • Keep the count of wrong codes per account, not per session, so a new session does not reset it.

Read more

More posts