Back to Blog
securityHMACcryptographywebhooks

HMAC Signatures Explained: Securing Your Webhooks End-to-End

How HMAC-SHA256 signing works, why timing-safe comparison matters, how AES-256-GCM protects your secrets at rest, and the exact security model GetHook uses in production.

N
Nadia Kowalski
Security Engineer
November 14, 2025
9 min read

Webhook security has two sides: verifying that incoming events are genuine (signature verification), and proving to your customers that outgoing events came from you (outbound signing). Both use the same cryptographic primitive: HMAC-SHA256.

This post explains how it works, where it's commonly implemented wrong, and how to do it correctly.


What Is HMAC?

HMAC stands for Hash-based Message Authentication Code. It combines a secret key with a message to produce an authentication code:

HMAC-SHA256(key, message) → signature

The signature has two properties:

  1. Authentication — only someone who knows the key can produce the same signature
  2. Integrity — any modification to the message produces a different signature

For webhooks, the "message" is the request body, and the "key" is a shared secret known only to you and the webhook provider.


How Stripe Signs Webhooks

Stripe's format is the de-facto standard and worth understanding in detail:

Stripe-Signature: t=1614556800,v1=abc123...,v0=def456...
  • t — Unix timestamp of when Stripe sent the event
  • v1 — HMAC-SHA256 signature
  • v0 — Deprecated MD5 signature (ignore this)

The signed payload is: {timestamp}.{body}

python
signed_payload = f"{timestamp}.{request_body}"
signature = hmac.new(secret.encode(), signed_payload.encode(), sha256).hexdigest()

Why include the timestamp? This is a replay attack prevention mechanism. If an attacker captures a valid webhook request and resends it later, the timestamp in the signature will be old. You should reject events where t is more than 5 minutes old:

go
const maxAge = 5 * time.Minute

webhookTime := time.Unix(timestamp, 0)
if time.Since(webhookTime) > maxAge {
    return errors.New("webhook timestamp too old — possible replay attack")
}

Signature Verification by Provider

Each provider has a slightly different format. Here's a reference:

GitHub

X-Hub-Signature-256: sha256=abc123...
go
func verifyGitHub(body []byte, header, secret string) bool {
    // Strip "sha256=" prefix
    sig := strings.TrimPrefix(header, "sha256=")
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sig))
}

Shopify

X-Shopify-Hmac-SHA256: base64encodedhmac==
go
func verifyShopify(body []byte, header, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(header))
}

Slack

X-Slack-Signature: v0=abc123... X-Slack-Request-Timestamp: 1614556800
go
func verifySlack(body []byte, sig, timestamp, secret string) bool {
    baseString := fmt.Sprintf("v0:%s:%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(baseString))
    expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sig))
}

The Timing Attack Problem

Here's the most common security mistake in signature verification:

go
// ❌ VULNERABLE — timing side-channel
if computedSig != receivedSig {
    return false
}

Why is this dangerous? String comparison in most languages returns false as soon as it finds the first non-matching character. This means:

  • A signature with a correct first byte takes slightly longer to reject than one with a wrong first byte
  • An attacker can measure these tiny timing differences and learn the correct signature one byte at a time
  • This is a timing attack, and it works against any string comparison done byte-by-byte
go
// ✅ SAFE — constant-time comparison
if !hmac.Equal([]byte(computedSig), []byte(receivedSig)) {
    return false
}

hmac.Equal (Go), hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js) all compare the full string in constant time regardless of where the first mismatch occurs.

LanguageConstant-time function
Gocrypto/hmac.Equal(a, b []byte) bool
Pythonhmac.compare_digest(a, b)
Node.jscrypto.timingSafeEqual(a, b)
RubyActiveSupport::SecurityUtils.secure_compare(a, b)
PHPhash_equals($a, $b)
JavaMessageDigest.isEqual(a, b)

Storing Secrets Safely: AES-256-GCM

Signatures solve the transmission problem. But where do you store the webhook signing secret?

If you store secrets in plaintext in your database:

  • A database breach exposes all customer signing secrets
  • An attacker can forge webhooks to any of your customers' endpoints

GetHook uses AES-256-GCM (Authenticated Encryption) to encrypt secrets at rest.

go
func Encrypt(plaintext, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key) // 32-byte key → AES-256
    if err != nil {
        return nil, err
    }

    gcm, err := cipher.NewGCM(block) // GCM mode for authenticated encryption
    if err != nil {
        return nil, err
    }

    nonce := make([]byte, gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return nil, err
    }

    // Seal appends: nonce || ciphertext || GCM tag
    ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
    return ciphertext, nil
}

Why GCM specifically? AES-GCM provides authenticated encryption — it encrypts the data AND provides a MAC (authentication tag). If the ciphertext is tampered with in the database, decryption fails with an authentication error. This prevents padding oracle attacks and ciphertext manipulation.


Key Management

AES encryption is only as strong as your key management. GetHook requires an ENCRYPTION_KEY environment variable (64 hex characters = 32 bytes) and refuses to start without it.

Best practices:

  1. Never commit the key to git — use .env files excluded from version control
  2. Rotate keys on schedule — re-encrypt all secrets with the new key on rotation
  3. Different keys per environment — dev, staging, and production should have different keys
  4. Key derivation for per-customer secrets — use HKDF to derive unique keys per customer rather than using the master key directly

The Full Security Model

Here's GetHook's complete security model for secrets:

┌─────────────────────────────────────────────────────────────────┐ │ INBOUND (Provider → GetHook) │ │ │ │ Provider signs with HMAC-SHA256 │ │ GetHook verifies signature using stored (encrypted) secret │ │ Signature verified before event enters queue │ │ Invalid signatures rejected with 401 │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ OUTBOUND (GetHook → Your Customer) │ │ │ │ Per-destination signing secret (encrypted with AES-256-GCM) │ │ Decrypted in memory only at delivery time │ │ Signs payload with Stripe-compatible format │ │ Secret never returned in API responses after creation │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ AT REST (Database) │ │ │ │ Signing secrets: AES-256-GCM encrypted │ │ API keys: SHA-256 hashed (one-way, not recoverable) │ │ Event payloads: stored as-is (customer controls what to send) │ └─────────────────────────────────────────────────────────────────┘

Security Checklist for Webhook Implementers

If you're building a webhook system, verify each item:

Inbound verification:

  • Compute HMAC over the raw request body (before JSON parsing)
  • Use constant-time comparison
  • Validate timestamp (reject events older than 5 minutes)
  • Return 401 on signature mismatch (not 400, which some providers treat as "permanent failure")
  • Log signature failures (security monitoring signal)

Secret storage:

  • Encrypt secrets at rest (AES-256-GCM minimum)
  • Never log secrets
  • Never return secrets in API responses after initial creation
  • Separate encryption keys from application secrets

Outbound signing:

  • Sign with per-customer secrets (not a global key)
  • Include timestamp in signed payload (prevent replay)
  • Document the signature format for your customers

Why GetHook Handles This For You

Implementing all of the above correctly is achievable. Doing it for every provider format (Stripe, GitHub, Shopify, Slack, Twilio, PagerDuty, and the dozen others your customers will ask for) is a significant ongoing maintenance burden.

Provider formats change. Shopify updated their signature format in 2023. GitHub deprecated SHA-1 and moved to SHA-256. Slack added additional signed fields. If you're maintaining your own verification engine, you need to track all of these changes.

GetHook maintains provider-specific verification presets and keeps them up to date. Your handler never needs to know which format the event came in — verification happens at the gateway before the event ever reaches your code.

Set up signature verification in 5 minutes →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.