OTP Code Generator — PHP source
Generate time-based one-time passwords (RFC 6238 TOTP) from a Base32 secret, with selectable algorithm, digit count, and period - updating live, entirely in your browser.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* otp-code-generator — TOTP (RFC 6238) / HOTP (RFC 4226) code generator.
*
* Language: PHP (8.1+, standard library only)
* Source: CosmoDev polyglot showcase port of the OTP Code Generator tool,
* ported from cli/otp-code-generator/otp-code-generator.go (the live
* Go CLI twin — the authoritative reference) and src/lib/otp.ts
* (canonical TypeScript, which wraps the `otpauth` dependency).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws (generate returns ?string, null on a
* bad secret/algorithm).
* - Functionally equivalent to the Go/TS reference: same inputs -> same outputs
* (both implement RFC 6238, so tokens agree by construction).
* - Self-contained: stdlib only (hash_hmac, no Composer packages).
*
* Implements RFC 4226 (HOTP: HMAC the 8-byte counter, dynamic-truncate, mod
* 10^digits) and RFC 6238 (TOTP: counter = floor(timestamp_ms / 1000 / period),
* then HOTP). otp_generate() mirrors Generate() in the Go twin (defaults SHA1, 6
* digits, 30-second period); otp_validate() accepts the current period and +/-1
* adjacent periods (matching otpauth's default window=1). Secrets are base32
* (RFC 4648); whitespace/case tolerated, '=' padding stripped — exactly like
* secretFrom()/decodeSecret() in the TS/Go. intdiv() is used for every RFC
* division so it matches Go's integer division exactly (all operands
* non-negative). PHP has no built-in base32 decoder, so otp_decode_secret()
* hand-rolls RFC 4648 — mirroring Go's base32.StdEncoding.WithPadding(NoPadding).
*/
declare(strict_types=1);
// HMAC algorithm names for hash_hmac. Mirrors newHasher() in the Go twin.
const OTP_HASHES = ['SHA1' => 'sha1', 'SHA256' => 'sha256', 'SHA512' => 'sha512'];
/**
* Normalize + base32-decode the secret. Twin of decodeSecret() in the Go: strip
* whitespace, uppercase, strip '=' padding, then base32-decode. Returns the raw
* key bytes, or null on any byte outside the base32 alphabet.
*/
function otp_decode_secret(string $secret): ?string
{
$s = strtoupper(preg_replace('/\s+/', '', $secret) ?? '');
$s = rtrim($s, '=');
if ($s === '') {
return '';
}
// Hand-rolled RFC 4648 base32 (NoPadding). PHP ships no base32 decoder.
$alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = 0;
$value = 0;
$out = '';
for ($i = 0, $n = strlen($s); $i < $n; $i++) {
$idx = strpos($alpha, $s[$i]);
if ($idx === false) {
return null;
}
$value = ($value << 5) | $idx;
$bits += 5;
if ($bits >= 8) {
$bits -= 8;
$out .= chr(($value >> $bits) & 0xFF);
}
}
return $out;
}
/**
* Apply the TS defaults (the `??` coalescing in src/lib/otp.ts config()). A zero
* digits/period and an empty algorithm fall back to the otpauth defaults — 6, 30,
* SHA1 — exactly like the Go twin's withDefaults().
*/
function otp_with_defaults(array $opts): array
{
return [
'secret' => $opts['secret'],
'algorithm' => strtoupper($opts['algorithm'] ?? 'SHA1'),
'digits' => $opts['digits'] ?? 6,
'period' => $opts['period'] ?? 30,
];
}
/**
* RFC 4226 section 5.4 dynamic truncation + mod 10^digits, zero-padded. Twin of
* the truncation tail of Generate() in the Go twin: mask the top bit of the
* 4-byte big-endian window (equiv. to Go's &0x7f on byte[offset]). Operates on
* the raw HMAC bytes ($digest is binary, from hash_hmac(..., true)).
*/
function otp_truncate(string $digest, int $digits): string
{
$offset = ord($digest[strlen($digest) - 1]) & 0x0F;
$bin = ((ord($digest[$offset]) & 0x7F) << 24)
| (ord($digest[$offset + 1]) << 16)
| (ord($digest[$offset + 2]) << 8)
| ord($digest[$offset + 3]);
return str_pad((string)($bin % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
}
/**
* RFC 4226 HOTP for opts['secret'] at the given 8-byte counter. Shared core:
* otp_generate() builds the counter from the timestamp then calls this. Returns
* null on a bad secret/algorithm. It is the PHP twin of the HOTP step inside
* Generate() in the Go twin.
*/
function otp_hotp(array $opts, int $counter): ?string
{
$o = otp_with_defaults($opts);
if (!isset(OTP_HASHES[$o['algorithm']])) {
return null;
}
$key = otp_decode_secret($o['secret']);
if ($key === null) {
return null;
}
// 8-byte big-endian counter — RFC 4226 section 5.2. pack 'J' is an unsigned
// 64-bit int big-endian (always available on 64-bit PHP, the modern norm).
$msg = pack('J', $counter);
$digest = hash_hmac(OTP_HASHES[$o['algorithm']], $msg, $key, true);
return otp_truncate($digest, $o['digits']);
}
/**
* TOTP (RFC 6238) for opts['secret'] at $timestamp_ms (milliseconds since the
* Unix epoch). The PHP twin of generateTotp() in src/lib/otp.ts / Generate() in
* the Go — defaults SHA1, 6 digits, 30-second period. Returns null on a bad
* secret/algorithm.
*/
function otp_generate(array $opts, int $timestamp_ms): ?string
{
$o = otp_with_defaults($opts);
// counter = floor(timestamp_ms / 1000 / period) — RFC 6238 section 4.2.
$counter = intdiv(intdiv($timestamp_ms, 1000), $o['period']);
return otp_hotp($opts, $counter);
}
/**
* Check $token against opts['secret'] at $timestamp_ms, accepting the current
* period and +/-1 adjacent periods (otpauth window=1). Mirrors validateTotp()
* in the TS / Validate() in the Go twin.
*/
function otp_validate(string $token, array $opts, int $timestamp_ms): bool
{
$o = otp_with_defaults($opts);
$period_ms = $o['period'] * 1000;
foreach ([$timestamp_ms, $timestamp_ms - $period_ms, $timestamp_ms + $period_ms] as $ts) {
// Negative/underflowing timestamps are non-physical for TOTP; clamp to 0
// (PHP int is signed — the Go twin casts the same value to uint64).
$ts = max(0, $ts);
$got = otp_hotp($opts, intdiv(intdiv($ts, 1000), $o['period']));
if ($got !== null && hash_equals($got, $token)) {
return true;
}
}
return false;
}
// Showcase vectors — run only when this file is executed directly (not when
// included as a library). The debug_backtrace() check is the idiomatic PHP "am I
// the entry script?" guard. Requires zend.assertions >= 0 (the default).
if (!debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)) {
// RFC = base32 of ASCII "12345678901234567890" — the RFC 6238/4226 key.
$RFC = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
assert(otp_hotp(['secret' => $RFC], 0) === '755224'); // RFC 4226 c=0
assert(otp_generate(['secret' => $RFC, 'digits' => 8], 59000) === '94287082'); // RFC 6238 T=59s
assert(otp_generate(['secret' => 'JBSWY3DPEHPK3PXP'], 1700000000000) === '324550'); // Go-twin lock-step
assert(otp_generate(['secret' => 'jbsw y3dp ehpk 3pxp'], 1700000000000) === '324550'); // spaces/lowercase
assert(otp_generate(['secret' => '!!!not-base32!!!'], 0) === null); // invalid secret
assert(otp_validate('324550', ['secret' => 'JBSWY3DPEHPK3PXP'], 1700000000000) === true); // round-trip
echo "otp: all showcase vectors passed\n";
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →