w7corp/easywechat · error · BadRequestException

Request ciphertext must not be empty.

Error message

Request ciphertext must not be empty.

What it means

decryptMessage requires the incoming push to carry an encrypted body — the Encrypt (XML) or encrypt (JSON) field. OfficialAccount/MiniApp servers only take the decryption path when the request looks encrypted (encrypt_type=aes query param or an Encrypt node, src/OfficialAccount/Server.php:154), and Work always decrypts (src/Work/Server.php:53). When the trigger says encrypted but the parsed message has no ciphertext node, this BadRequestException is thrown.

Source

Thrown at src/Kernel/Traits/DecryptMessage.php:29

trait DecryptMessage
{
    /**
     * Decrypt message (automatically detects XML or JSON format).
     *
     * @throws RuntimeException
     * @throws BadRequestException
     */
    public function decryptMessage(
        Message $message,
        Encryptor $encryptor,
        string $signature,
        int|string $timestamp,
        string $nonce
    ): Message {
        $ciphertext = $message->Encrypt ?? $message->encrypt ?? null;

        if (! is_string($ciphertext) || $ciphertext === '') {
            throw new BadRequestException('Request ciphertext must not be empty.');
        }

        $this->validateSignature($encryptor->getToken(), $ciphertext, $signature, $timestamp, $nonce);

        $plaintext = $encryptor->decrypt(
            ciphertext: $ciphertext,
            msgSignature: $signature,
            nonce: $nonce,
            timestamp: $timestamp
        );

        $attributes = MessageParser::parse($plaintext);

        $message->merge($attributes);

        return $message;
    }

View on GitHub (pinned to f0cf0a8b83)

Solutions

  1. Pass the original untouched ServerRequestInterface to the app/server (setRequest/serve).
  2. Align modes: in plaintext mode remove encrypt_type=aes expectations, or set require_encryption correctly for your mode.
  3. If middleware must inspect the body, rewind/rebuild it before dispatching to the SDK.
  4. For custom flows, check $message->Encrypt/encrypt exists before calling getDecryptedMessage().

Example fix

// before: middleware consumed php://input, so the message carries no Encrypt node
file_get_contents('php://input');
$app->getServer()->serve();
// after: hand the pristine PSR-7 request (raw body intact) to the SDK
$app->setRequest($psrRequest);
$app->getServer()->serve();
Defensive patterns

Strategy: validation

Validate before calling

$body = (string) $request->getBody();
$parsed = str_contains($request->getHeaderLine('content-type'), 'json') ? json_decode($body, true) : (array) simplexml_load_string($body);
$hasCipher = is_array($parsed) && (!empty($parsed['Encrypt']) || !empty($parsed['encrypt']));
if (!hasCipher && ($request->getQueryParams()['encrypt_type'] ?? '') === 'aes') { return new \Nyholm\Psr7\Response(400); }

Try / catch

try { $message = $app->getServer()->getDecryptedMessage(); } catch (\EasyWeChat\Kernel\Exceptions\BadRequestException $e) { if (str_contains($e->getMessage(), 'ciphertext must not be empty')) { \Log::warning('push without Encrypt field', ['body' => substr((string) $request->getBody(), 0, 200)]); return new \Nyholm\Psr7\Response(400); } throw $e; }

Prevention

When it happens

Trigger: encrypt_type=aes on the callback URL while the account was switched to plaintext mode in the console (or vice versa); the raw request body already consumed or emptied by framework middleware so Message::createFromRequest sees no Encrypt field; a Work callback routed through a proxy that rewrites the body; hand-crafted test requests without an Encrypt node.

Common situations: Reading php://input (or creating the PSR-7 request without a body) before the SDK; mode changes between plaintext/compatible/safe in the console after URL verification; re-posting queued callback bodies in a rebuilt, incomplete form.

Related errors


AI-assisted analysis of w7corp/easywechat@f0cf0a8b83 (2026-08-21). Data as JSON: /api/errors/2e7bc0f58c8af359. Report an issue: GitHub.