NewMeet PingPage — status pages for your users

Every webhook endpoint is a publicly accessible URL. If someone discovers it, they can send fake events to your server. Without authentication, your application has no way to distinguish a legitimate webhook from a forged one.

This isn't a theoretical risk. Webhook URL patterns are predictable (/webhooks/stripe, /api/hooks/github), and attackers actively scan for them.

The Three Approaches

1. HMAC Signatures (Recommended)

HMAC (Hash-based Message Authentication Code) is the gold standard for webhook authentication. The sender signs each request using a shared secret, and your server verifies the signature before processing.

How it works:

Provider                          Your Server
   │                                  │
   │  POST /webhooks/stripe           │
   │  X-Signature: sha256=abc123...   │
   │  Body: {"event": "payment"}    │
   │ ─────────────────────────────►   │
   │                                  │
   │                   Compute HMAC of body
   │                   using shared secret
   │                   Compare with header
   │                                  │
   │              200 OK              │
   │  ◄─────────────────────────────  │

Implementation:

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

const expectedBuffer = Buffer.from(expected, 'hex');
  const signatureBuffer = Buffer.from(signature, 'hex');

if (expectedBuffer.length !== signatureBuffer.length) {
    return false;
  }

return crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}

// Express middleware
app.post('/webhooks/stripe', (req, res) => {
  const signature = req.headers['x-signature']
    ?.replace('sha256=', '');

if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }

const rawBody = req.rawBody; // Need raw body, not parsed
  if (!verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

// Signature valid — process the event
  handleEvent(req.body);
  res.status(200).json({ received: true });
});

Critical details:

  • Use the raw body — verifying against the parsed/re-serialized body will fail because JSON serialization isn't deterministic
  • Use constant-time comparison — crypto.timingSafeEqual prevents timing attacks
  • Store secrets securely — environment variables, not source code

Who uses HMAC:

  • Stripe (Stripe-Signature header)
  • GitHub (X-Hub-Signature-256 header)
  • Shopify (X-Shopify-Hmac-SHA256 header)
  • HookWatch (X-HookWatch-Signature header)

2. OAuth 2.0 Bearer Tokens

Some webhook providers authenticate using OAuth tokens. Instead of signing the payload, the sender includes a bearer token that your server validates against the provider's authorization server.

app.post('/webhooks/provider', async (req, res) => {
  const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
    return res.status(401).json({ error: 'Missing token' });
  }

// Validate token with the provider
  const isValid = await validateToken(token);
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid token' });
  }

handleEvent(req.body);
  res.status(200).json({ received: true });
});

Pros:

  • Token rotation without changing webhook configuration
  • Fine-grained scopes and permissions
  • Standard protocol with library support

Cons:

  • Requires a network call to validate each webhook (unless using JWTs)
  • More complex setup than HMAC
  • Latency added by token validation

Who uses OAuth:

  • Microsoft Graph
  • Zoom
  • Salesforce

3. API Keys

The simplest approach: include a secret key as a header or query parameter. Your server checks that the key matches.

app.post('/webhooks/service', (req, res) => {
  const apiKey = req.headers['x-api-key'];

if (apiKey !== process.env.WEBHOOK_API_KEY) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

handleEvent(req.body);
  res.status(200).json({ received: true });
});

Pros:

  • Dead simple to implement
  • No cryptographic operations
  • Easy to rotate

Cons:

  • Key is sent in every request — if intercepted, an attacker can forge webhooks
  • No payload integrity — even with a valid key, the body could be tampered with in transit
  • Must use HTTPS (but you should always use HTTPS anyway)

Comparison

Factor HMAC OAuth API Key
Payload integrity Yes No No
Implementation complexity Medium High Low
Network calls required None Yes (unless JWT) None
Replay protection With timestamp With token expiry No
Industry adoption Very high Medium Low

Preventing Replay Attacks

Even with valid signatures, an attacker who captures a webhook request can replay it. Defend against this by checking the timestamp:

function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const webhookTime = new Date(timestamp).getTime();
  const now = Date.now();
  const difference = Math.abs(now - webhookTime);

return difference <= toleranceSeconds * 1000;
}

app.post('/webhooks/stripe', (req, res) => {
  const timestamp = req.headers['x-webhook-timestamp'];

// Reject webhooks older than 5 minutes
  if (!verifyTimestamp(timestamp)) {
    return res.status(401).json({ error: 'Webhook too old' });
  }

// Include timestamp in HMAC verification
  const signedPayload = timestamp + '.' + req.rawBody;
  if (!verifyWebhookSignature(signedPayload, signature, secret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

handleEvent(req.body);
  res.status(200).json({ received: true });
});

Testing Authentication with HookWatch

The HookWatch Sandbox includes a built-in Signature Tester that lets you:

  1. Enter your webhook signing secret
  2. Paste or craft a payload
  3. Generate the correct HMAC signature
  4. Verify your handler accepts it

This makes it easy to test your verification logic without setting up the full provider integration.

# Use the CLI to send a signed test webhook
hookwatch endpoints create \
  --name "Signed Endpoint" \
  --destination "https://myapp.com/webhooks" \
  --signing-secret "whsec_your_secret_here"

Recommendations

  1. Always use HMAC signatures when the provider supports it
  2. Verify the raw body — never the parsed version
  3. Use constant-time comparison to prevent timing attacks
  4. Check timestamps to prevent replay attacks
  5. Rotate secrets periodically and when team members leave
  6. Log verification failures — they could indicate an attack or a misconfiguration
  7. Always use HTTPS — regardless of which authentication method you use

Tags: security, authentication, hmac, oauth, webhooks