logo

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 password
await db.workflows.update(workflowId, { signalToken });

Security properties of signal tokens:

PropertyDetail
Entropy256 bits (64 hex characters)
StorageSHA-256 hash only, raw token never persisted
UsageOne-time, deleted after signal is processed
ExpiryAuto-expires after maxWaitTime seconds
Rate limitingSignal 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 IP
const 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 rotation
const 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:

  1. Generate a new secret via the AsyncQueue dashboard or API
  2. Add the new secret to your environment as WEBHOOK_SECRET_CURRENT
  3. Move the old secret to WEBHOOK_SECRET_PREVIOUS
  4. Deploy your updated code (now accepts both)
  5. After 24 hours, remove WEBHOOK_SECRET_PREVIOUS