Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · PHP

How to sign and verify a message with HMAC-SHA256 in PHP

To sign and verify a message with HMAC-SHA256 in PHP, compute the MAC with hash_hmac('sha256', $body, $secret) and compare it with hash_equals, which runs in constant time. The typical case is a webhook signature header; this recipe checks GitHub's documented example and verifies deliveries in an endpoint served by PHP's built-in web server.

Tested with PHP 8.5.11 (ext/hash, built-in web server (php -S)) and OpenSSL 3.5.7 (signature of the test request).

webhook.php — sign and verify a sha256=<hex> header
<?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 time
function 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
}
endpoint.php — read the raw body from php://input and verify it before decoding JSON
<?php
require __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

  1. hash_hmac('sha256', $rawBody, $secret) returns 64 lower-case hex digits (pass true as the fourth argument for raw bytes); the header value is sha256= followed by them.
  2. hash_equals($known, $received) compares in constant time and returns false for strings of different lengths; the documented order is the value you computed first, the one you received second.
  3. 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 with HTTP_.
  4. The run checks GitHub's example (Hello, World!, secret It's a Secret to Everybody → sha256=757107ea…3e17), then serves the endpoint with php -S and posts real requests with curl.

Gotchas

  • hash_hmac takes 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' is true in PHP 8.5). hash_equals compares the strings as they are, in constant time.
  • The MAC covers bytes, not JSON: the same event minified is rejected with 401. Verify the php://input string before json_decode, never a re-encoded array.

Related recipes