Webhook Security Testing Checklist: 12 Checks Every Developer Needs

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 refused

What 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}    413

Run 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.

Read more