Github webhook signature verification

Check your implementation against GitHub's published example first: the payload Hello, World! with the secret It's a Secret to Everybody gives 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. Then deliver a signed body to your own receiver and confirm it answers 202, and 401 when one field of that body changes.

Why check this

Run this when an endpoint first subscribes to repository events, after the secret is rotated, and after any framework upgrade that touches body parsing. The failure it prevents is a deploy hook that runs on any POST to its URL. GitHub webhook URLs end up in build logs and in browser history, and an endpoint that skips the header will build and ship whatever payload it is handed. Checking against the published example first separates two questions that otherwise arrive together: whether your HMAC code is right, and whether the secret on both sides is the same.

Prerequisites

// gh.js - a receiver that validates GitHub's X-Hub-Signature-256 over the raw body.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.GH_WEBHOOK_SECRET || "It's a Secret to Everybody";
http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    const sent = Buffer.from(String(req.headers['x-hub-signature-256'] || ''), 'utf8');
    const want = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(raw).digest('hex'), 'utf8');
    const ok = sent.length === want.length && crypto.timingSafeEqual(sent, want);
    const event = String(req.headers['x-github-event'] || '-');
    console.log(`event=${event} bytes=${raw.length} sent=${sent.length}ch want=${want.length}ch ${ok ? 'OK' : 'REJECT'}`);
    res.writeHead(ok ? 202 : 401).end(ok ? 'accepted\n' : 'signature mismatch\n');
  });
}).listen(8922, '127.0.0.1', () => console.log('receiver on http://127.0.0.1:8922/gh'));

Step 8 uses this benchmark, saved as compare.mjs.

// compare.mjs - does the position of the wrong byte show up in the timing of ===?
import { createHmac, timingSafeEqual } from 'node:crypto';
const body = '{"action":"opened","number":7,"repository":{"full_name":"acme/site"}}';
const want = 'sha256=' + createHmac('sha256', "It's a Secret to Everybody").update(body).digest('hex');
const flip = (s, i) => { const a = s.split(''); a[i] = a[i] === 'a' ? 'b' : 'a'; return a.join(''); };
const N = 2_000_000;
function ns(other) {
  for (let i = 0; i < 300_000; i++) if (want === other) throw new Error('x');
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < N; i++) if (want === other) throw new Error('x');
  return (Number(process.hrtime.bigint() - t0) / N).toFixed(1);
}
for (let r = 1; r <= 3; r++) {
  console.log(`round ${r}: wrong byte first ${ns(flip(want, 7))} ns, wrong byte last ${ns(flip(want, 70))} ns`);
}
console.log('=== verdict ' + (want === flip(want, 70)) + ', timingSafeEqual verdict ' + timingSafeEqual(Buffer.from(want), Buffer.from(flip(want, 70))));

