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 namespace App\Http\Controllers;
Streaming a filtered CSV export in Laravel
streaming
csv-export
query-builder
Intermediate
9 steps
php
<?php namespace App\Http\Middleware;
Idempotent requests with Laravel middleware
idempotency
middleware
caching
Advanced
8 steps
php
<?php namespace App\Services;
Batch image resizing with PHP GD
image-processing
constructor-promotion
match-expression
Intermediate
10 steps
php
<?php namespace App\Cache;
A content-hashed fragment cache in PHP
caching
content-hashing
atomic-writes
Intermediate
10 steps
python
import base64 import hashlib import hmac import json
Building signed expiring tokens with HMAC
hmac
authentication
base64
Intermediate
7 steps
php
<?php namespace App\View\Composers;
How a Laravel view composer feeds the navigation
view-composer
caching
dependency-injection
Intermediate
5 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.