Tutoriel
20 juin 20268 min de lecture

Comment sécuriser les webhooks Stripe dans Next.js et Supabase ?

Les erreurs de validation sur les webhooks Stripe permettent à des attaquants de simuler des paiements réussis. Voici comment blinder vos endpoints dans Next.js et Supabase.

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 :

  1. Mettre à niveau le compte de l'utilisateur dans votre base de données Supabase ou PostgreSQL.
  2. Enregistrer l'identifiant client (stripe_customer_id) et le statut de l'abonnement.
  3. 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 jamais req.json().
  • Secret dédié : Le STRIPE_WEBHOOK_SECRET commence par whsec_... et ne doit jamais être confondu avec votre clé d'API sk_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.id dé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éthode constructEvent() 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 table processed_events de 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.

Audit Express GVO

Testez la sécurité de votre flux Stripe

Vérifiez si vos endpoints de webhooks et vos variables d'environnement exposent des failles publiques.

100% gratuit & sans inscriptionRésultats en 30 secondesCompatible Lovable, Bolt & Cursor

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.

Foire aux questions sur les webhooks Stripe

Quelle est la différence entre ma clé secrète Stripe et mon secret de webhook ?
Votre clé secrète (sk_live_...) sert à initier des requêtes depuis votre serveur vers Stripe (ex: créer un lien de paiement). Le secret de webhook (whsec_...) est une clé cryptographique partagée qui permet à votre serveur de vérifier mathématiquement qu'une requête entrante provient bien de Stripe et n'a pas été falsifiée en cours de route.
Pourquoi Stripe renvoie-t-il une erreur 'Signature verification failed' ?
Dans 95 % des cas, cette erreur survient parce que votre serveur a parsé le corps de la requête en JSON avant la vérification. Stripe exige que la signature soit validée sur les octets bruts (raw text) de la requête. Utilisez req.text() dans Next.js plutôt que req.json().
Que se passe-t-il si mon serveur ne répond pas 200 à Stripe ?
Stripe considère que la notification a échoué et retente l'envoi de manière échelonnée sur une durée pouvant aller jusqu'à 72 heures. Si votre code plante ou met plus de quelques secondes à répondre, Stripe risque de désactiver automatiquement votre webhook en le marquant comme défaillant.
Comment tester mes webhooks Stripe en local pendant le développement ?
Installez la CLI officielle de Stripe et utilisez la commande 'stripe listen --forward-to localhost:3000/api/webhooks/stripe'. La CLI vous fournira un secret de webhook temporaire commençant par whsec_test_ à renseigner dans votre fichier .env.local.
Puis-je me contenter de vérifier le paiement sur la page de redirection /success ?
Absolument pas. N'importe quel internaute peut saisir directement l'adresse 'votresite.com/success' dans son navigateur pour tenter de débloquer le service. Seul le webhook Stripe direct de serveur à serveur fait juridiquement et techniquement foi.
Passez à l'action

Votre application présente-t-elle ces failles ?

Ne lancez pas votre SaaS à l'aveugle. Choisissez le niveau d'audit adapté à l'état d'avancement de votre projet.

100% Gratuit • Sans compte30 secondes

Audit Express de Surface

Vérifiez instantanément si votre URL publique ou votre repo expose des clés privées, des headers non sécurisés ou des endpoints ouverts.

  • Détection des clés API visibles dans le code compilé
  • Audit des en-têtes HTTP, CORS et SSL
  • Score de sécurité et rapport immédiat
Lancer l'Audit Express Gratuit
Recommandé pour la ProductionGitHub Connect

Audit Complet de Repository

Passez au crible l'intégralité de vos composants privés, de vos migrations SQL et de vos Server Actions avec remédiation instantanée.

  • Contrôle d'étanchéité Supabase RLS & PostgreSQL
  • Détection des Server Actions ouvertes & failles IDOR
  • Prompts de correction prêts à coller pour Cursor & Lovable
Découvrir les offres d'audit