Webhook security
Webhook Signature Verification With HMAC-SHA256
Implement webhook signature verification with HMAC-SHA256, raw request bodies, timestamp checks, constant-time comparison, and secret rotation.
Webhook signature verification proves that a request was signed by a system holding the shared secret and that its body was not changed in transit. HTTPS protects the connection. It does not tell your endpoint which application created the request.
The reliable pattern is to sign the timestamp plus the exact raw body with HMAC-SHA256, reject stale requests, and compare signatures in constant time before parsing or processing the event.
The webhook signature verification sequence
Verify freshness and authenticity before the payload reaches business logic.
Core Forms signs outgoing webhooks with:
signed_payload = timestamp + "." + raw_json_body
signature = HMAC-SHA256(signed_payload, secret)
X-CF-Signature: sha256=<hex digest>
X-CF-Timestamp: <Unix timestamp>
The complete contract is in the Core Forms webhook signature documentation. HMAC-SHA-256 is standardized in the SHA and HMAC algorithms documented by RFC 6234.
Read the raw request body
Signature verification must use the same bytes the sender signed. JSON parsing and serialization can change whitespace, property order, escaping, or number representation.
Wrong order:
- Parse JSON.
- Re-encode it.
- Verify the new string.
Correct order:
- Read the raw body.
- Read signature and timestamp headers.
- Verify the signature.
- Parse JSON only after verification succeeds.
For Express, configure a raw body for the webhook route. For PHP, read php://input before decoding.
Verify freshness before processing
A valid signature can be replayed if the receiver accepts it forever. Reject timestamps outside a short tolerance, commonly five minutes.
$payload = file_get_contents( 'php://input' );
$signature = $_SERVER['HTTP_X_CF_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_CF_TIMESTAMP'] ?? '';
if ( abs( time() - (int) $timestamp ) > 300 ) {
http_response_code( 401 );
exit;
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestamp . '.' . $payload,
getenv( 'CF_WEBHOOK_SECRET' )
);
if ( ! hash_equals( $expected, $signature ) ) {
http_response_code( 401 );
exit;
}
Timestamp checks reduce replay risk, but idempotency still matters. Two valid deliveries can arrive inside the allowed window.
Use constant-time comparison
Do not compare secret-derived values with a normal equality operator. Use the language’s constant-time primitive:
- PHP:
hash_equals(); - Node.js:
crypto.timingSafeEqual(); - Python:
hmac.compare_digest().
Check lengths before APIs that require equal-sized buffers. Never reveal the expected signature or secret in an error response.
Store and rotate the secret safely
Treat a webhook secret like an API credential:
- keep it in an environment variable or secret manager;
- never place it in frontend JavaScript;
- do not commit it to the repository;
- redact it from logs and support screens;
- use a separate secret per endpoint or environment;
- rotate after suspected exposure.
For graceful rotation, accept the current and previous secret for a short overlap, then remove the old one. Record which key version verified the event, not the secret itself.
Return the right status
Return 401 or 400 for missing, stale, or invalid signatures. Return a fast 2xx only after the request is safely accepted for processing.
Do not return internal cryptographic detail. Log a bounded reason such as:
- missing signature header;
- timestamp outside tolerance;
- signature mismatch;
- malformed JSON after successful verification.
Rate-limit repeated failures and alert on a sudden spike.
Add idempotency after authentication
A valid webhook can be delivered more than once. Store a stable event ID or payload digest and skip business actions already completed. The webhook retries and idempotency guide shows the receiver-side pattern.
Signature verification and idempotency solve different problems:
- signature: who signed these exact bytes?
- timestamp: is the signed request fresh enough?
- idempotency: have I already processed this event?
- authorization: is this event allowed to change this resource?
FAQ
Does HTTPS replace webhook signatures?
No. HTTPS secures transport, while a signature authenticates the sender and body at the application layer.
Why must I use the raw body?
Parsing and re-encoding JSON may change the bytes and produce a different HMAC.
What timestamp tolerance should I use?
Five minutes is a common default. Adjust for clock drift and delivery behavior without creating a large replay window.
Can I put the webhook secret in the payload?
No. Keep it only on the sender and receiver servers.
Does a valid signature prevent duplicate processing?
No. Use a separate idempotency key or stable event ID to recognize repeat deliveries.