Webhook Signature Verification
Every delivery is signed with HMAC-SHA256 using the secret you set when creating the subscription. Verify the signature before processing any payload.
The signature header
AuthWorx sets three headers on every delivery:
| Header | Example value | Description |
|---|---|---|
| X-Webhook-Signature | sha256=a3b4c5d6e7f8... |
HMAC-SHA256 hex digest of the raw request body, prefixed with sha256=. |
| X-Webhook-Event | member.joined |
The event name, for quick routing without parsing the body. |
| X-Delivery-Id | del_01WXYZ |
Unique delivery ID — use for idempotency checks. |
How the signature is computed
signature = "sha256=" + HMAC-SHA256(secret, rawRequestBody)
The HMAC is computed over the raw request body bytes before any JSON parsing. Do not use a parsed/re-serialized object — even a single added space will invalidate the signature.
🔒
Use constant-time comparison (
timingSafeEqual) when
comparing signatures. A naive === check is vulnerable to timing attacks
that can leak the secret.Node.js / Next.js
lib/webhook.ts
import crypto from 'crypto';
export function verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string,
secret: string
): boolean {
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
} catch {
return false; // buffers differ in length
}
}
app/api/webhooks/authworx/route.ts
import { NextRequest } from 'next/server';
import { verifyWebhookSignature } from '@/lib/webhook';
export async function POST(req: NextRequest) {
const rawBody = Buffer.from(await req.arrayBuffer());
const sig = req.headers.get('x-webhook-signature') ?? '';
if (!verifyWebhookSignature(rawBody, sig, process.env.AUTHWORX_WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody.toString());
switch (event.event) {
case 'member.joined':
// Provision the user in your database
// await db.users.upsert({ email: event.data.user.email, orgId: event.orgId });
break;
case 'member.removed':
// Revoke the user's access
// await db.users.deactivate({ email: event.data.user.email, orgId: event.orgId });
break;
case 'user.login':
// Log the login event
break;
default:
// Unknown event — ignore safely
break;
}
return new Response('OK', { status: 200 });
}
Python (Flask)
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['AUTHWORX_WEBHOOK_SECRET']
@app.route('/webhooks/authworx', methods=['POST'])
def authworx_webhook():
sig_header = request.headers.get('X-Webhook-Signature', '')
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
request.get_data(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig_header, expected):
abort(401)
event = request.get_json()
print(f"Received event: {event['event']}")
return 'OK', 200
Idempotency
Due to retries, your endpoint may receive the same delivery more than once.
Use the X-Delivery-Id header to detect and discard duplicates.
Node.js — idempotency check
const deliveryId = req.headers.get('x-delivery-id');
// Check if we've already processed this delivery
const alreadyProcessed = await cache.get(`webhook:${deliveryId}`);
if (alreadyProcessed) {
return new Response('OK', { status: 200 }); // acknowledge and skip
}
// Process the event ...
// Mark as processed (store for at least 24 hours)
await cache.set(`webhook:${deliveryId}`, true, { ex: 86400 });
Security checklist
- ✅ Verify
X-Webhook-Signatureon every request before doing anything - ✅ Use
timingSafeEqual(or equivalent) — never=== - ✅ Read the raw body bytes, not the parsed object
- ✅ Store
AUTHWORX_WEBHOOK_SECRETas a server-side environment variable only - ✅ Return 200 quickly — defer slow work to a background job
- ✅ Use the
X-Delivery-Idheader to implement idempotency