What Is Webhook Security?
Why AI-generated webhook handlers are one of the biggest security gaps in vibe-coded apps — and how to close it.
TL;DR
Webhooks are HTTP requests that services like Stripe, GitHub, and Clerk send to your app when something happens — a payment completes, a repo gets pushed to, a user signs up. Without verification, anyone who finds your webhook URL can send fake requests and trigger real actions — crediting accounts, creating users, or marking orders as paid. Webhook security means checking that each incoming request actually came from the service it claims to be from.
Why AI Coders Need to Know This
If you've built anything with Stripe payments, Clerk authentication, or GitHub integrations, you're already using webhooks. They're the standard way these services tell your app "hey, something happened."
Here's the problem: when you ask Claude or ChatGPT to "add Stripe webhooks to my app," the AI almost always generates a handler that works — but isn't secure. It'll receive the event, parse the JSON, and process it perfectly. What it usually skips:
- Signature verification — checking the request actually came from Stripe
- Replay protection — preventing the same event from being processed twice
- Idempotency — handling duplicate deliveries without duplicate side effects
The AI-generated code passes every test because real Stripe events arrive correctly. You ship it, it works, and you move on. But your webhook endpoint is a public URL — and anyone who finds it can send whatever they want to it.
This isn't theoretical. It's one of the most common security gaps in AI-generated code.
Real Scenario: The Fake Payment Problem
Here's how this plays out. You're building a SaaS app. You ask your AI to integrate Stripe payments. The AI generates a beautiful webhook handler:
// ⚠️ INSECURE — What AI typically generates
app.post('/api/webhooks/stripe', express.json(), (req, res) => {
const event = req.body;
if (event.type === 'payment_intent.succeeded') {
// Grant the user premium access
await grantPremiumAccess(event.data.object.customer);
console.log('Payment succeeded, access granted!');
}
res.json({ received: true });
});
This works perfectly in development. Stripe sends real events, your handler processes them, users get premium access after paying. Ship it.
The problem: Your webhook URL is https://yourapp.com/api/webhooks/stripe. That URL is guessable (most AI follows this naming pattern). Now anyone can do this:
# An attacker sends a fake "payment succeeded" event
curl -X POST https://yourapp.com/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{
"type": "payment_intent.succeeded",
"data": {
"object": {
"customer": "cus_attacker123"
}
}
}'
Your app can't tell the difference between this fake request and a real one from Stripe. It grants premium access. No payment happened. The attacker just got your product for free — and they can do it for any customer ID they want.
Scale this up: fake refund events, fake subscription cancellations, fake invoice payments. The damage depends on what your webhook handler does, and most handlers do important things.
What Webhook Signatures Actually Do
Every major service that sends webhooks has a solution for this. Here's how it works in plain English:
The Signature Process (Simple Version)
- You and Stripe share a secret key. When you set up webhooks in your Stripe dashboard, Stripe gives you a "webhook signing secret" (starts with
whsec_). Only you and Stripe know this key. - Stripe signs every request. Before sending a webhook, Stripe takes the request body + a timestamp and runs them through a one-way mathematical function using the shared secret. This produces a unique "signature" string.
- Stripe includes the signature in the request header. It arrives as
Stripe-Signature. - Your code checks the signature. You run the same function on the request body using your copy of the secret. If your result matches the signature in the header — the request is legit. If not, someone faked it.
Think of it like a wax seal on a letter. The sender stamps it with their unique seal. You know what the seal should look like. If the seal doesn't match, someone else wrote the letter.
The important part: an attacker can't forge the signature because they don't have the secret key. They can send requests to your endpoint all day, but without the correct signature, your code will reject every one.
This same concept applies to webhooks from any service — GitHub uses X-Hub-Signature-256, Clerk uses svix-signature, and so on. Different header names, same principle.
What AI-Generated Webhook Code Typically Misses
When you ask an AI to build a webhook handler, here's what it usually gets wrong — ranked by how dangerous each gap is:
1. No Signature Verification (Critical)
The most common and most dangerous gap. The AI generates a handler that processes the raw request body without ever checking the signature header. Any HTTP request to your endpoint gets treated as a legitimate event.
The fix: Always verify the signature before processing any event. Every major webhook provider has a library that does this in one line.
2. Parsing JSON Before Verification (Subtle but Dangerous)
Sometimes the AI does include signature verification — but puts express.json() middleware before the verification step. This parses the raw body into a JavaScript object, which changes it. The signature was calculated on the raw body. Once parsed, the verification fails or gets skipped.
The fix: Use express.raw({ type: 'application/json' }) for webhook routes so you get the raw body for verification.
3. No Replay Protection (High Risk)
Even with valid signatures, an attacker could intercept a legitimate webhook and replay it later. Imagine a valid "payment succeeded" event being sent 100 times — your app credits the account 100 times.
The fix: Check the timestamp in the webhook header. Reject events older than 5 minutes. Stripe's library does this automatically.
4. No Idempotency (Medium Risk)
Webhook providers sometimes send the same event multiple times (network issues, retries). Without idempotency, your handler processes the same event twice — double-crediting accounts, sending duplicate emails, creating duplicate records.
The fix: Store processed event IDs and check before processing. If you've seen that event ID before, skip it.
5. Logging Sensitive Data (Sneaky Risk)
AI loves to add console.log(req.body) for debugging. Webhook bodies often contain customer emails, payment details, and internal IDs. These end up in your server logs, log aggregation services, and error tracking tools — all places attackers look.
The fix: Log event types and IDs, not full request bodies. Never log in production what you wouldn't post on a billboard.
Secure Webhook Code: What It Actually Looks Like
Here's the difference between what AI usually generates and what you should actually ship. Both examples are in Node.js with Express, since that's what most AI tools produce for Stripe webhook integrations.
Stripe Webhook Verification (The Right Way)
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// ⚠️ IMPORTANT: Use express.raw(), NOT express.json()
// Signature verification needs the raw request body
app.post('/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
// Step 1: Get the signature from the header
const signature = req.headers['stripe-signature'];
// Step 2: Verify the signature
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw body (Buffer)
signature, // from header
webhookSecret // your signing secret
);
} catch (err) {
// Signature invalid — this request is NOT from Stripe
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send('Invalid signature');
}
// Step 3: Check for duplicate events (idempotency)
const alreadyProcessed = await checkEventProcessed(event.id);
if (alreadyProcessed) {
return res.json({ received: true, duplicate: true });
}
// Step 4: Process the verified event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
default:
// Log unknown event types, don't crash
console.log('Unhandled event type:', event.type);
}
// Step 5: Mark event as processed
await markEventProcessed(event.id);
res.json({ received: true });
}
);
What each part does:
express.raw()— keeps the body as raw bytes so the signature check worksstripe.webhooks.constructEvent()— Stripe's library checks the signature AND the timestamp (replay protection built in). If either fails, it throws an error.checkEventProcessed()— your function that looks up the event ID in your database to prevent duplicate processing- The
switchstatement — only processes event types you actually care about markEventProcessed()— stores the event ID so you won't process it again
Generic HMAC Verification (For Any Service)
Not every service has a nice library like Stripe. For services that send an HMAC-SHA256 signature (GitHub, custom webhooks, etc.), here's the manual approach:
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
// Create the expected signature using your secret
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Compare signatures in a timing-safe way
// (prevents attackers from guessing the signature one character at a time)
const expected = Buffer.from(expectedSignature, 'hex');
const received = Buffer.from(signatureHeader, 'hex');
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
// Usage in an Express route
app.post('/api/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256']?.replace('sha256=', '');
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!signature || !verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Signature verified — safe to process
const event = JSON.parse(req.body);
// ... handle the event
res.json({ received: true });
}
);
What's happening here:
crypto.createHmac()— creates a signature using the same method the service usedcrypto.timingSafeEqual()— compares signatures safely. A regular===comparison can leak information about the correct signature through timing differences. This function always takes the same amount of time regardless of how many characters match.- The
?.replace('sha256=', '')— GitHub prefixes their signature withsha256=, so we strip that off before comparing
You don't need to understand the cryptography. You need to know: always use timingSafeEqual instead of === for signature comparison, and always use express.raw() for webhook routes.
What to Tell Your AI
The easiest way to get secure webhook code is to ask for it explicitly. Here are prompts that work:
Prompt 1: New Stripe Webhook Handler
"Build a Stripe webhook handler in Express that verifies the webhook signature using the Stripe library, rejects requests with invalid signatures, uses express.raw() for the body parser on the webhook route only, checks for duplicate events using the event ID, and handles payment_intent.succeeded and customer.subscription.deleted events. Store the webhook secret in an environment variable."
Prompt 2: Audit Existing Webhook Code
"Review my webhook handler for security issues. Check for: missing signature verification, using express.json() instead of express.raw(), no replay protection, no idempotency check, and any sensitive data being logged. Show me what to fix."
Prompt 3: Generic Webhook Security
"Add HMAC-SHA256 signature verification to my webhook endpoint. Use crypto.timingSafeEqual for the comparison, not ===. Get the secret from an environment variable. Reject any request where the signature doesn't match and return a 401 status."
Prompt 4: Idempotency Layer
"Add an idempotency layer to my webhook handler. Before processing any event, check if the event ID has already been processed by looking it up in my database. If it has, return 200 without processing again. After successful processing, store the event ID. Use a database table called processed_webhook_events."
The key insight: AI generates secure code when you specifically ask for security features. It just doesn't add them on its own. Being explicit about signature verification, raw body parsing, and idempotency in your prompts is the difference between a secure webhook and an open door.
For more on managing the secrets these webhook handlers need, see API key management.
Webhook Security Checklist
| Check | What It Prevents | Priority |
|---|---|---|
| Verify signature on every request | Fake/forged webhook events | Critical |
| Use raw body (not parsed JSON) for verification | Signature check silently failing | Critical |
| Check event timestamp (reject old events) | Replay attacks | High |
| Track processed event IDs | Duplicate processing | High |
Use timingSafeEqual for comparisons |
Timing-based signature guessing | Medium |
| Store webhook secret in environment variable | Secret exposure in code/repos | Critical |
| Don't log raw request bodies | Sensitive data in logs | Medium |
| Return 200 quickly, process async if needed | Timeout retries causing duplicates | Medium |
Frequently Asked Questions
What is webhook security?
Webhook security is verifying that incoming webhook requests actually come from the service that claims to have sent them. Services like Stripe, GitHub, and Clerk sign each webhook request using a shared secret key. Your code checks that signature before processing the event. Without this check, anyone who knows your webhook URL can send fake events and trigger real actions in your app — like crediting accounts without payment or creating unauthorized users.
Does AI-generated code include webhook security?
Usually not. When you ask an AI to build a Stripe webhook handler, it typically generates code that receives the event and processes it — but skips signature verification, replay protection, and idempotency checks. The code works perfectly in testing because real Stripe events arrive correctly. But it's wide open to fake requests in production. You need to explicitly ask for signature verification in your prompt to get secure webhook code.
What happens if I don't verify webhook signatures?
Anyone who discovers your webhook endpoint URL can send fake events to it. For a payment webhook, this means someone could send fake "payment_intent.succeeded" events to credit accounts, grant premium access, or mark orders as paid — all without actually paying. For an authentication webhook like Clerk, fake events could create unauthorized user accounts or trigger privilege escalations.
How do webhook signatures work?
The service (like Stripe) takes the webhook request body and a secret key that only you and Stripe know. It runs them through a mathematical function (HMAC-SHA256) that produces a unique signature string. Stripe includes this signature in the request headers. Your code runs the same function on the body using your copy of the secret key. If your result matches the one in the header, the request is legitimate. If not, someone faked it. You don't need to understand the math — just use the verification library the service provides.
What is replay protection for webhooks?
Replay protection prevents attackers from capturing a legitimate, properly-signed webhook request and sending it again later. Even if the signature is valid, a replayed "payment succeeded" event would credit an account again. Protection works two ways: checking the timestamp in the webhook header and rejecting requests older than a few minutes (Stripe's library does this automatically), and tracking processed event IDs in your database so you never process the same event twice.
What to Learn Next
- What Is a Webhook? — understand what webhooks are and why services use them
- What Are Stripe Webhooks? — deep dive into the most common webhook integration for vibe coders
- Security Basics for AI Coders — the full picture of what AI-generated code gets wrong on security
- What Is API Key Management? — how to safely store the webhook secrets your handlers need
- What Is Authentication? — the broader context of verifying identity in your apps
Last updated: March 27, 2026. Code examples tested with Stripe Node.js SDK v17 and Express 4.x / 5.x.