php
57 lines · 7 steps
How TOTP codes are generated in PHP
Turn a shared base32 secret and the current time into the same six-digit code an authenticator app shows.
Explained by
highlit
1<?php
2
3namespace App\Support;
4
5final class Totp
6{
7 private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
8
9 public function __construct(
10 private readonly int $digits = 6,
11 private readonly int $period = 30,
12 private readonly string $algorithm = 'sha1',
13 ) {}
14
15 public function code(string $base32Secret, ?int $timestamp = null): string
16 {
17 $counter = intdiv($timestamp ?? time(), $this->period);
18 $binary = pack('N*', 0) . pack('N*', $counter);
19
20 $hash = hash_hmac($this->algorithm, $binary, $this->decodeBase32($base32Secret), true);
21 $offset = ord($hash[strlen($hash) - 1]) & 0x0F;
22
23 $truncated = (
24 ((ord($hash[$offset]) & 0x7F) << 24) |
25 ((ord($hash[$offset + 1]) & 0xFF) << 16) |
26 ((ord($hash[$offset + 2]) & 0xFF) << 8) |
27 (ord($hash[$offset + 3]) & 0xFF)
28 );
29
30 $code = $truncated % (10 ** $this->digits);
31
32 return str_pad((string) $code, $this->digits, '0', STR_PAD_LEFT);
33 }
34
35 private function decodeBase32(string $secret): string
36 {
37 $secret = strtoupper(rtrim($secret, '='));
38 $bits = '';
39
40 foreach (str_split($secret) as $char) {
41 $index = strpos(self::ALPHABET, $char);
42 if ($index === false) {
43 throw new \InvalidArgumentException("Invalid base32 character: {$char}");
44 }
45 $bits .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT);
46 }
47
48 $bytes = '';
49 foreach (str_split($bits, 8) as $chunk) {
50 if (strlen($chunk) === 8) {
51 $bytes .= chr(bindec($chunk));
52 }
53 }
54
55 return $bytes;
56 }
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1TOTP is just HMAC over a time counter, so client and server agree by sharing a secret and a clock.
- 2Dynamic truncation extracts a stable 31-bit integer from the hash using its own last nibble as an offset.
- 3Base32 decoding is a manual bit-buffer: accumulate 5 bits per character, then slice into 8-bit bytes.
Related explainers
php
<?php class NameParser {
Parsing a full name into components in PHP
string-parsing
arrays
normalization
Intermediate
8 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
php
<?php namespace App\Services;
How a password strength validator works in PHP
validation
regular-expressions
data-driven
Intermediate
8 steps
php
<?php namespace App\Services;
Building a cached daily leaderboard in Laravel
caching
aggregation
eager-loading
Intermediate
9 steps
python
import secrets from django.contrib.auth import authenticate, login from django.core.cache import cache
Two-factor login with OTP in Django
two-factor-auth
one-time-passwords
caching
Intermediate
9 steps
php
<?php final class RotatingFileLogger {
How a rotating file logger works in PHP
logging
file-rotation
io
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/how-totp-codes-are-generated-in-php-explained-php-f2b5/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.