Webhook Security Testing Checklist: 12 Checks Every Developer Needs
Webhook endpoints are unauthenticated HTTP endpoints that receive data from external services. They're a common target for attacks — and most security issues stem from missing a few basic checks. This checklist covers 12 security tests every webhook implementation should pass.
1. Signature Verification Is Enforced
Risk: Anyone who knows your URL can send fake events.
Test:
it('rejects requests without signature header', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.send({ type: 'payment_intent.succeeded' });
expect(res.status).toBe(400);
});
it('rejects requests with invalid signature', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', 'v1=aaabbbccc,t=1234567890')
.send({ type: 'payment_intent.succeeded' });
expect(res.status).toBe(400);
});What to check: Does removing or corrupting the signature header return 4xx? If it returns 200, your verification isn't being enforced.
2. Timing-Safe Comparison Is Used
Risk: String comparison (===) leaks timing information. Attackers can brute-force your secret byte by byte.
Test: Review the code — this isn't something you can test from the outside. But verify with a code search:
# Find any signature comparisons using == or ===
grep -rn <span class="hljs-string">"signature.*==\|hmac.*==" src/webhooks/What to check: All HMAC comparisons must use crypto.timingSafeEqual() in Node.js, hmac.compare_digest() in Python, or equivalent.
3. Replay Attacks Are Blocked
Risk: An attacker intercepts a valid webhook and replays it later.
Test:
it('rejects requests with old timestamp', async () => {
const payload = JSON.stringify({ type: 'payment_intent.succeeded' });
const oldTimestamp = Math.floor(Date.now() / 1000) - 400; // 400s ago, beyond 300s tolerance
const signed = `${oldTimestamp}.${payload}`;
const sig = crypto.createHmac('sha256', secret).update(signed).digest('hex');
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', `t=${oldTimestamp},v1=${sig}`)
.send(payload);
expect(res.status).toBe(400);
});What to check: Requests older than your tolerance window (typically 300 seconds) should be rejected, even if the signature is valid.
4. Raw Body Is Used for Signature Verification
Risk: JSON parsing and re-serialization can change whitespace, breaking valid signatures — or silently accepting invalid ones.
Test: Send a payload with extra whitespace — it should succeed (the signature covers the raw bytes):
it('accepts payload with non-standard whitespace formatting', async () => {
// Build signature over this specific raw string
const rawPayload = '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_123"}}}';
const sig = buildSignature(rawPayload, secret);
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', sig)
.set('Content-Type', 'application/json')
.send(rawPayload); // Exact bytes used to sign
expect(res.status).toBe(200);
});What to check: Your handler reads req.body as a Buffer (raw bytes), not a parsed JSON object, before verifying the signature.
5. Rate Limiting Is Applied
Risk: Webhook endpoints can be flooded to exhaust resources or disguise an attack in noise.
Test:
it('rate limits excessive requests', async () => {
const requests = Array.from({ length: 200 }, () =>
request(app)
.post('/webhooks/stripe')
.set('stripe-signature', 'invalid')
.send({ type: 'test' })
);
const responses = await Promise.all(requests);
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
});What to check: After N requests in a short window, the endpoint should return 429 Too Many Requests.
6. Payload Size Is Limited
Risk: An attacker sends a 100MB payload to exhaust memory or crash the server.
Test:
it('rejects oversized payloads', async () => {
const largePayload = 'x'.repeat(10 * 1024 * 1024); // 10MB
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.send(largePayload);
expect(res.status).toBe(413); // Payload Too Large
});What to check: Webhook endpoints should have a size limit (typically 1-5MB). Express default is 100kb; increase it intentionally, don't leave it unlimited.
7. Sensitive Data Is Not Logged
Risk: Webhook payloads often contain PII (customer emails, names, addresses). Logging them creates compliance risk.
Test: Run a test webhook and inspect your logs:
it('does not log sensitive customer data', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', buildSig(validPayload, secret))
.send(validPayload);
const loggedContent = consoleSpy.mock.calls.join(' ');
expect(loggedContent).not.toContain('user@example.com');
expect(loggedContent).not.toContain('John Doe');
});What to check: Customer emails, names, card details, and addresses should not appear in application logs.
8. SSRF Is Not Possible via Webhook URL Configs
Risk: If your app lets users configure webhook URLs, an attacker can set the URL to http://169.254.169.254/latest/meta-data/ (AWS metadata) or internal services.
Test:
const ssrfUrls = [
'http://169.254.169.254/latest/meta-data/',
'http://localhost/admin',
'http://10.0.0.1/internal',
'file:///etc/passwd',
'http://0.0.0.0:3306'
];
for (const url of ssrfUrls) {
it(`blocks SSRF URL: ${url}`, async () => {
const res = await request(app)
.post('/api/webhooks/configure')
.set('Authorization', `Bearer ${userToken}`)
.send({ url, events: ['order.created'] });
expect(res.status).toBeGreaterThanOrEqual(400);
});
}What to check: Webhook URL configuration should validate against an allowlist of schemes (https only) and block private IP ranges.
9. Error Messages Don't Leak Internal Details
Risk: Detailed error messages reveal implementation details to attackers.
Test:
it('returns generic error on signature failure', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', 'invalid')
.send({ type: 'test' });
expect(res.body.error).not.toMatch(/HMAC|sha256|secret|key|token/i);
expect(res.body).not.toHaveProperty('stack');
});What to check: Error responses should say "Invalid request" — not "HMAC signature mismatch using SHA-256 key stripe_live_..."
10. Webhook Is Only Accessible Over HTTPS
Risk: HTTP allows signature interception and modification.
Test:
# Verify no HTTP endpoint exists
curl -v http://yourdomain.com/webhooks/stripe
<span class="hljs-comment"># Should redirect to HTTPS or return connection refusedWhat to check: Webhook endpoints must require HTTPS. HTTP should redirect to HTTPS or be blocked entirely.
11. Event Type Is Validated Before Processing
Risk: If you process any event type without checking, an attacker who bypasses signature verification (or a misconfigured provider) can trigger unintended actions.
Test:
it('ignores unknown event types', async () => {
const payload = JSON.stringify({ type: 'internal.admin_action', data: {} });
const sig = buildSignature(payload, secret);
const processSpy = jest.spyOn(eventProcessor, 'handle');
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', sig)
.send(payload);
expect(res.status).toBe(200); // Don't error — just ignore
expect(processSpy).not.toHaveBeenCalled();
});What to check: Unknown event types should return 200 (so the provider stops retrying) but not trigger any processing.
12. Database Writes Are Transactional
Risk: A partial failure (process payment but fail to send confirmation email) can leave data in an inconsistent state, which attackers can exploit.
Test:
it('rolls back on partial failure', async () => {
// Make email sending fail
jest.spyOn(emailService, 'send').mockRejectedValue(new Error('SMTP error'));
const res = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', buildSig(validPayload, secret))
.send(validPayload);
expect(res.status).toBe(500);
// Verify database was rolled back
const order = await db.getOrder(validPayload.data.object.id);
expect(order?.status).not.toBe('fulfilled'); // Partial state not committed
});What to check: Webhook side effects (database + API calls) should be wrapped in transactions. Partial completion is worse than no completion.
Running All Security Tests
Automate all 12 checks in your CI pipeline with HelpMeTest:
*** Test Cases ***
Webhook Rejects Invalid Signature
${response}= POST ${WEBHOOK_URL} headers={"stripe-signature": "invalid"}
Should Be Equal As Integers ${response.status_code} 400
Webhook Rejects Oversized Payload
${large_body}= Generate Random String 10485760 # 10MB
${response}= POST ${WEBHOOK_URL} data=${large_body}
Should Be Equal As Integers ${response.status_code} 413Run these on every deploy to catch regressions before they reach production.
Summary
| # | Check | Risk If Missing |
|---|---|---|
| 1 | Signature enforced | Fake events accepted |
| 2 | Timing-safe comparison | Secret brute-forced |
| 3 | Replay attack blocked | Old events replayed |
| 4 | Raw body for verification | Signature bypass |
| 5 | Rate limiting | Resource exhaustion |
| 6 | Payload size limit | OOM crash |
| 7 | No PII in logs | Compliance violation |
| 8 | SSRF protection | Internal service exposure |
| 9 | Generic error messages | Info disclosure |
| 10 | HTTPS only | Signature interception |
| 11 | Event type validation | Unintended actions |
| 12 | Transactional writes | Inconsistent state |
A webhook endpoint that passes all 12 checks is significantly more resilient to both malicious attacks and accidental misconfiguration.