Pour sécuriser un webhook Stripe dans Next.js ou Supabase, vous devez impérativement valider la signature cryptographique HMAC (stripe-signature) sur le flux brut du corps de la requête (req.text() ou raw buffer) à l'aide de stripe.webhooks.constructEvent(), rejeter toute requête non signée avec un statut HTTP 400, traiter l'idempotence des événements pour contrer les rejeux, et réserver l'attribution des privilèges payants aux événements formellement acquittés (checkout.session.completed avec payment_status === 'paid').
L'erreur la plus fréquente dans les applications générées par IA (Cursor, Lovable, Bolt) consiste à analyser la requête avec req.json() avant la vérification, ce qui altère les octets originaux et fait échouer la signature. Découragés par l'erreur, de nombreux créateurs finissent par désactiver la vérification, ouvrant la porte à la fraude aux abonnements.
Le danger de désactiver la signature Stripe
Si votre route de webhook n'exécute pas de validation HMAC stricte avec votre clé STRIPE_WEBHOOK_SECRET, n'importe quel internaute peut envoyer une requête POST simulée avec curl et débloquer un abonnement annuel sans jamais débourser le moindre centime.
Pourquoi les webhooks Stripe sont-ils la cible numéro 1 des attaquants ?
Dans une architecture SaaS moderne, le client (navigateur) ne doit jamais décider s'il a le droit d'accéder au service payant. Une redirection vers /success?session_id=... n'est qu'une indication visuelle : elle peut être forgée en tapant l'URL manuellement.
C'est le webhook Stripe — un message direct envoyé de serveur à serveur par Stripe — qui fait foi pour :
- Mettre à niveau le compte de l'utilisateur dans votre base de données Supabase ou PostgreSQL.
- Enregistrer l'identifiant client (
stripe_customer_id) et le statut de l'abonnement. - Révoquer l'accès en cas d'échec de paiement ou de résiliation (
customer.subscription.deleted).
Si ce canal de communication n'est pas cryptographiquement verrouillé, l'intégrité financière de votre SaaS est anéantie.
Le piège critique : l'altération du Raw Body dans Next.js
La signature envoyée par Stripe dans l'en-tête stripe-signature est un code d'authentification de message basé sur le hachage (HMAC SHA-256). Elle est calculée par Stripe sur la suite exacte d'octets du corps de la requête.
L'erreur commise par 90 % des assistants IA
Lorsque vous demandez à un agent de code de créer la route /api/webhooks/stripe, il écrit quasi systématiquement :
// ERREUR FATALE GÉNÉRÉE PAR L'IA :
export async function POST(req: Request) {
const body = await req.json(); // Altère le buffer original !
const signature = req.headers.get('stripe-signature');
// Cette fonction va échouer systématiquement :
const event = stripe.webhooks.constructEvent(
JSON.stringify(body),
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
}
Pourquoi cela échoue-t-il ? Parce que JSON.stringify(body) ne reconstitue jamais fidèlement la chaîne de caractères exacte reçue par le serveur (espaces, retours à la ligne, ordre des clés). Le hash calculé ne correspond plus à la signature de Stripe, déclenchant une erreur Webhook Error: Signature verification failed.
Face à cette erreur, l'IA ou le développeur pressé commente la ligne constructEvent et lit directement body.data.object, rendant le webhook totalement perméable.
Implémentation sécurisée dans Next.js (App Router)
Voici le code de référence pour sécuriser votre Route Handler dans Next.js 14, 15 et 16 (app/api/webhooks/stripe/route.ts) :
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createAdminClient } from '@/lib/supabase/admin';
export async function POST(req: NextRequest) {
// 1. Lire le corps brut sous forme de chaîne textuelle brute
const rawBody = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Signature Stripe manquante' },
{ status: 400 }
);
}
let event;
try {
// 2. Validation cryptographique HMAC obligatoire
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error(`Alerte sécurité : échec de signature webhook : ${err.message}`);
return NextResponse.json(
{ error: `Webhook Signature Error: ${err.message}` },
{ status: 400 }
);
}
// 3. Traitement sécurisé selon le type d'événement
const supabase = createAdminClient();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Toujours vérifier que le paiement a bien été honoré
if (session.payment_status !== 'paid') {
return NextResponse.json({ received: true });
}
const userId = session.metadata?.userId || session.client_reference_id;
if (!userId) {
console.error('Aucun userId associé à cette session de paiement');
break;
}
// Mise à niveau sécurisée en base
await supabase
.from('subscriptions')
.upsert({
user_id: userId,
stripe_customer_id: session.customer as string,
status: 'active',
plan: session.metadata?.plan || 'pro',
updated_at: new Date().toISOString()
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await supabase
.from('subscriptions')
.update({ status: 'canceled', updated_at: new Date().toISOString() })
.eq('stripe_customer_id', subscription.customer as string);
break;
}
default:
// Ignorer les autres événements
break;
}
// 4. Toujours renvoyer un statut 200 à Stripe pour accuser réception
return NextResponse.json({ received: true });
}
Implémentation dans une Supabase Edge Function (Deno)
Si vous développez avec une plateforme comme Lovable ou Bolt.new, vos webhooks s'exécutent souvent dans une Supabase Edge Function (supabase/functions/stripe-webhook/index.ts).
Le principe reste strictement identique :
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import Stripe from "https://esm.sh/stripe@14.21.0?target=deno";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
apiVersion: "2023-10-16",
httpClient: Stripe.createFetchHttpClient(),
});
serve(async (req) => {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("No signature header", { status: 400 });
}
// Lire le corps brut au format texte
const body = await req.text();
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!
);
if (event.type === "checkout.session.completed") {
// Traitement métier avec Supabase Admin Client
}
return new Response(JSON.stringify({ received: true }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
});
Règles d'or de la gestion des Webhooks Stripe
- Corps brut (Raw Body) : Utilisez impérativement
req.text()et jamaisreq.json(). - Secret dédié : Le
STRIPE_WEBHOOK_SECRETcommence parwhsec_...et ne doit jamais être confondu avec votre clé d'APIsk_live_.... - Réponse HTTP 200 rapide : Traitez l'événement et renvoyez un statut 200 sous 3 secondes pour éviter que Stripe ne réessaie la livraison en boucle.
- Idempotence : Enregistrez les
event.iddéjà traités pour empêcher les attaques par rejeu.
Les 4 attaques courantes sur les webhooks et comment s'en prémunir
Même avec une signature HMAC en place, trois autres vecteurs d'attaque doivent être neutralisés :
1. L'attaque par rejeu (Replay Attack)
Un pirate intercepte une requête de webhook légitime et la renvoie ultérieurement à votre serveur pour prolonger artificiellement un abonnement.
- Protection native Stripe : Stripe intègre un timestamp dans l'en-tête
stripe-signature. La méthodeconstructEvent()rejette automatiquement tout événement vieux de plus de 5 minutes (tolérance de tolérance configurable). - Table d'idempotence : Stockez l'identifiant unique de chaque événement traité (
event.id) dans une tableprocessed_eventsde votre base PostgreSQL pour ignorer les doublons.
2. L'absence de vérification du statut effectif de paiement
Recevoir un événement checkout.session.completed ne garantit pas à 100 % que les fonds ont été perçus (notamment lors de paiements différés par virement SEPA ou Boleto).
Vérifiez toujours la propriété session.payment_status === 'paid' avant de livrer le service, sous peine de donner un accès Premium à des paiements en attente qui échoueront plus tard.
3. La fuite du secret de webhook dans le frontend
Comme nous l'avons documenté dans notre guide pour détecter une clé API exposée, le secret de webhook STRIPE_WEBHOOK_SECRET ne doit jamais être préfixé par NEXT_PUBLIC_ ou VITE_. Il doit rester exclusivement consigné sur vos serveurs de production.
4. L'usurpation d'identité d'utilisateur (Metadata Spoofing)
Ne laissez jamais le client envoyer son propre user_id sans validation lors de la création de la session de paiement. L'identifiant utilisateur injecté dans metadata: { userId: session.user.id } doit provenir de la session authentifiée du serveur, et non d'un paramètre transmis par le navigateur du visiteur.
Testez la sécurité de votre flux Stripe
Vérifiez si vos endpoints de webhooks et vos variables d'environnement exposent des failles publiques.
Comment auditer automatiquement vos flux de paiement avec GVO
La vérification d'un endpoint de webhook est l'un des contrôles les plus difficiles à réaliser manuellement, car il s'exécute silencieusement en arrière-plan.
Pour vérifier l'exposition publique de vos routes et de vos certificats, commencez par un Audit Express gratuit.
Si vous souhaitez vous assurer que votre code source vérifie bien les signatures HMAC, isole les rôles administrateur dans Supabase et protège vos Server Actions, connectez votre repository GitHub à GoodVibesOnly (GVO). Notre moteur d'analyse statique détecte immédiatement les webhooks vulnérables et vous fournit le code exact à coller pour éliminer tout risque de fraude.