If your webhook endpoint accepts any POST request without verification, anyone who discovers the URL can send fake payloads. Forged requests can trigger false order completions, fake payment confirmations, or data corruption.
This guide covers three layers of webhook security: HMAC (Hash-based Message Authentication Code) signature verification, token-based authentication, and request validation.
Step 1: Understand Webhook Security Risks
Your webhook endpoint is a public URL. Without protection, attackers can exploit several vulnerabilities:
- Spoofing - An attacker sends a crafted payload that mimics a real event
- Replay attacks - An attacker captures a legitimate request and resends the captured data
- Enumeration - An attacker guesses signal tokens or task IDs through brute force
The goal is to confirm two things: the request originated from a trusted source, and nobody has tampered with the content.
Step 2: Verify HMAC Signatures on Incoming Webhooks
AsyncQueue signs every callback and onComplete webhook with your team’s webhook secret. The signature appears in the X-AsyncQueue-Signature header.
The format is: t=<timestamp>,v1=<hex_signature>
const crypto = require('crypto');
function verifySignature(body, signature, secret) { const parts = Object.fromEntries( signature.split(',').map(p => p.split('=')) );
const timestamp = parts.t; const expected = parts.v1;
// Recreate the signature const payload = `${timestamp}.${body}`; const computed = crypto .createHmac('sha256', secret) .update(payload) .digest('hex');
// Timing-safe comparison if (!crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected))) { return false; }
// Reject requests older than 5 minutes (replay protection) const age = Math.floor(Date.now() / 1000) - parseInt(timestamp); if (age > 300) { return false; }
return true;}
app.post('/api/webhook', (req, res) => { const signature = req.headers['x-asyncqueue-signature']; const rawBody = req.rawBody; // must preserve raw body for verification
if (!signature || !verifySignature(rawBody, signature, WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); }
// Safe to process const data = JSON.parse(rawBody); // ... handle event res.json({ received: true });});Important: You must verify against the raw request body, not a parsed-then-re-serialized version. JSON serialization can reorder fields, which breaks the signature.
Step 3: Use Signal Tokens for Wait-for-Signal Workflows
When you create a task with waitForSignal: true, you receive a one-time signal token. This token serves as the authentication credential, with no API key required to send the signal.
// Signal tokens are 256-bit random values// Only the hash is stored - the raw token is returned once
const { task, signalToken } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/start-process', waitForSignal: true, maxWaitTime: 3600,});
// Store securely - treat like a passwordawait db.workflows.update(workflowId, { signalToken });Security properties of signal tokens:
| Property | Detail |
|---|---|
| Entropy | 256 bits (64 hex characters) |
| Storage | SHA-256 hash only, raw token never persisted |
| Usage | One-time, deleted after signal is processed |
| Expiry | Auto-expires after maxWaitTime seconds |
| Rate limiting | Signal endpoint is rate-limited by IP |
When to use signal tokens vs HMAC verification:
- Use HMAC verification when AsyncQueue calls your endpoint (callbacks, onComplete)
- Use signal tokens when your system calls the AsyncQueue signal endpoint
Step 4: Add Request Validation and Rate Limiting
Beyond signature verification, add these defensive layers:
Validate the payload shape:
app.post('/api/webhook', (req, res) => { // After signature verification... const data = JSON.parse(rawBody);
// Validate expected fields exist if (!data.taskId || !data.status) { return res.status(400).json({ error: 'Malformed payload' }); }
// Validate status is an expected value const validStatuses = ['completed', 'failed', 'timeout']; if (!validStatuses.includes(data.status)) { return res.status(400).json({ error: 'Invalid status' }); }
// Validate the task belongs to your system const order = await db.orders.findOne({ taskId: data.taskId }); if (!order) { return res.status(404).json({ error: 'Unknown task' }); }
// Process... res.json({ received: true });});Rate limit your webhook endpoints:
Even with signature verification, rate limiting adds defense in depth:
// Example: allow max 100 webhook requests per minute per IPconst rateLimit = require('express-rate-limit');
app.use('/api/webhook', rateLimit({ windowMs: 60 * 1000, max: 100, message: { error: 'Rate limit exceeded' },}));Step 5: Rotate Secrets Without Downtime
When you rotate your webhook secret, a brief window exists where in-flight requests might carry the old signature. Handle this transition gracefully:
// Keep both old and new secrets during rotationconst WEBHOOK_SECRETS = [ process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS, // remove after rotation settles].filter(Boolean);
function verifyWithAnySecret(body, signature) { return WEBHOOK_SECRETS.some(secret => verifySignature(body, signature, secret) );}Rotation steps:
- Generate a new secret via the AsyncQueue dashboard or API
- Add the new secret to your environment as
WEBHOOK_SECRET_CURRENT - Move the old secret to
WEBHOOK_SECRET_PREVIOUS - Deploy your updated code (now accepts both)
- After 24 hours, remove
WEBHOOK_SECRET_PREVIOUS