<?php// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>"function sign(string $secret, string $rawBody): string{ return 'sha256=' . hash_hmac('sha256', $rawBody, $secret); // data first, then the key} // Receiver: recompute over the raw body and compare in constant timefunction verify(string $secret, string $rawBody, ?string $header): bool{ if ($header === null) { return false; // a missing header fails, it never skips the check } return hash_equals(sign($secret, $rawBody), $header); // known value first, then the received one} <?phprequire __DIR__ . '/webhook.php'; $secret = getenv('WEBHOOK_SECRET');$rawBody = file_get_contents('php://input'); // the bytes as received; $_POST stays empty for JSON$header = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? null; // how PHP names X-Hub-Signature-256 if ($secret === false || !verify($secret, $rawBody, $header)) { http_response_code(401); exit("bad signature\n");}$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);echo "accepted {$event['action']} #{$event['number']}\n"; How it works
hash_hmac('sha256', $rawBody, $secret)returns 64 lower-case hex digits (passtrueas the fourth argument for raw bytes); the header value issha256=followed by them.hash_equals($known, $received)compares in constant time and returnsfalsefor strings of different lengths; the documented order is the value you computed first, the one you received second.file_get_contents('php://input')returns the request body exactly as sent. PHP exposes the header as$_SERVER['HTTP_X_HUB_SIGNATURE_256']: upper-cased, dashes turned into underscores, prefixed withHTTP_.- The run checks GitHub's example (
Hello, World!, secretIt's a Secret to Everybody→sha256=757107ea…3e17), then serves the endpoint withphp -Sand posts real requests with curl.
Gotchas
hash_hmactakes the data before the key. Swapped arguments produce a perfectly valid-looking MAC that never matches — the run prints it next to the right one.- Never compare MACs with
==: two different strings that look like numbers in exponent notation compare equal ('0e1234' == '0e5678'istruein PHP 8.5).hash_equalscompares the strings as they are, in constant time. - The MAC covers bytes, not JSON: the same event minified is rejected with
401. Verify thephp://inputstring beforejson_decode, never a re-encoded array.