Steps

  1. Step 1.

    Reproduce GitHub's published example. This is the one step that tests your code rather than your configuration.

    printf '%s' 'Hello, World!' | openssl dgst -sha256 -hmac "It's a Secret to Everybody" -r
    
    757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 *stdin

    The header GitHub sends is that value with sha256= in front. Any digest but this one means the code is wrong, whatever the secret is.

  2. Step 2.

    Start the receiver in its own terminal.

    node gh.js | tee gh.log
    
    receiver on http://127.0.0.1:8922/gh
  3. Step 3.

    Save a delivery body to payload.json with no trailing newline, then sign the file.

    SIG=$(openssl dgst -sha256 -hmac "It's a Secret to Everybody" -r < payload.json | cut -d' ' -f1)
    echo "$SIG"
    
    fcffc0e22c9f2bc1b3fc3cbc41c52c3c6e6191faf52aca95bb1c215896aeea14

    The body here is 69 bytes: {"action":"opened","number":7,"repository":{"full_name":"acme/site"}}.

  4. Step 4.

    Deliver it with the headers GitHub attaches.

    curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8922/gh' -H 'content-type: application/json' -H 'x-github-event: pull_request' -H 'x-github-delivery: 0e1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d' -H "x-hub-signature-256: sha256=$SIG" --data-binary @payload.json
    
    accepted
    202
  5. Step 5.

    Change the pull request number from 7 to 8 and send the same header.

    curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8922/gh' -H 'content-type: application/json' -H 'x-github-event: pull_request' -H "x-hub-signature-256: sha256=$SIG" --data-raw '{"action":"opened","number":8,"repository":{"full_name":"acme/site"}}'
    
    signature mismatch
    401
  6. Step 6.

    Send the value of the other header GitHub attaches, X-Hub-Signature, which is SHA-1.

    curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8922/gh' -H 'content-type: application/json' -H 'x-github-event: pull_request' -H 'x-hub-signature-256: sha1=0383d64c4fc9db45dce5ef10a407b0493a721b70' --data-binary @payload.json
    
    signature mismatch
    401
  7. Step 7.

    Read the receiver log. The character counts on the three request lines are the diagnosis.

    cat gh.log
    
    receiver on http://127.0.0.1:8922/gh
    event=pull_request bytes=69 sent=71ch want=71ch OK
    event=pull_request bytes=69 sent=71ch want=71ch REJECT
    event=pull_request bytes=69 sent=45ch want=71ch REJECT

    Line two and line three fail for different reasons and a one-word log would have hidden it. In line two the lengths agree, so the algorithm is right and one of the inputs is not. In line three the header is 45 characters against 71, which is sha1= plus 40 hex characters: a different hash function, not a different secret.

  8. Step 8.

    Before replacing the constant-time comparison with an equality operator, measure what the difference looks like from your own machine. This runs two million string comparisons per case, one differing in the first byte and one in the last.

    node compare.mjs
    
    round 1: wrong byte first 12.9 ns, wrong byte last 15.7 ns
    round 2: wrong byte first 11.5 ns, wrong byte last 8.8 ns
    round 3: wrong byte first 6.3 ns, wrong byte last 8.7 ns
    === verdict false, timingSafeEqual verdict false

    Round 2 puts the cases in the opposite order from rounds 1 and 3, and the spread between rounds is larger than the gap between the two cases. On this machine, in process, with no network in the way, the position of the wrong byte does not separate from noise. That is the useful result: the argument for a constant-time comparison is not one you can win or lose with a benchmark, and the last line shows both comparisons returning the same verdict on the same input. It is settled by reading the code.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Step 1 gives 757107ea… | The HMAC code is correct | Any later failure is configuration, not code. | | Step 1 gives another digest | The code hashes something else | Check for a trailing newline in the payload and for a secret read with surrounding whitespace. | | sent=71ch want=71ch OK | Body and secret both match | Record the byte count of the body. | | sent=71ch want=71ch REJECT | Right format, wrong input | The body changed in transit or the secrets differ. Compare the byte count with a known good delivery. | | sent=45ch want=71ch | SHA-1 read as SHA-256 | Read x-hub-signature-256, not x-hub-signature. | | sent=0ch | The header never arrived | A proxy stripped it, or the header name is misspelled in the handler. |

Common mistakes

Sign: Verification works against the published example and fails against every real delivery.Cause: The receiver hashes a re-serialised body. GitHub sends compact JSON, so a fixture round-trips unchanged through a JSON parser and hides the bug, while a real delivery that contains an escaped character or a different key order does not. Capture the raw bytes before any parser runs.
Sign: The handler reads X-Hub-Signature and the digest never matches.Cause: That header is HMAC-SHA-1, 40 hex characters after the prefix, and it is sent alongside the SHA-256 one. Step 6 shows the length in the log: 45 characters against 71. A receiver that only reports a boolean gives you no way to see this.
Sign: The endpoint answers 401 for deliveries and 200 for a hand-made curl request.Cause: The hand-made request usually loses the trailing newline that a here-document or an editor added to the payload file. One byte of difference produces an unrelated digest. Send the file with --data-binary, which preserves the bytes, rather than with -d, which strips newlines.
Sign: A code review asks for a constant-time comparison and nobody can show the leak.Cause: Nobody can, from a test. Step 8 measures it in process and the ordering flips between rounds. Both comparisons return the same verdict on every input, so no black box test separates them. Use crypto.timingSafeEqual with a length guard because the code is easier to review, not because a test will catch it.

What to check next

FAQ

What is X-Hub-Signature-256?

The header GitHub attaches to every webhook delivery. Its value is sha256= followed by the hex HMAC-SHA-256 of the raw request body, keyed with the secret configured on the repository, organisation or app.

Do I need a GitHub account to test this?

No. The scheme is published and the example values are published, so the whole check runs against a receiver on 127.0.0.1. An account is needed only for the final pass with a real delivery.

Why does GitHub send two signature headers?

X-Hub-Signature is the older SHA-1 form, kept for receivers written against it. Verify the SHA-256 one. Step 6 shows what happens when the two are confused: a 45 character value against a 71 character one.

Should I compare the signatures with an equality operator?

Use crypto.timingSafeEqual and check the lengths first. Step 8 shows that the timing difference is not measurable on one machine, so the reason is reviewability rather than a test result. The length check also keeps a wrong-length header from throwing.

Verified

Verified by Maks Vernycurl 8.21.0openssl 3.1.1node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate7 minpublished updated Maks Verny