GitHub Webhook Signature Verification Deep Dive

If you're building any kind of integration that reacts to events in GitHub, you're likely using webhooks. They're powerful, allowing your application to be instantly notified when code is pushed, a pull request is opened, or an issue is commented on. But with great power comes great responsibility – specifically, the responsibility to ensure that the webhook requests you receive are actually from GitHub and haven't been tampered with. This is where GitHub webhook signature verification comes in.

The Problem: Trusting Incoming Webhooks

Imagine your application processes GitHub events to trigger CI/CD pipelines, update project management tools, or send notifications. If an attacker could spoof a GitHub webhook, they could potentially:

  • Trigger unauthorized builds or deployments.
  • Inject malicious data into your systems.
  • Cause denial-of-service by flooding your endpoint with fake requests.

You might think, "Can't I just whitelist GitHub's IP addresses?" While GitHub does publish a list of IP ranges, this isn't a sufficient security measure on its own. IP addresses can be spoofed, and GitHub's ranges are broad, shared by many services, making IP whitelisting a weak defense against a determined attacker. You need a cryptographic guarantee that the request originated from GitHub and its content hasn't been altered.

How GitHub Webhook Signatures Work

GitHub addresses this trust problem by signing every webhook request. They do this using a shared secret and a cryptographic hash function. Here's the breakdown:

  1. Shared Secret: When you configure a webhook in GitHub, you provide a "Secret" string. This secret is known only to GitHub and your application.
  2. HMAC-SHA256: For every outgoing webhook request, GitHub takes the raw request body and computes an HMAC-SHA256 hash using your shared secret as the key.
  3. X-Hub-Signature-256 Header: GitHub then includes this computed hash in the X-Hub-Signature-256 header of the HTTP request, prefixed with sha256=. For example: X-Hub-Signature-256: sha256=7d38c117c....

Your job, as the receiver, is to perform the exact same computation and compare your result with the signature GitHub sent. If they match, you can be confident the request is authentic and untampered.

Step-by-Step Verification Process

Let's walk through the precise steps your application needs to take to verify a GitHub webhook signature:

  1. Retrieve Your Webhook Secret: This is the secret string you configured in GitHub for this specific webhook. It should be stored securely (e.g., in an environment variable or a secret manager), not hardcoded in your application.
  2. Get the Raw Request Body: This is crucial. You must use the exact, raw HTTP request body as received. Do not parse it into an object (e.g., JSON, YAML) and then serialize it back. Parsing can reorder keys, change whitespace, or alter formatting in subtle ways that will cause your computed signature to differ from GitHub's.
  3. Extract the Signature from the Header: Look for the X-Hub-Signature-256 header. It will typically be in the format sha256=<signature_hex_digest>. You'll need to parse this string to get just the hexadecimal digest.
  4. Compute Your Own HMAC-SHA256: Using your retrieved secret as the key and the raw request body as the message, compute an HMAC-SHA256 hash. The output should be a hexadecimal string.
  5. Compare Signatures: Compare the hexadecimal signature you computed with the one extracted from the X-Hub-Signature-256 header. They must be an exact, byte-for-byte match.

Concrete Example: Python Implementation

Here's how you might implement this in a Python Flask application. Notice the emphasis on request.get_data() to ensure we get the raw body.

import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)

# Load your secret from an environment variable (best practice!)
GITHUB_WEBHOOK_SECRET = os.environ.get('GITHUB_WEBHOOK_SECRET')

@app.route('/webhook', methods=['POST'])
def github_webhook():
    if GITHUB_WEBHOOK_SECRET is None:
        print("GITHUB_WEBHOOK_SECRET environment variable not set.")
        abort(500)

    # 1. Get the raw request body
    # THIS IS CRITICAL: ensure you get the raw bytes, not a parsed JSON object.
    payload_body = request.get_data()

    # 2. Get the signature from the X-Hub-Signature-256 header
    signature_header = request.headers.get('X-Hub-Signature-256')
    if not signature_header:
        print("X-Hub-Signature-256 header missing.")
        abort(400)

    try:
        # Extract the digest part (e.g., "sha256=..." -> "...")
        sha_name, github_signature = signature_header.split('=', 1)
        if sha_name != 'sha256':
            print(f"Unsupported signature algorithm: {sha_name}")
            abort(400)
    except ValueError:
        print("Invalid X-Hub-Signature-256 header format.")
        abort(400)

    # 3. Compute your own HMAC-SHA256 signature
    # Ensure the secret is bytes for hmac.new
    secret_bytes = GITHUB_WEBHOOK_SECRET.encode('utf-8')
    computed_signature = hmac.new(secret_bytes, payload_body, hashlib.sha256).hexdigest()

    # 4. Compare signatures
    # Use hmac.compare_digest to prevent timing attacks
    if not hmac.compare_digest(computed_signature, github_signature):
        print("Webhook signature verification failed.")
        abort(401)

    print("Webhook signature verified successfully!")
    # Process the webhook payload here
    # For example, if it's JSON: json_payload = request.get_json()
    return 'OK', 200

if __name__ == '__main__':
    # For local testing, set GITHUB_WEBHOOK_SECRET in your environment
    # e.g., export GITHUB_WEBHOOK_SECRET="your_secret_here"
    app.run(port=5000, debug=True)

Pitfalls and Edge Cases

Verification seems straightforward, but several common mistakes can lead to frustrating 401 Unauthorized errors:

  • Raw Body vs. Parsed Body: This is the most common pitfall. As mentioned, if you parse the JSON body and then try to re-serialize it, you're almost guaranteed to get a different byte string. JSON parsers might reorder keys, remove whitespace, or normalize escape sequences. Always use the exact raw bytes of the request body.
  • Encoding Issues: Ensure consistent encoding, typically UTF-8, for both the secret and the request body when computing the hash. Python's hmac.new expects bytes.
  • Timing Attacks: When comparing the computed signature with the received one, avoid simple string equality (==). This can be vulnerable to timing attacks, where an attacker can deduce information about the signature by measuring the time it takes for your comparison function to return. Use cryptographically secure comparison functions like hmac.compare_digest() in Python, crypto.timingSafeEqual() in Node.js, or similar functions in other languages.
  • Secret Management: Never hardcode your webhook secrets. Use environment variables, a dedicated secret manager (e.g., AWS Secrets Manager, HashiCorp Vault), or a secure configuration system. Rotate secrets periodically.
  • Multiple Webhooks, Same Endpoint: If you have multiple GitHub repositories or organizations sending webhooks to the same endpoint, they might use different secrets. You'll need a way to identify which secret to use. GitHub includes an X-GitHub-Delivery header, which is a unique UUID for each delivery. You could potentially use this (or information from the payload itself, if trusted after verification) to look up the correct secret in a database.
  • Replay Attacks: