w7corp/easywechat · error · InvalidSignatureException

Clock Offset Exceeded

Error message

Clock Offset Exceeded

What it means

Replay protection in Validator::validate() (src/Pay/Validator.php:49-51): every signed message carries Wechatpay-Timestamp, and the validator rejects anything where time() - intval($timestamp) exceeds MAX_ALLOWED_CLOCK_OFFSET = 300 seconds (Validator.php:14). It is raised as InvalidSignatureException even when the signature itself is valid, because a stale timestamp can indicate a replayed notification. The check is one-directional — only past-dated timestamps are rejected; future timestamps pass.

Source

Thrown at src/Pay/Validator.php:50

    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);

        if (! $publicKey) {
            throw new InvalidConfigException(
                "No platform certs found for serial: {$serial}, 
                please download from wechat pay and set it in merchant config with key `certs`."
            );
        }

        if (\openssl_verify(
            $message,
            base64_decode($signature),
            strval($publicKey),
            OPENSSL_ALGO_SHA256
        ) !== 1) {
            throw new InvalidSignatureException('Invalid Signature');

View on GitHub (pinned to f0cf0a8b83)

Solutions

  1. Validate at ingress: run the Validator/Server flow inside the HTTP handler immediately on receipt, then enqueue the already-verified message.
  2. Sync the clock on every host and container: enable chrony, ntpd or systemd-timesyncd and verify with `date -u` against world time.
  3. If validation must happen late, treat the stale notification as untrusted and confirm the real order state via the WeChat Pay query API instead of trusting the old body.
  4. Monitor webhook queue lag and alert well before it approaches the 300-second window.

Example fix

// before: validate long after receipt (queue worker runs 10 min later)
$storedRequest = $cache->get('wechat_notify_' . $id);
$app->getValidator()->validate($storedRequest); // Clock Offset Exceeded

// after: validate immediately in the HTTP handler, queue only the verified message
$app->getValidator()->validate($request); // ingress, within seconds of delivery
$queue->push(ProcessWechatNotify::class, ['body' => (string) $request->getBody()]);
Defensive patterns

Strategy: validation

Validate before calling

$timestamp = (int) $request->getHeaderLine('Wechatpay-Timestamp');

if ($timestamp <= 0 || (time() - $timestamp) > 300) {
    // Stale/replayed or clock-drifted: reject before full validation
    return new \GuzzleHttp\Psr7\Response(400);
}

$app->getValidator()->validate($request);

Try / catch

use EasyWeChat\Pay\Exceptions\InvalidSignatureException;

try {
    $app->getValidator()->validate($request);
} catch (InvalidSignatureException $e) {
    if (str_contains($e->getMessage(), 'Clock Offset')) {
        // Late processing or clock drift: don't trust the stored message —
        // query the order via the WeChat Pay API for truth, then act on that.
        return confirmOrderStateViaApi($request);
    }

    return $response->withStatus(401);
}

Prevention

When it happens

Trigger: Local server clock behind real time so genuine notifications look older than 5 minutes; validating a webhook long after receipt (queue worker picks it up 10 minutes later, retry backlog, crashed workers resumed); replaying captured requests through the validator during debugging; WeChat re-delivering an old notification after a long endpoint outage.

Common situations: Containers or VMs without NTP (chrony/ntpd/systemd-timesyncd blocked in the network policy); store-now-validate-later webhook architectures; queue lag exceeding 300 seconds; reprocessing archived notifications in a test or migration environment.

Related errors


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