Webhook Authentication Methods: HMAC, OAuth, and API Keys
Your webhook endpoint is a public URL. Without proper authentication, anyone can send fake events. Learn how to secure it with HMAC signatures, OAuth, and API keys.
HookWatch Team
February 25, 2026
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.timingSafeEqualprevents timing attacks - Store secrets securely — environment variables, not source code
Who uses HMAC:
- Stripe (
Stripe-Signatureheader) - GitHub (
X-Hub-Signature-256header) - Shopify (
X-Shopify-Hmac-SHA256header) - HookWatch (
X-HookWatch-Signatureheader)
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:
- Enter your webhook signing secret
- Paste or craft a payload
- Generate the correct HMAC signature
- 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
- Always use HMAC signatures when the provider supports it
- Verify the raw body — never the parsed version
- Use constant-time comparison to prevent timing attacks
- Check timestamps to prevent replay attacks
- Rotate secrets periodically and when team members leave
- Log verification failures — they could indicate an attack or a misconfiguration
- Always use HTTPS — regardless of which authentication method you use