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:
- Provider signs the raw request body using a shared secret
- Signature goes in a header (
Stripe-Signature,X-Hub-Signature-256, etc.) - 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.