w7corp/easywechat · error · RuntimeException
-40001
-40001
Error message
Invalid Signature.
What it means
Encryptor::decrypt() recomputes the SHA1 signature over the sorted [token, timestamp, nonce, ciphertext] values and compares it with hash_equals against the msg_signature you supplied. Any mismatch throws RuntimeException('Invalid Signature.', -40001 ERROR_INVALID_SIGNATURE) before decryption is attempted — this is the WeChat callback authenticity check.
Source
Thrown at src/Kernel/Encryptor.php:189
$attributes = array_map(
static fn (string|int $attribute): string => (string) $attribute,
$attributes
);
sort($attributes, SORT_STRING);
return sha1(implode('', $attributes));
}
/**
* @throws RuntimeException
*/
public function decrypt(string $ciphertext, string $msgSignature, string $nonce, int|string $timestamp): string
{
$signature = $this->createSignature($this->token, $timestamp, $nonce, $ciphertext);
if (! hash_equals($signature, $msgSignature)) {
throw new RuntimeException('Invalid Signature.', self::ERROR_INVALID_SIGNATURE);
}
$plaintext = Pkcs7::unpadding(
openssl_decrypt(
base64_decode($ciphertext, true) ?: '',
'aes-256-cbc',
$this->aesKey,
OPENSSL_NO_PADDING,
iv: substr($this->aesKey, 0, self::BLOCK_SIZE)
) ?: '',
blockSize: strlen($this->aesKey)
);
$plaintext = substr($plaintext, self::BLOCK_SIZE);
$contentLength = (unpack('N', substr($plaintext, 0, 4)) ?: [])[1];
if ($this->receiveId && trim(substr($plaintext, $contentLength + 4)) !== $this->receiveId) {
throw new RuntimeException('Invalid appId.', self::ERROR_INVALID_APP_ID);
}View on GitHub (pinned to f0cf0a8b83)
Solutions
- Compare the token in your config with the Token field of the callback server config in the WeChat admin console — they must match exactly
- Make sure timestamp, nonce and msg_signature all come from the same request's query string, and the ciphertext comes from that request's body, without re-encoding
- Prefer $server->handle($request) / Server::handle() which extracts the four inputs from the PSR-7 request correctly instead of assembling them manually
Example fix
// before: manual assembly, easy to get wrong
$plaintext = $encryptor->decrypt(
(string) $request->getContent(),
$request->query('msg_signature'),
$request->query('nonce'),
$request->query('timestamp')
);
// after: let the Server validate and decrypt
$server = $app->getServer();
$server->handle(function (array $message, \asyWeChat\ernel\essage $msg) { /* ... */ });
// inside a dedicated handler, token/nonce/timestamp are taken from the same request Defensive patterns
Strategy: try-catch
Try / catch
try {
$plaintext = $encryptor->decrypt($ciphertext, $msgSignature, $nonce, $timestamp);
} catch (\EasyWeChat\Kernel\Exceptions\RuntimeException $e) {
if ((int) $e->getCode() === \EasyWeChat\Kernel\Encryptor::ERROR_INVALID_SIGNATURE) {
// do NOT process the message; respond 403 and log for investigation
abort(403, 'Invalid callback signature');
}
throw $e;
} Prevention
- Always take timestamp, nonce and msg_signature from the same request's query string and the ciphertext from its body
- Prefer $app->getServer()->handle($request) over manual decrypt calls
- When rotating the token in the admin console, deploy the matching config in the same window
When it happens
Trigger: Handling a WeChat callback with an Encryptor whose token differs from the Token configured in the MP/WeCom admin console; passing a timestamp/nonce taken from the wrong source (body vs query string); double-decoding or re-encoding the msg_signature/ciphertext from the query; or replaying a captured request through a different token.
Common situations: Token mismatch after rotating server config, multiple apps (test/prod) sharing one callback URL, framework middleware urldecoding query params twice, using the encrypted string from the body with the signature of a different field.
Related errors
AI-assisted analysis of w7corp/easywechat@f0cf0a8b83 (2026-08-21).
Data as JSON: /api/errors/c8cdcbd9e3c1f3af.
Report an issue: GitHub.