Unsigned webhooks let any attacker impersonate your event source and inject arbitrary payloads. CWE-345 (Insufficient Verification of Data Authenticity) and CWE-347 (Improper Verification of Cryptographic Signature) describe the failure. OWASP API Security Top 10 2023 API8 (Security Misconfiguration) covers unsigned webhook receipt as a trust boundary violation. Platforms like Stripe, GitHub, and Twilio all sign their webhooks with HMAC-SHA256 for this reason. Without signature verification, attackers who discover your webhook endpoint can send fake events — triggering account upgrades, order fulfillments, or permission changes — by simply POSTing correctly shaped JSON. Timing-safe comparison is required; string equality is vulnerable to timing attacks.
Info severity because webhook forgery requires knowing your endpoint URL, but the impact — injecting arbitrary business events — can be critical to business logic.
Compute and compare the HMAC-SHA256 signature using crypto.timingSafeEqual — regular string comparison leaks timing information that can be used to forge valid signatures bit by bit.
// src/app/api/webhooks/route.ts
import crypto from 'crypto'
export async function POST(req: Request) {
const rawBody = await req.text() // read as text before parsing
const sig = req.headers.get('x-webhook-signature') ?? ''
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex')
const sigBuffer = Buffer.from(sig, 'hex')
const expectedBuffer = Buffer.from(expected, 'hex')
// timingSafeEqual requires same-length buffers
if (sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return new Response('Unauthorized', { status: 401 })
}
const payload = JSON.parse(rawBody)
// ... process verified payload
}
ID: api-security.design-security.webhook-signatures
Severity: info
What to look for: Enumerate every relevant item. If the API sends webhooks or receives webhooks from external services, check whether webhook payloads are signed and verified. Look for HMAC-SHA256 signature generation and verification logic.
Pass criteria: At least 1 of the following conditions is met. All webhooks are signed using HMAC-SHA256 or equivalent. The receiving endpoint verifies the signature before processing the payload.
Fail criteria: Webhooks are sent or received without signature verification.
Skip (N/A) when: The API does not send or receive webhooks.
Detail on fail: "Webhooks sent without signature — recipients cannot verify payload authenticity" or "Webhook endpoint does not verify HMAC signature before processing payload"
Remediation: Implement webhook signing and verification:
import crypto from 'crypto'
// Sending webhooks
const sendWebhook = async (payload, webhookUrl, secret) => {
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex')
await fetch(webhookUrl, {
method: 'POST',
headers: {
'X-Signature': signature,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
}
// Receiving webhooks
app.post('/api/webhooks/external', (req, res) => {
const signature = req.headers['x-signature']
const computed = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex')
if (signature !== computed) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Process webhook
res.json({ success: true })
})