Seguridad de webhooks de Stripe
Este skill ejecuta la parte de Stripe del escáner vibe-preflight (scripts/preflight.mjs junto a este archivo,
Node 20+, sin dependencias, sin red). Encuentra manejadores de webhook en manejadores de ruta de Next.js, pages/api,
Express y Supabase Edge Functions y verifica dos cosas en el código. El resto de esta página es una revisión que realizas leyendo el manejador, porque un escaneo de patrones no puede probarlo.
1. Ejecutar el escaneo
Desde la raíz del proyecto:
node ${CLAUDE_SKILL_DIR}/scripts/preflight.mjs . --only stripe --fail-on warning --format json
Claude Code completa ${CLAUDE_SKILL_DIR}. Otros agentes: usa la carpeta de este skill, por ejemplo
.agents/skills/stripe-webhook-security o .claude/skills/stripe-webhook-security.
| Regla | Nivel | Significado |
|---|---|---|
stripe-webhook-unverified | error | El manejador actúa sobre eventos de Stripe y nunca llama a constructEvent / constructEventAsync. Cualquier persona que encuentre la URL puede enviar un checkout.session.completed falso. |
stripe-webhook-parsed-body | warning | La firma se verifica, pero contra un cuerpo ya analizado. Stripe firma los bytes exactos que envió, por lo que los eventos reales fallan con 400 y alguien termina eliminando la verificación. |
--only stripe encuentra un manejador por nombres de eventos de Stripe, una lectura del encabezado stripe-signature, o una ruta de webhook en un archivo que menciona Stripe. Si reporta 0 errors pero el usuario sabe que tiene un webhook, encuentra el manejador tú mismo y realiza la revisión a continuación de todas formas.
2. Corregir la verificación
Manejador de ruta (Next.js App Router, Remix, Hono y otros manejadores tipo fetch):
export async function POST(request: Request) {
const body = await request.text(); // cuerpo sin procesar, nunca request.json()
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Falta la firma", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Firma inválida", { status: 400 });
}
// manejar evento
return Response.json({ received: true });
}
Express: registra la ruta del webhook con express.raw({ type: "application/json" }) antes de
app.use(express.json()), y pasa req.body (un Buffer entonces) a constructEvent.
Next.js pages/api: export const config = { api: { bodyParser: false } }; y lee la solicitud sin procesar en un
Buffer antes de verificar.
Supabase Edge Functions y otros entornos Deno / edge:
await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, Stripe.createSubtleCryptoProvider()).
3. Revisar el resto leyendo el manejador
El escáner no verifica estos puntos. Mira el código e informa al usuario lo que encontraste para cada uno.
- Secreto.
STRIPE_WEBHOOK_SECRET(whsec_...) proviene de variables de entorno del servidor, nunca de una variableNEXT_PUBLIC_/VITE_o del código fuente. El modo de prueba y el modo en vivo tienen diferentes secretos, al igual que cada endpoint y la CLIstripe listen. - Repetición.
constructEventrechaza una firma más antigua que su tolerancia (300 segundos por defecto). Omite el argumento de tolerancia o mantenlo pequeño. Nunca captures el error y continúes. - Idempotencia. Stripe puede entregar el mismo evento más de una vez. Haz que la escritura sea naturalmente repetible (un upsert con la clave del id de Stripe, estableciendo un estado en lugar de agregar créditos) o almacena
event.iden una tabla con una restricción única y omite los ids que ya hayas visto. Revisa cuidadosamente cualquier cosa que agregue: créditos, saldo, stock. - Orden. Los eventos pueden llegar desordenados. Para suscripciones, obtén el objeto actual de la API (
stripe.subscriptions.retrieve) o compara marcas de tiempo en lugar de confiar en el orden de los eventos. - Confianza. Toma el usuario y el plan del evento verificado (
client_reference_id,metadata, el id de cliente que almacenaste al finalizar la compra), nunca de una cadena de consulta o una segunda solicitud sin firma. La redirección a la página de éxito no prueba nada: otorga acceso desde el webhook. - Respuesta. Devuelve un 2xx rápidamente una vez que el evento se almacena o maneja. El trabajo lento va a una cola. Devuelve 400 solo para una firma incorrecta, para que los reintentos de Stripe sigan siendo significativos.
- Lista de eventos. Suscribe el endpoint solo a los eventos que el código maneja e ignora otros tipos sin error.
4. Reporte
Enumera los hallazgos del escáner, luego una línea por elemento de revisión: bien, corregido, o una pregunta para el usuario (por ejemplo "los créditos se agregan en cada entrega: ¿un evento repetido debería agregarlos de nuevo?"). Vuelve a ejecutar el escaneo después de la corrección y muestra su última línea. Para probar en real, sugiere stripe listen --forward-to localhost:3000/api/webhooks y stripe trigger checkout.session.completed con la CLI de Stripe.
Un falso positivo confirmado puede silenciarse con // vibe-preflight-ignore <rule-id> en la línea o la línea anterior, con la razón en el comentario.