Verify GitHub Webhook Signature
Verify GitHub webhook signatures using SHA-256 HMAC. Get clear error messages when something's wrong.
🔒 Your webhook secret never leaves your browser. All verification happens client-side.
Find this in your GitHub repo → Settings → Webhooks
Code Examples
Copy-paste examples for verifying GitHub webhook signatures in your application.
Node.js / Express
Using crypto moduleconst express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use raw body for webhook routes
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!signature) {
return res.status(400).send('Missing signature');
}
// Compute expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(req.body);
const expectedSignature = `sha256=${hmac.digest('hex')}`;
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Parse and handle the event
const event = JSON.parse(req.body.toString());
console.log('Event:', event.action);
res.json({ received: true });
}
);
app.listen(3000);Next.js App Router
Route handler// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('x-hub-signature-256');
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
// Compute expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(body);
const expectedSignature = `sha256=${hmac.digest('hex')}`;
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse and handle the event
const event = JSON.parse(body);
console.log('GitHub event:', event.action);
return NextResponse.json({ received: true });
}Python / Flask
Using hmac moduleimport hmac
import hashlib
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Hub-Signature-256')
secret = os.getenv('GITHUB_WEBHOOK_SECRET').encode()
if not signature:
return jsonify({'error': 'Missing signature'}), 400
# Compute expected signature
payload = request.get_data()
expected_signature = 'sha256=' + hmac.new(
secret,
payload,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(signature, expected_signature):
return jsonify({'error': 'Invalid signature'}), 401
# Parse and handle the event
event = request.get_json()
print(f"GitHub event: {event.get('action')}")
return jsonify({'success': True}), 200
if __name__ == '__main__':
app.run(port=3000)Go
Using crypto/hmacpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
)
func main() {
http.HandleFunc("/webhook", handleWebhook)
log.Fatal(http.ListenAndServe(":3000", nil))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Hub-Signature-256")
if signature == "" {
http.Error(w, "Missing signature", http.StatusBadRequest)
return
}
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
secret := os.Getenv("GITHUB_WEBHOOK_SECRET")
// Compute expected signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expectedMAC := mac.Sum(nil)
expectedSignature := "sha256=" + hex.EncodeToString(expectedMAC)
// Timing-safe comparison
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse and handle the event
var event map[string]interface{}
if err := json.Unmarshal(payload, &event); err != nil {
http.Error(w, "Error parsing JSON", http.StatusBadRequest)
return
}
fmt.Printf("GitHub event: %v\n", event["action"])
w.WriteHeader(http.StatusOK)
}Ruby / Rails
Using OpenSSL# config/routes.rb
post '/webhook', to: 'webhooks#github'
# app/controllers/webhooks_controller.rb
require 'openssl'
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def github
signature = request.headers['X-Hub-Signature-256']
unless signature
render json: { error: 'Missing signature' }, status: 400
return
end
payload = request.body.read
secret = ENV['GITHUB_WEBHOOK_SECRET']
# Compute expected signature
expected_signature = 'sha256=' + OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
secret,
payload
)
# Timing-safe comparison
unless Rack::Utils.secure_compare(signature, expected_signature)
render json: { error: 'Invalid signature' }, status: 401
return
end
# Parse and handle the event
event = JSON.parse(payload)
puts "GitHub event: #{event['action']}"
render json: { success: true }, status: 200
end
endPHP
Using hash_hmac<?php
// webhook.php
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (empty($signature)) {
http_response_code(400);
die('Missing signature');
}
$payload = file_get_contents('php://input');
$secret = getenv('GITHUB_WEBHOOK_SECRET');
// Compute expected signature
$expectedSignature = 'sha256=' . hash_hmac(
'sha256',
$payload,
$secret
);
// Timing-safe comparison
if (!hash_equals($signature, $expectedSignature)) {
http_response_code(401);
die('Invalid signature');
}
// Parse and handle the event
$event = json_decode($payload, true);
error_log('GitHub event: ' . ($event['action'] ?? 'unknown'));
http_response_code(200);
echo json_encode(['success' => true]);
?>🔒 Security reminders:
- Always use timing-safe comparison functions to prevent timing attacks
- Verify signatures BEFORE parsing the JSON payload
- Use the raw request body (don't parse/modify it first)
- Keep your webhook secret secure (use environment variables)
Frequently Asked Questions (FAQ)
Where do I find my GitHub webhook secret?
To find or set your webhook secret:
- Go to your GitHub repository
- Click Settings → Webhooks
- Click on the webhook you want to verify
- Scroll to the Secret field
- If you're creating a new webhook, enter a strong random secret
- If you forgot your secret, you need to update it (GitHub doesn't show existing secrets)
Tip: Generate a secure secret using: openssl rand -hex 32
Should I use SHA-1 or SHA-256 signatures?
Always use SHA-256 for new webhooks. GitHub provides both headers for backward compatibility:
X-Hub-Signature-256- SHA-256 HMAC (recommended)X-Hub-Signature- SHA-1 HMAC (deprecated)
SHA-1 is cryptographically weaker and deprecated. GitHub still sends it for compatibility, but you should verify using SHA-256. This tool only supports SHA-256 verification.
Why does verification fail in my code but work here?
This is almost always caused by your framework parsing or modifying the request body before verification. GitHub signatures must be verified against the exact raw bytes received.
Common issues:
- JSON parsing: Don't parse with
JSON.parse()before verifying - Body parser middleware: Express, Next.js, etc. may auto-parse JSON
- Character encoding: Converting string → bytes → string changes the data
- Trailing whitespace: Some frameworks strip whitespace from bodies
Fix: Access the raw request body as a Buffer or Uint8Array before any parsing.
What's the difference between Shopify, Stripe, and GitHub signatures?
| Feature | Shopify | Stripe | GitHub |
|---|---|---|---|
| Header | X-Shopify-Hmac-SHA256 | Stripe-Signature | X-Hub-Signature-256 |
| Encoding | Base64 | Hex | Hex |
| Algorithm | HMAC-SHA256 | HMAC-SHA256 | HMAC-SHA256 |
| Timestamp | No | Yes (5 min tolerance) | No |
How do I test GitHub webhooks locally?
Several ways to test locally:
1. Use ngrok or similar tunneling service:
ngrok http 3000 # Copy the HTTPS URL to GitHub webhook settings
2. Use GitHub CLI (gh):
gh webhook forward --repo=owner/repo --events=push --url=http://localhost:3000/webhook
3. Manually craft test payloads:
Use this tool to verify that your manually created signatures match what you expect. Just make sure to use the exact same secret and payload bytes.