{"record":{"id":"f156cd4ec7dcb344","repo":"ellite/Wallos","slug":"unable-to-decode-the-secret-is-it-correctly-base32-encoded","errorCode":null,"errorMessage":"Unable to decode the secret. Is it correctly base32 encoded?","messagePattern":"Unable to decode the secret\\. Is it correctly base32 encoded\\?","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"error","filePath":"libs/OTPHP/OTP.php","lineNumber":133,"sourceCode":"\n    /**\n     * @param non-empty-string $safe\n     * @param non-empty-string $user\n     */\n    protected function compareOTP(string $safe, string $user): bool\n    {\n        return hash_equals($safe, $user);\n    }\n\n    /**\n     * @return non-empty-string\n     */\n    private function getDecodedSecret(): string\n    {\n        try {\n            $decoded = Base32::decodeUpper($this->getSecret());\n        } catch (Exception) {\n            throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');\n        }\n        assert($decoded !== '');\n\n        return $decoded;\n    }\n\n    private function intToByteString(int $int): string\n    {\n        $result = [];\n        while ($int !== 0) {\n            $result[] = chr($int & 0xFF);\n            $int >>= 8;\n        }\n\n        return str_pad(implode('', array_reverse($result)), 8, \"\\000\", STR_PAD_LEFT);\n    }\n}\n","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/ellite/Wallos/blob/52820e87ca5a6e105fdbb7f1c0c681bc0cfee2fd/libs/OTPHP/OTP.php#L115-L151","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\n$totp = TOTP::create($rawBinarySecret); // throws on use\n// after\n$encoded = strtoupper(Base32::encodeUpper($rawBinarySecret));\n$totp = TOTP::create($encoded);","handlingStrategy":"try-catch","validationCode":"if (!preg_match('/^[A-Z2-7]+$/', $secret)) {\n    throw new DomainException('Secret must be uppercase RFC 4648 base32');\n}","typeGuard":"function isValidBase32Secret(string $secret): bool {\n    return $secret !== '' && preg_match('/^[A-Z2-7]+=*$/', $secret) === 1;\n}","tryCatchPattern":"try {\n    $code = $totp->now();\n} catch (RuntimeException $e) {\n    if (str_contains($e->getMessage(), 'base32')) {\n        forceReenrollment($user); // secret is corrupt; re-enroll device\n    }\n    throw $e;\n}","preventionTips":["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"],"tags":["php","otp","base32","secret"],"backgroundTag":"invalid-argument-format","analyzedSha":"52820e87ca5a6e105fdbb7f1c0c681bc0cfee2fd","analyzedAt":"2026-09-13T14:09:30.873Z","contentChangedAt":"2026-09-13T14:09:30.873Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}