[NewMeet PingPage — status pages for your users](https://pingpage.live/)

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:**

```javascript
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.

```javascript
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.

```javascript
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:

```javascript
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.

```bash
# 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
