Webhook Signature Validation

Signature Format

Webhooks use versioned signatures for secret rotation:

X-Signature: t=timestamp,v1=current_sig,v0=old_sig

Components:

  • t: Unix timestamp when sent
  • v1: Current signature (HMAC SHA256)
  • v0: Expiring signature (during rotation only)

Validation Algorithm

signed_payload = timestamp + "." + request_body
expected_sig = HMAC-SHA256(signed_payload, webhook_secret)

Verify:

  1. Parse timestamp and v1 from header
  2. Check timestamp (reject if > 5 minutes)
  3. Compute expected signature
  4. Constant-time compare with v1
  5. During rotation, also check v0

PHP Implementation

function verifyWebhook($payload, $signatureHeader, $secret) {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$key, $value] = explode('=', $part, 2);
        $parts[$key] = $value;
    }
    
    if (abs(time() - (int)$parts['t']) > 300) {
        return false;
    }
    
    $signedPayload = $parts['t'] . '.' . $payload;
    $expectedSig = hash_hmac('sha256', $signedPayload, $secret);
    
    return hash_equals($expectedSig, $parts['v1']) 
        || (isset($parts['v0']) && hash_equals($expectedSig, $parts['v0']));
}

JavaScript Implementation

function verifyWebhook(payload, signatureHeader, secret) {
    const parts = Object.fromEntries(
        signatureHeader.split(',').map(p => p.split('='))
    );
    
    if (Math.abs(Date.now()/1000 - parseInt(parts.t)) > 300) {
        return false;
    }
    
    const signedPayload = `${parts.t}.${payload}`;
    const expectedSig = crypto
        .createHmac('sha256', secret)
        .update(signedPayload)
        .digest('hex');
    
    return crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(parts.v1))
        || (parts.v0 && crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(parts.v0)));
}

Python Implementation

def verify_webhook(payload: str, signature_header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    
    if abs(time.time() - int(parts['t'])) > 300:
        return False
    
    signed_payload = f"{parts['t']}.{payload}"
    expected_sig = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(expected_sig, parts['v1']) \
        or ('v0' in parts and hmac.compare_digest(expected_sig, parts['v0']))