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
- Encode the secret to uppercase base32 before creating the OTP (e.g. using a base32 encoder on raw bytes).
- Uppercase and strip non-base32 chars: strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $secret)).
- Check the source of the secret (DB column, env var) wasn't hex or base64 encoded.
- 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
- Base32-encode secrets at creation time; never store raw bytes or hex
- Uppercase and strip whitespace before passing secrets to the library
- Add a schema/config check that secrets match ^[A-Z2-7]+$
- Catch this at enrollment to prompt immediate re-scan of the QR code
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.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- Invalid "secret" parameter.
- Unsupported " " OTP type
- Invalid "counter" parameter.
- The counter must be at least 0.
- Counter must be at least 0.
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)