ellite/Wallos · error · RuntimeException

Unable to decode the secret. Is it correctly base32 encoded?

Error message

Unable to decode the secret. Is it correctly base32 encoded?

What it means

OTP::getDecodedSecret wraps Base32::decodeUpper and converts any decode failure into a RuntimeException asking whether the secret is correctly base32 encoded. OTP secrets must be uppercase base32 (RFC 4648, no padding variants supported here); anything else breaks HMAC generation.

Solutions

  1. Encode the secret to uppercase base32 before creating the OTP (e.g. using a base32 encoder on raw bytes).
  2. Uppercase and strip non-base32 chars: strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $secret)).
  3. Check the source of the secret (DB column, env var) wasn't hex or base64 encoded.
  4. Catch the RuntimeException at the boundary and surface a clear 're-enroll the device' message to the user.

Example fix

// before
$totp = TOTP::create($rawBinarySecret); // throws on use
// after
$encoded = strtoupper(Base32::encodeUpper($rawBinarySecret));
$totp = TOTP::create($encoded);
Defensive patterns

Strategy: try-catch

Validate before calling

if (!preg_match('/^[A-Z2-7]+$/', $secret)) {
    throw new DomainException('Secret must be uppercase RFC 4648 base32');
}

Type guard

function isValidBase32Secret(string $secret): bool {
    return $secret !== '' && preg_match('/^[A-Z2-7]+=*$/', $secret) === 1;
}

Try / catch

try {
    $code = $totp->now();
} catch (RuntimeException $e) {
    if (str_contains($e->getMessage(), 'base32')) {
        forceReenrollment($user); // secret is corrupt; re-enroll device
    }
    throw $e;
}

Prevention

When it happens

Trigger: Calling at(), now(), verify(), or getProvisioningUri()-driven flows when the secret contains lowercase letters, invalid characters (0/1/8/9), whitespace, padding issues, or is empty/binary raw bytes.

Common situations: Storing secrets hex-encoded or raw binary in the DB; users pasting secrets with spaces or lowercase; generating secrets with random_bytes() without base32 encoding; secrets trimmed/mangled by config parsing.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of ellite/Wallos@52820e87ca (2026-09-13). Data as JSON: /api/errors/f156cd4ec7dcb344. Report an issue: GitHub.

Appendix: source

Thrown at libs/OTPHP/OTP.php:133

    /**
     * @param non-empty-string $safe
     * @param non-empty-string $user
     */
    protected function compareOTP(string $safe, string $user): bool
    {
        return hash_equals($safe, $user);
    }

    /**
     * @return non-empty-string
     */
    private function getDecodedSecret(): string
    {
        try {
            $decoded = Base32::decodeUpper($this->getSecret());
        } catch (Exception) {
            throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
        }
        assert($decoded !== '');

        return $decoded;
    }

    private function intToByteString(int $int): string
    {
        $result = [];
        while ($int !== 0) {
            $result[] = chr($int & 0xFF);
            $int >>= 8;
        }

        return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT);
    }
}

View on GitHub (pinned to 52820e87ca)