Verifying signatures
Every delivery carries an HMAC-SHA256 signature over the timestamp + raw body. Verifying it on your server is non-optional — without it, anyone who learns your URL can forge events.
The canonical string
canonical = f"{X-CPG-Timestamp}\n".encode("utf-8") + <raw body bytes>
The HMAC input is the byte concatenation of the timestamp string, a single newline, and the exact request body bytes. Sign / verify against the raw body — don't parse JSON and re-serialise; any whitespace or key-order change breaks the signature.
The signature
X-CPG-Signature = hmac_sha256(signing_secret, canonical).hexdigest() # lowercase hex
What your receiver must do
On every request:
- Reject if
|now - X-CPG-Timestamp| > 300seconds (5-minute replay window). - Recompute the HMAC over
f"{X-CPG-Timestamp}\n".encode() + raw_bodywith your stored signing secret. - Constant-time compare the recomputed signature to
X-CPG-Signature. Usehmac.compare_digest(Python) /crypto.timingSafeEqual(Node) /hash_equals(PHP) — never==. A naive comparison leaks timing information that an attacker can exploit to forge signatures byte-by-byte.
If any of the three fails, respond 401 Unauthorized and do not process the payload.
Recipes
- Python
- Node.js
- PHP
import hmac
import hashlib
import time
from typing import Mapping
REPLAY_WINDOW_SECONDS = 300
def verify_webhook(body: bytes, headers: Mapping[str, str], signing_secret: str) -> bool:
"""Return True iff the signature is valid and the timestamp is fresh."""
ts = headers.get("X-CPG-Timestamp") or headers.get("x-cpg-timestamp")
sig = headers.get("X-CPG-Signature") or headers.get("x-cpg-signature")
if not ts or not sig:
return False
try:
if abs(int(time.time()) - int(ts)) > REPLAY_WINDOW_SECONDS:
return False
except ValueError:
return False
canonical = f"{ts}\n".encode("utf-8") + body
expected = hmac.new(signing_secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.lower(), sig.lower())
FastAPI integration:
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SIGNING_SECRET = "<your stored secret>"
@app.post("/webhooks/cpg")
async def cpg_webhook(
request: Request,
x_cpg_timestamp: str | None = Header(default=None),
x_cpg_signature: str | None = Header(default=None),
):
raw_body = await request.body()
headers = {"X-CPG-Timestamp": x_cpg_timestamp, "X-CPG-Signature": x_cpg_signature}
if not verify_webhook(raw_body, headers, SIGNING_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
# Dedupe by envelope.id, enqueue for async processing, return 2xx fast.
return {"received": True}
const crypto = require('node:crypto');
const REPLAY_WINDOW = 300;
function verifyWebhook(rawBody, headers, signingSecret) {
const ts = headers['x-cpg-timestamp'];
const sig = headers['x-cpg-signature'];
if (!ts || !sig) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(ts, 10)) > REPLAY_WINDOW) return false;
const canonical = Buffer.concat([Buffer.from(ts + '\n', 'utf-8'), rawBody]);
const expected = crypto.createHmac('sha256', signingSecret).update(canonical).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.toLowerCase()));
} catch {
return false;
}
}
module.exports = { verifyWebhook };
Express integration:
const express = require('express');
const { verifyWebhook } = require('./verify-webhook');
const app = express();
const SIGNING_SECRET = process.env.CPG_SIGNING_SECRET;
// IMPORTANT: keep the raw body around — parsing JSON before verifying breaks the signature.
app.post('/webhooks/cpg', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.headers, SIGNING_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf-8'));
// Dedupe by event.id, enqueue, respond 2xx.
res.status(200).json({ received: true });
});
<?php
const REPLAY_WINDOW_SECONDS = 300;
function verify_webhook(string $rawBody, array $headers, string $signingSecret): bool {
$ts = $headers['X-CPG-Timestamp'] ?? $headers['x-cpg-timestamp'] ?? null;
$sig = $headers['X-CPG-Signature'] ?? $headers['x-cpg-signature'] ?? null;
if (!$ts || !$sig) return false;
if (!ctype_digit($ts)) return false;
if (abs(time() - (int) $ts) > REPLAY_WINDOW_SECONDS) return false;
$canonical = $ts . "\n" . $rawBody;
$expected = hash_hmac('sha256', $canonical, $signingSecret);
return hash_equals(strtolower($expected), strtolower($sig));
}
Plain PHP endpoint (read raw input, normalise headers, then verify):
<?php
require 'verify_webhook.php';
$signingSecret = getenv('CPG_SIGNING_SECRET');
$rawBody = file_get_contents('php://input');
$headers = [];
foreach ($_SERVER as $k => $v) {
if (str_starts_with($k, 'HTTP_')) {
$name = str_replace('_', '-', substr($k, 5));
$headers[$name] = $v;
}
}
if (!verify_webhook($rawBody, $headers, $signingSecret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// Dedupe by $event['id'], enqueue, respond 2xx.
http_response_code(200);
echo json_encode(['received' => true]);
$_SERVER exposes incoming HTTP headers as HTTP_X_CPG_TIMESTAMP / HTTP_X_CPG_SIGNATURE — the loop above translates them into the case the verifier expects.
Common verification mistakes
- Parsing then re-serialising the body.
JSON.parse(req.body)followed byJSON.stringify(parsed)produces a different byte string than what was signed. The signature is over the raw bytes — never re-serialise before verifying. - Treating the signature as base64. It's lowercase hex (
a1b2c3...), not base64. Don'tbase64_decode()before comparing. - Case-sensitive header lookup. HTTP header names are case-insensitive. Frameworks may normalise to lowercase (
x-cpg-signature) or preserve as sent — your verifier must check both casings or canonicalise. - String comparison with
==. Vulnerable to timing attacks. Always use the framework's constant-time compare (hmac.compare_digest,crypto.timingSafeEqual,hash_equals). - Forgetting the timestamp window. Without the 300-second freshness check, an attacker who captures one valid delivery can replay it forever. The signature itself doesn't expire — only the timestamp check makes it freshness-bound.
- Body parsing middleware running before the verifier. In Express, putting
app.use(express.json())before the webhook route meansreq.bodyis the parsed object — the raw bytes are gone. Useexpress.raw()on the webhook route specifically (as in the example).