Webhook Authentication Methods Explained

Every webhook endpoint is a publicly accessible URL. Without authentication, anyone who discovers that URL can send forged events to your application — triggering fake payment confirmations, creating phantom orders, or injecting malicious data. Webhook authentication verifies that incoming requests genuinely originate from the expected source and have not been modified in transit. This guide covers every authentication method used in production webhook systems, with implementation examples for each.
Why Webhook Authentication Is Non-Negotiable
Consider the risk of an unauthenticated webhook endpoint that processes payment events. An attacker discovers your endpoint URL (through network inspection, leaked logs, or brute force) and sends:
{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_fake_12345",
"amount": 99900,
"status": "succeeded",
"customer": "attacker@example.com"
}
}
}
Without authentication, your system processes this as a legitimate payment — fulfilling an order, granting premium access, or updating financial records. The damage is real and immediate.
Authentication prevents this by ensuring every incoming webhook can be cryptographically verified as authentic before any processing occurs.
Method 1: HMAC-SHA256 Signature Verification
HMAC (Hash-based Message Authentication Code) signature verification is the industry standard for webhook authentication. It provides both sender verification (proves the request came from the expected source) and payload integrity (proves the payload was not modified in transit).
How It Works
Shared secret establishment
During webhook registration, the provider generates a shared secret (sometimes called a signing key or webhook secret). Both you and the provider have a copy of this key. It is never transmitted with webhook requests.
Provider signs the payload
When sending a webhook, the provider computes an HMAC-SHA256 hash of the request body (and sometimes a timestamp) using the shared secret. The resulting hash is included in a request header.
You verify the signature
When receiving the webhook, you compute the same HMAC-SHA256 hash using your copy of the shared secret and the raw request body. If your computed hash matches the one in the header, the webhook is authentic.
Generic HMAC-SHA256 Implementation
const crypto = require('crypto');
function verifyHmacSignature(secret, payload, receivedSignature) {
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Timing-safe comparison prevents timing attacks
const computed = Buffer.from(computedSignature, 'utf8');
const received = Buffer.from(receivedSignature, 'utf8');
if (computed.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(computed, received);
}
// Express middleware
function webhookAuthMiddleware(req, res, next) {
const signature = req.headers['x-webhook-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
if (!verifyHmacSignature(secret, req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
app.post('/webhook',
express.raw({ type: 'application/json' }),
webhookAuthMiddleware,
handleWebhook
);
Python Implementation
import hmac
import hashlib
from flask import Flask, request, abort
def verify_hmac_signature(secret: str, payload: bytes, received_signature: str) -> bool:
computed_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_signature, received_signature)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
if not signature:
abort(401, 'Missing signature')
if not verify_hmac_signature(
os.environ['WEBHOOK_SECRET'],
request.data,
signature
):
abort(401, 'Invalid signature')
# Process authenticated webhook
event = request.get_json()
process_event(event)
return {'received': True}, 200
Platform-Specific Signature Verification
Each major platform implements HMAC signatures slightly differently. Here is how to verify webhooks from the most popular providers.
Stripe Signature Verification
Stripe uses a custom signature scheme with a timestamp to prevent replay attacks:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhook/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
try {
// Stripe's library handles timestamp validation and signature verification
const event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
handleStripeEvent(event);
res.status(200).json({ received: true });
} catch (err) {
console.error('Stripe webhook verification failed:', err.message);
res.status(400).json({ error: `Webhook Error: ${err.message}` });
}
}
);
The Stripe signature header looks like: t=1708523400,v1=5257a869e7eceb.... The t is the timestamp, and v1 is the HMAC-SHA256 signature of {timestamp}.{payload}. Stripe's SDK handles all of this automatically.
Manual Stripe Verification (Without SDK)
function verifyStripeSignature(payload, header, secret) {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
const signature = parts.find(p => p.startsWith('v1=')).slice(3);
// Verify timestamp is recent (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
GitHub Signature Verification
GitHub uses X-Hub-Signature-256 with HMAC-SHA256:
function verifyGitHubSignature(payload, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
app.post('/webhook/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
const eventType = req.headers['x-github-event'];
handleGitHubEvent(eventType, event);
res.status(200).json({ received: true });
}
);
Shopify Signature Verification
Shopify uses X-Shopify-Hmac-Sha256 with a Base64-encoded signature:
function verifyShopifySignature(payload, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(computed, 'base64'),
Buffer.from(signature, 'base64')
);
}
app.post('/webhook/shopify',
express.raw({ type: 'application/json' }),
(req, res) => {
const hmacHeader = req.headers['x-shopify-hmac-sha256'];
if (!verifyShopifySignature(req.body, hmacHeader, process.env.SHOPIFY_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
handleShopifyEvent(event);
res.status(200).json({ received: true });
}
);
Notice the differences: Stripe prefixes the signature with v1= and includes a timestamp. GitHub prefixes with sha256=. Shopify uses Base64 encoding instead of hex. Always consult the provider's documentation for the exact signature format — a single encoding mismatch will cause all verifications to fail.
Method 2: API Keys in Headers
Some webhook providers authenticate using a static API key or token sent in a request header. This is simpler than HMAC but less secure because it only verifies the sender's identity — it does not verify payload integrity.
function verifyApiKey(req) {
const providedKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '');
const expectedKey = process.env.WEBHOOK_API_KEY;
if (!providedKey || !expectedKey) {
return false;
}
// Still use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(providedKey),
Buffer.from(expectedKey)
);
}
app.post('/webhook', (req, res) => {
if (!verifyApiKey(req)) {
return res.status(401).json({ error: 'Invalid API key' });
}
processWebhook(req.body);
res.status(200).json({ received: true });
});
Limitations of API key authentication:
- The key is transmitted with every request — if intercepted, it can be reused
- No payload integrity verification — an attacker could modify the payload without invalidating the key
- No replay protection — a captured request can be resent as-is
API keys are acceptable for low-risk webhooks but should not be relied on for financial or security-critical events.
Method 3: Basic Authentication
Some providers support HTTP Basic Authentication, encoding credentials in the webhook URL:
https://username:password@your-app.com/webhook
The provider sends the credentials in the Authorization header:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
function verifyBasicAuth(req) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Basic ')) {
return false;
}
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const [username, password] = decoded.split(':');
return username === process.env.WEBHOOK_USERNAME
&& password === process.env.WEBHOOK_PASSWORD;
}
Limitations: Similar to API keys — credentials are sent with every request. HTTPS is mandatory (without it, credentials are visible in plaintext). This method is becoming less common.
Method 4: OAuth 2.0 Bearer Tokens
Some advanced webhook systems use OAuth 2.0 bearer tokens for authentication. The provider obtains a token from your OAuth server before sending the webhook.
const jwt = require('jsonwebtoken');
function verifyOAuthToken(req) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { valid: false, error: 'Missing bearer token' };
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.OAUTH_PUBLIC_KEY, {
algorithms: ['RS256'],
audience: 'webhook-endpoint',
issuer: 'webhook-provider.com'
});
return { valid: true, claims: decoded };
} catch (error) {
return { valid: false, error: error.message };
}
}
OAuth is most common in enterprise integrations and provides strong authentication with token expiration and scope-based authorization. However, it is more complex to set up than HMAC signatures.
Method 5: Mutual TLS (mTLS)
Mutual TLS goes beyond standard HTTPS by requiring both the server and the client (webhook sender) to present valid TLS certificates. This provides the strongest authentication at the transport layer.
# Nginx configuration for mutual TLS on webhook endpoint
server {
listen 443 ssl;
server_name your-app.com;
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
# Require client certificate
ssl_client_certificate /etc/ssl/webhook-provider-ca.crt;
ssl_verify_client on;
location /webhook {
# Only requests with valid client certificates reach here
proxy_pass http://localhost:3000;
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
}
}
When to use mTLS:
- Enterprise integrations with strict security requirements
- Financial services and healthcare (regulatory compliance)
- Internal microservice communication where both parties are known
Limitations:
- Complex certificate management (issuance, rotation, revocation)
- Not widely supported by SaaS webhook providers
- Requires infrastructure-level configuration (load balancer or reverse proxy)
Method 6: Webhook Verification Tokens
Some providers (like Meta/Facebook) use a verification challenge during webhook registration:
app.get('/webhook/meta', (req, res) => {
// Meta sends a GET request to verify your endpoint
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.META_VERIFY_TOKEN) {
// Respond with the challenge value to confirm ownership
res.status(200).send(challenge);
} else {
res.status(403).send('Verification failed');
}
});
// Actual webhook events come via POST
app.post('/webhook/meta', (req, res) => {
// Verify the payload signature (Meta uses SHA256)
const signature = req.headers['x-hub-signature-256'];
// ... verify signature
res.status(200).send('EVENT_RECEIVED');
});
This two-step approach first verifies endpoint ownership (via the challenge) and then authenticates each delivery (via signatures).
Building a Multi-Provider Authentication System
In production, you often receive webhooks from multiple providers, each with different authentication methods. Build a modular verification system:
class WebhookAuthenticator {
constructor() {
this.verifiers = new Map();
}
register(provider, verifier) {
this.verifiers.set(provider, verifier);
}
verify(provider, req) {
const verifier = this.verifiers.get(provider);
if (!verifier) {
throw new Error(`No verifier registered for provider: ${provider}`);
}
return verifier(req);
}
}
const auth = new WebhookAuthenticator();
// Register provider-specific verifiers
auth.register('stripe', (req) => {
const sig = req.headers['stripe-signature'];
return stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
});
auth.register('github', (req) => {
const sig = req.headers['x-hub-signature-256'];
return verifyGitHubSignature(req.body, sig, process.env.GITHUB_WEBHOOK_SECRET);
});
auth.register('shopify', (req) => {
const sig = req.headers['x-shopify-hmac-sha256'];
return verifyShopifySignature(req.body, sig, process.env.SHOPIFY_WEBHOOK_SECRET);
});
// Route webhooks to the appropriate verifier
app.post('/webhook/:provider',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = auth.verify(req.params.provider, req);
processEvent(req.params.provider, event);
res.status(200).json({ received: true });
} catch (error) {
console.error(`Auth failed for ${req.params.provider}:`, error.message);
res.status(401).json({ error: 'Authentication failed' });
}
}
);
When using separate endpoints per provider (like /webhook/stripe and /webhook/github), each endpoint can be configured with the appropriate authentication method. This is cleaner than a single endpoint that must detect the provider from headers. It also makes it easier to apply different rate limits and processing logic per provider.
Secret Rotation Without Downtime
Webhook signing secrets should be rotated periodically. Here is how to do it without missing any events:
Generate a new secret
Create a new signing secret in the provider's dashboard. The old secret remains active.
Update your verification code to accept both secrets
function verifyWithRotation(payload, signature, secrets) {
for (const secret of secrets) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))) {
return true;
}
}
return false;
}
// Accept either the old or new secret
const secrets = [
process.env.WEBHOOK_SECRET_NEW,
process.env.WEBHOOK_SECRET_OLD
];
verifyWithRotation(payload, signature, secrets);
Verify the new secret is working
Monitor your logs to confirm webhooks signed with the new secret are being verified successfully.
Remove the old secret
Once you are confident the new secret is working, remove the old secret from the provider's dashboard and from your code.
Webhookify simplifies authentication monitoring by logging every incoming webhook with full headers — including signature headers. When rotating secrets, you can verify in real time that new signatures are being sent and verified correctly, without adding custom logging code. If a verification failure occurs during rotation, you will get an immediate alert via Telegram, Discord, Slack, or email.
Authentication Method Comparison
| Method | Sender Verification | Payload Integrity | Replay Protection | Complexity | |---|---|---|---|---| | HMAC-SHA256 | Yes | Yes | With timestamp | Low | | API Key | Yes | No | No | Very Low | | Basic Auth | Yes | No | No | Very Low | | OAuth 2.0 | Yes | No (unless signed) | Token expiry | High | | Mutual TLS | Yes | Yes (TLS) | Session-based | Very High | | Verification Token | Endpoint only | With HMAC | No | Low |
For most webhook integrations, HMAC-SHA256 with timestamp validation provides the best balance of security and simplicity.
Monitor Webhook Authentication in Real Time
Webhookify logs every webhook delivery with full headers, signatures, and verification status. Get instant alerts when authentication fails, and debug signature issues without deploying code changes.
Get Started FreeFurther Reading
- Webhook Security Best Practices — comprehensive security checklist
- The Complete Guide to Webhooks — webhook fundamentals
- How Webhooks Work — the technical architecture
- Webhook Debugging Guide — troubleshoot authentication failures
- Webhook Testing Guide — test signature verification
Related Articles
- Webhook Security Best Practices
- How Webhooks Work: A Technical Deep Dive
- How to Set Up Stripe Webhook Notifications
- How to Set Up GitHub Webhook Notifications
- Security Event Monitoring with Webhooks