Skip to content

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 →