Verify a GitHub webhook signature

"sha256=" + hex(HMAC-SHA256(secret, raw body))

X-Hub-Signature-256 is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with the webhook’s secret and written as lowercase hex. To verify a delivery, compute the same value over the bytes you received and compare it with the header in constant time. GitHub’s docs give a test case, secret It's a Secret to Everybody and payload Hello, World!:

const crypto = require('node:crypto');

'sha256=' + crypto.createHmac('sha256', "It's a Secret to Everybody").update('Hello, World!').digest('hex');
// 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'

The tool below computes the same 757107ea… and says it matches. Paste only what follows sha256= into Check a signature.

Verify it in Node

const crypto = require('node:crypto');

function verifyGitHub(secret, rawBody, header) {
  const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'));
  const received = Buffer.from(header ?? '');
  // timingSafeEqual throws on buffers of different lengths, so check that first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Without the length check, a header of any other length makes timingSafeEqual throw RangeError: Input buffers must have the same byte length, and the handler answers 500 instead of 401. Don’t compare with ===: GitHub’s docs say never to use a plain equality operator, because it can return as soon as one character differs.

Express: keep the raw body

The signature covers the bytes GitHub sent. express.json() parses them, and JSON.stringify(req.body) writes new bytes, so the HMAC stops matching whenever the original had other whitespace, \u escapes or numbers like 1.0. A body of {"action": "opened", "issue": {"title": "Caf\u00e9 menu"}} comes back as {"action":"opened","issue":{"title":"Café menu"}} and fails. Read the route’s body with express.raw, which leaves req.body a Buffer:

const express = require('express');
const app = express();

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verifyGitHub(process.env.GITHUB_WEBHOOK_SECRET, req.body, req.get('X-Hub-Signature-256'))) {
    return res.status(401).send('Bad signature');
  }
  const event = JSON.parse(req.body);
  res.send(`ok ${req.get('X-GitHub-Event')} ${event.action}`);
});

express.raw only reads the content type it’s given, so a delivery with another Content-Type leaves req.body something other than a Buffer, and the Buffer.isBuffer check turns that into a 401. If the app already runs express.json() everywhere, keep the bytes from its verify callback and pass req.rawBody to verifyGitHub:

app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

Python: Flask and FastAPI

import hashlib
import hmac
import os

SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()

def verify_github(secret: bytes, raw_body: bytes, header: str | None) -> bool:
    expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (header or "").encode())

hmac.compare_digest returns False for inputs of different lengths, so it needs no length check. Give it bytes: with a str holding any non-ASCII character it raises TypeError: comparing strings with non-ASCII characters is not supported, and the header holds whatever the sender put there.

In Flask, request.get_data() is the raw body, and request.get_json() still works after it. In FastAPI, it’s await request.body():

import json

from fastapi import FastAPI, HTTPException, Request
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/webhooks/github")
def github():
    if not verify_github(SECRET, request.get_data(), request.headers.get("X-Hub-Signature-256")):
        abort(401)
    event = request.get_json()
    return f"ok {event['action']}"

api = FastAPI()

@api.post("/webhooks/github")
async def github_fastapi(request: Request):
    body = await request.body()
    if not verify_github(SECRET, body, request.headers.get("X-Hub-Signature-256")):
        raise HTTPException(status_code=401, detail="Bad signature")
    return {"ok": json.loads(body)["action"]}

What the signature doesn’t cover

  • Time. GitHub signs the body alone, with no timestamp, so there’s no tolerance window to check. X-GitHub-Delivery, the GUID of each delivery, sits outside the HMAC too, so a captured delivery replays with any GUID and still verifies. Make handlers safe to run twice.
  • SHA-1. X-Hub-Signature (no -256) is the same scheme with HMAC-SHA1, sent for legacy integrations. GitHub recommends the SHA-256 header.
  • No secret, no header. A webhook without a secret sends neither header, so treat a missing one as a failure, never as “nothing to check”.

Try it in the HMAC Generator

Signed as the UTF-8 bytes of what's pasted, spaces included, with every line break written as the one chosen. This box keeps line breaks as LF whatever was pasted, so pick CRLF for a body sent with CRLF line endings.

The key never leaves your browser: the signature is worked out here in the page, and nothing is sent anywhere.

Algorithm
Written as
Signature · SHA-256

757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

13 bytes of message, 26 of key.

Matches — this message, signed with this key.

How webhook signatures are checked

Both services below sign exactly the way this tool does, so the value after the prefix is an HMAC you can check here.

ServiceWhat it sendsWhat the signature is made of
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw request body, keyed with the webhook secret.
StripeStripe-Signature: t=<time>,v1=<hex>HMAC-SHA256 of <time>.<raw body>, keyed with the signing secret, whsec_… and all.

GitHub: paste the raw body as the message, the secret as the key, and what follows sha256= into Check a signature. Stripe: sign t.body — the timestamp from the header, a dot, then the raw body — with the whole signing secret as the key, and compare against v1=.

Runs in your browser. Nothing you enter is sent anywhere.