Webhook Signature Verification Testing: HMAC, JWT, and Beyond

Webhook Signature Verification Testing: HMAC, JWT, and Beyond

Webhook signature verification is the one security control standing between your server and arbitrary POST requests. If you're not testing it, you're trusting that your implementation is correct — which is how security bugs get shipped.

This guide covers how to test HMAC and JWT signature verification for the most common webhook providers, with working code examples.

Why Signature Verification Matters

Without signature verification, anyone who knows your webhook URL can:

  • Trigger actions in your system by sending fake events
  • Inject fraudulent payment confirmations
  • Spam your endpoint with malicious payloads

Signature verification lets you confirm the POST actually came from the provider — not an attacker.

How HMAC Webhook Signatures Work

Most providers (Stripe, GitHub, Shopify, Svix) use HMAC-SHA256:

  1. Provider signs the raw request body using a shared secret
  2. Signature goes in a header (Stripe-Signature, X-Hub-Signature-256, etc.)
  3. Your handler recomputes the HMAC and compares
const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signed = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signed, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Note timingSafeEqual — always use constant-time comparison to prevent timing attacks.

Testing Stripe Signatures

Stripe provides a test helper. Use it instead of computing signatures manually:

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = 'whsec_test_secret';

function buildStripeSignatureHeader(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signed = `${timestamp}.${payload}`;
  const sig = crypto
    .createHmac('sha256', secret)
    .update(signed)
    .digest('hex');
  return `t=${timestamp},v1=${sig}`;
}

describe('Stripe webhook signature', () => {
  it('accepts valid signature', async () => {
    const payload = JSON.stringify({
      type: 'payment_intent.succeeded',
      data: { object: { id: 'pi_123' } }
    });
    const header = buildStripeSignatureHeader(payload, endpointSecret);

    const res = await request(app)
      .post('/webhooks/stripe')
      .set('stripe-signature', header)
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(res.status).toBe(200);
  });

  it('rejects invalid signature', async () => {
    const payload = JSON.stringify({ type: 'payment_intent.succeeded' });

    const res = await request(app)
      .post('/webhooks/stripe')
      .set('stripe-signature', 'v1=invalidsignature,t=1234567890')
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(res.status).toBe(400);
  });

  it('rejects missing signature header', async () => {
    const payload = JSON.stringify({ type: 'payment_intent.succeeded' });

    const res = await request(app)
      .post('/webhooks/stripe')
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(res.status).toBe(400);
  });

  it('rejects replayed requests (timestamp too old)', async () => {
    const payload = JSON.stringify({ type: 'payment_intent.succeeded' });
    const oldTimestamp = Math.floor(Date.now() / 1000) - 400; // 400s ago
    const signed = `${oldTimestamp}.${payload}`;
    const sig = crypto
      .createHmac('sha256', endpointSecret)
      .update(signed)
      .digest('hex');
    const header = `t=${oldTimestamp},v1=${sig}`;

    const res = await request(app)
      .post('/webhooks/stripe')
      .set('stripe-signature', header)
      .send(payload);

    expect(res.status).toBe(400);
  });
});

The replay attack test is critical — Stripe rejects signatures older than 300 seconds. Test that your handler enforces this too.

Testing GitHub Webhook Signatures

GitHub uses X-Hub-Signature-256 with sha256= prefix:

function buildGithubSignature(payload, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(payload);
  return `sha256=${hmac.digest('hex')}`;
}

describe('GitHub webhook handler', () => {
  const secret = 'github_webhook_secret';

  it('accepts valid push event', async () => {
    const payload = JSON.stringify({
      ref: 'refs/heads/main',
      commits: [{ id: 'abc123', message: 'Fix bug' }]
    });
    const signature = buildGithubSignature(payload, secret);

    const res = await request(app)
      .post('/webhooks/github')
      .set('X-Hub-Signature-256', signature)
      .set('X-GitHub-Event', 'push')
      .send(payload);

    expect(res.status).toBe(200);
  });

  it('rejects tampered payload', async () => {
    const original = JSON.stringify({ ref: 'refs/heads/main' });
    const signature = buildGithubSignature(original, secret);
    const tampered = JSON.stringify({ ref: 'refs/heads/attacker' });

    const res = await request(app)
      .post('/webhooks/github')
      .set('X-Hub-Signature-256', signature)
      .send(tampered);  // Different payload, same signature

    expect(res.status).toBe(400);
  });
});

Testing Svix Signatures

Svix (used by many SaaS webhooks) has a typed SDK that handles verification:

const { Webhook } = require('svix');

describe('Svix webhook verification', () => {
  const secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw';

  it('verifies valid Svix payload', async () => {
    const wh = new Webhook(secret);
    const payload = JSON.stringify({ type: 'user.created', data: { id: 'usr_123' } });
    const headers = wh.sign('msg_test', new Date(), payload);

    const res = await request(app)
      .post('/webhooks/svix')
      .set(headers)
      .send(payload);

    expect(res.status).toBe(200);
  });
});

Testing JWT-Based Webhook Auth

Some providers (like Auth0 actions) use JWT:

const jwt = require('jsonwebtoken');

describe('JWT webhook verification', () => {
  const secret = 'webhook_jwt_secret';

  it('accepts valid JWT token', async () => {
    const token = jwt.sign(
      { iss: 'webhook-provider', aud: 'my-app', event: 'user.created' },
      secret,
      { expiresIn: '5m' }
    );

    const res = await request(app)
      .post('/webhooks/auth0')
      .set('Authorization', `Bearer ${token}`)
      .send({ event: 'user.created', data: {} });

    expect(res.status).toBe(200);
  });

  it('rejects expired token', async () => {
    const token = jwt.sign(
      { iss: 'webhook-provider', aud: 'my-app' },
      secret,
      { expiresIn: -1 }  // Already expired
    );

    const res = await request(app)
      .post('/webhooks/auth0')
      .set('Authorization', `Bearer ${token}`)
      .send({ event: 'user.created' });

    expect(res.status).toBe(401);
  });
});

What to Test: The Minimum Checklist

Every webhook signature implementation needs these test cases:

Test What It Catches
Valid signature → 200 Happy path works
Invalid signature → 400/401 Verification is enforced
Missing signature header → 400 Unsigned requests rejected
Tampered payload → 400 Body integrity is checked
Expired timestamp → 400 Replay attacks blocked
Wrong secret → 400 Key mismatch detected
Non-JSON payload → handles gracefully Parser errors don't crash

Common Implementation Bugs

Using === instead of timingSafeEqual — string comparison leaks timing information. Attackers can brute-force your secret byte by byte. Always use crypto.timingSafeEqual.

Parsing body before verification — HMAC is computed over the raw bytes. If you parse JSON first and stringify back, whitespace differences can cause false failures.

Not checking timestamp — If you skip timestamp validation, an attacker can capture a valid request and replay it days later.

Ignoring the v1= prefix — Stripe sends multiple v1= hashes to support key rotation. Your code should check if any of them matches.

Summary

Signature verification tests are not optional. They're the difference between a secure webhook endpoint and a backdoor. At minimum, test that:

  • Valid signatures are accepted
  • Invalid signatures return 4xx (not 500)
  • Missing headers are rejected
  • Replay attacks are blocked

Use the helper functions above to generate valid signatures in tests — never skip the signature in test environments.

Read more