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 module
const 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 module
import 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/hmac
package 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
        end

PHP

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:

  1. Go to your GitHub repository
  2. Click SettingsWebhooks
  3. Click on the webhook you want to verify
  4. Scroll to the Secret field
  5. If you're creating a new webhook, enter a strong random secret
  6. 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?

FeatureShopifyStripeGitHub
HeaderX-Shopify-Hmac-SHA256Stripe-SignatureX-Hub-Signature-256
EncodingBase64HexHex
AlgorithmHMAC-SHA256HMAC-SHA256HMAC-SHA256
TimestampNoYes (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.