w7corp/easywechat · error · InvalidSignatureException
Missing Header: {$header}
Error message
Missing Header: {$header} What it means
Validator::validate() (src/Pay/Validator.php:34-38) first asserts that the PSR-7 message carries all four WeChat Pay signing headers — Wechatpay-Signature, Wechatpay-Timestamp, Wechatpay-Serial, Wechatpay-Nonce — and throws InvalidSignatureException('Missing Header: ...') before any crypto runs. Without these headers the message provenance cannot be verified, so the SDK treats their absence as an unauthenticatable message. The same validator runs for inbound webhooks (Server::serve(), src/Pay/Server.php) and for API responses (ResponseValidator, src/Pay/ResponseValidator.php:31).
Source
Thrown at src/Pay/Validator.php:36
public const HEADER_NONCE = 'Wechatpay-Nonce';
public const HEADER_SERIAL = 'Wechatpay-Serial';
public const HEADER_SIGNATURE = 'Wechatpay-Signature';
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @throws InvalidConfigException
* @throws InvalidSignatureException
*/
public function validate(MessageInterface $message): void
{
foreach ([self::HEADER_SIGNATURE, self::HEADER_TIMESTAMP, self::HEADER_SERIAL, self::HEADER_NONCE] as $header) {
if (! $message->hasHeader($header)) {
throw new InvalidSignatureException("Missing Header: {$header}");
}
}
[$timestamp] = $message->getHeader(self::HEADER_TIMESTAMP);
[$nonce] = $message->getHeader(self::HEADER_NONCE);
[$serial] = $message->getHeader(self::HEADER_SERIAL);
[$signature] = $message->getHeader(self::HEADER_SIGNATURE);
$body = (string) $message->getBody();
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
if (\time() - \intval($timestamp) > self::MAX_ALLOWED_CLOCK_OFFSET) {
throw new InvalidSignatureException('Clock Offset Exceeded');
}
$publicKey = $this->merchant->getPlatformCert($serial);
View on GitHub (pinned to f0cf0a8b83)
Solutions
- Log every incoming header at the callback route to see exactly what arrives and which of the four Wechatpay-* headers are missing.
- Pass the original PSR-7 server request with its untouched body into Server/Validator; don't validate reconstructed objects.
- Fix the proxy layer (nginx, CDN, WAF) to forward Wechatpay-Signature, Wechatpay-Timestamp, Wechatpay-Serial and Wechatpay-Nonce to PHP.
- In tests, build the request with all four headers plus a real signature, or stub/bypass validation deliberately.
- Confirm the notify URL in the WeChat Pay console points to the route that actually receives WeChat traffic.
Example fix
// before: simulated callback without signed headers
$request = (new ServerRequest('POST', '/webhook/wechat'))
->withHeader('Content-Type', 'application/json');
$request->getBody()->write(json_encode($payload));
$app->getValidator()->validate($request); // Missing Header: Wechatpay-Signature
// after: include all four Wechatpay-* headers
$request = (new ServerRequest('POST', '/webhook/wechat'))
->withHeader('Wechatpay-Timestamp', (string) time())
->withHeader('Wechatpay-Nonce', bin2hex(random_bytes(16)))
->withHeader('Wechatpay-Serial', '<platform-cert-serial>')
->withHeader('Wechatpay-Signature', base64_encode($signature));
$request->getBody()->write(json_encode($payload));
$app->getValidator()->validate($request); Defensive patterns
Strategy: validation
Validate before calling
$required = [
'Wechatpay-Signature',
'Wechatpay-Timestamp',
'Wechatpay-Serial',
'Wechatpay-Nonce',
];
$missing = array_filter($required, fn (string $h): bool => ! $request->hasHeader($h));
if ($missing !== []) {
// Not a WeChat Pay notification — reject before signature validation
return new \GuzzleHttp\Psr7\Response(400);
}
$app->getValidator()->validate($request); Type guard
function isWechatPaySignedRequest(\Psr\Http\Message\ServerRequestInterface $request): bool
{
foreach (['Wechatpay-Signature', 'Wechatpay-Timestamp', 'Wechatpay-Serial', 'Wechatpay-Nonce'] as $header) {
if (! $request->hasHeader($header)) {
return false;
}
}
return true;
} Try / catch
use EasyWeChat\Pay\Exceptions\InvalidSignatureException;
try {
$app->getValidator()->validate($request);
} catch (InvalidSignatureException $e) {
// Reject so WeChat Pay retries with a well-formed request; never process the message.
// Note: Server::serve() converts this internally into a 500 ERROR JSON response.
return $response->withStatus(400, 'Invalid Request');
} Prevention
- Forward Wechatpay-* headers through nginx/CDN/WAF to PHP
- Test callbacks with captured real signed requests, not bare JSON posts
- Log all inbound headers at the notify route during integration
- Register the notify URL only on routes meant to receive WeChat traffic
When it happens
Trigger: Validating any PSR-7 message lacking the Wechatpay-* headers: hand-built test or simulated callback requests; Server::serve() on a route hit by browsers, crawlers or health checks; a reverse proxy, WAF or CDN stripping non-standard vendor headers; validating a rebuilt request/response object instead of the original; using a mocked PSR-18 client for API calls so responses never carry Wechatpay-* headers and ResponseValidator trips.
Common situations: PHPUnit or local webhook simulation posted as bare JSON; nginx/Cloudflare/gateway config dropping unknown headers; framework middleware replacing the request object; wrong notify URL registered in the WeChat console so non-WeChat traffic arrives; mock HTTP clients in tests breaking response signature validation.
Related errors
- Clock Offset Exceeded
- No platform certs found for serial: {$serial},
- Invalid Signature
- You cannot use the "Symfony\Component\HttpClient\Psr18Client
- You cannot use the "Symfony\Component\HttpClient\HttplugClie
AI-assisted analysis of w7corp/easywechat@f0cf0a8b83 (2026-08-21).
Data as JSON: /api/errors/9e2e52347f21b6bb.
Report an issue: GitHub.