
Le contexte
J'ai créé une app Lovable en 20 min pour tester les possibilités. Sans aucune indications techniques. Voici un composant du code. Il concerne le checkout Stripe de cette app. L'utilisateur clique sur "Upgrade", le panneau s'ouvre sur la droite, le formulaire Stripe est embarqué dedans, on paie, c'est fluide. Démo parfaite.
Le code a donc été généré par un agent IA. Il fait 50 lignes. Il marche.
Il a aussi 5 problèmes, dont 2 graves. Et c'est exactement le genre de code que je vois passer toutes les semaines.
On va le décortiquer ensemble.
Le code en question
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js';
import { getStripe, getStripeEnvironment } from '@/lib/stripe';
import { supabase } from '@/integrations/supabase/client';
import { useCallback } from 'react';
export function CheckoutSheet({
open,
onOpenChange,
quantity,
customerEmail,
userId,
companyId,
onComplete,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
quantity: number;
customerEmail?: string;
userId?: string;
companyId?: string;
onComplete?: () => void;
}) {
const fetchClientSecret = useCallback(async () => {
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
priceId: 'pro_monthly_per_seat',
quantity,
customerEmail,
userId,
companyId,
environment: getStripeEnvironment(),
returnUrl: `${window.location.origin}/admin/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
},
});
if (error || !data?.clientSecret)
throw new Error(error?.message || 'Failed to start checkout');
onComplete?.();
return data.clientSecret as string;
}, [quantity, customerEmail, userId, companyId, onComplete]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>Upgrade to Pro</SheetTitle>
</SheetHeader>
<div className="mt-4">
{open && (
<EmbeddedCheckoutProvider
stripe={getStripe()}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</div>
</SheetContent>
</Sheet>
);
}
À première vue, ça a l'air propre. Bien typé, hooks correctement utilisés, Stripe embarqué dans un Sheet shadcn. Un dev junior pourrait merger ça sans problème. Un agent IA aussi.
Premier signal pourtant : l'appel à Supabase est fait directement dans le composant. Pas de hook dédié, pas de service, pas de client API. L'UI parle au backend en direct. Ça paraît anodin, mais ça ne l'est pas : sans couche intermédiaire, il n'y a aucun endroit naturel où poser la frontière entre "ce que le client envoie" et "ce que le serveur doit revalider". Tout est mélangé, et il n'y a pas d'architecture.
Maintenant, regardons ce qui se passe vraiment.
Problème 1 : userId et companyId viennent du client (grave)
Regarde ces lignes :
body: {
...
userId,
companyId,
...
}
Le userId et le companyId sont passés en props au composant, donc ils viennent du client. Et le client les renvoie au serveur dans le body de la requête.
Pourquoi c'est un problème ? Parce que tout ce qui passe par le client peut être modifié. Un utilisateur ouvre les DevTools de son navigateur, intercepte la requête, change le userId par celui de quelqu'un d'autre, et clique sur "Pay". Stripe encaisse. Le serveur attache l'abonnement à l'autre user.
Concrètement : je peux abonner ton compte au plan Pro avec ma carte. Tu ne demandes rien, tu paies rien, mais tu deviens "Pro". Pire, je peux faire l'inverse : si la logique se base sur companyId, je peux attacher mon paiement à la société de quelqu'un d'autre et bénéficier de ses limites.
Ce qu'il faut faire à la place : le serveur ne fait JAMAIS confiance à ces valeurs. Il récupère lui-même le userId depuis la session Supabase auth (côté serveur), et il dérive le companyId à partir de la base de données, en vérifiant que l'utilisateur authentifié appartient bien à cette company.
Règle simple : l'identité, ça se lit côté serveur, jamais côté client.
Problème 2 : priceId hardcodé côté client (grave)
priceId: "pro_monthly_per_seat",
Le client envoie au serveur le prix qu'il veut payer. Là encore, n'importe qui peut intercepter cette requête et envoyer un autre priceId. Par exemple, le priceId du plan gratuit ou d'un plan d'essai à 1€.
Si le serveur fait confiance à cette valeur et crée la session Stripe avec, le user paie 1€ et reçoit le plan Pro à 99€/mois.
Ce qu'il faut faire à la place : le client envoie un identifiant abstrait ("pro_monthly", "team_yearly"). Le serveur a une table de correspondance hardcodée côté serveur entre cet identifiant et le vrai priceId Stripe. Comme ça, même si le client trafique l'appel, il ne peut pas inventer un priceId arbitraire.
Encore mieux : le serveur valide aussi que ce plan est compatible avec le user (par exemple, pas de plan "team" pour un user solo).
Problème 3 : onComplete est appelé avant le paiement (très grave)
C'est le bug le plus sournois du fichier. Regarde :
const fetchClientSecret = useCallback(async () => {
const { data, error } = await supabase.functions.invoke("create-checkout", { ... });
if (error || !data?.clientSecret) throw new Error(...);
onComplete?.(); // <-- ici
return data.clientSecret as string;
}, [...]);
fetchClientSecret, c'est la fonction qui démarre la session Stripe. Pas celle qui valide qu'elle est terminée. Elle est appelée au moment où le formulaire de paiement s'affiche, pas quand l'utilisateur a tapé sa carte.
Donc onComplete est appelé avant que le user paie quoi que ce soit. Si dans onComplete tu fais un truc du style "marquer cet user comme Pro" ou "envoyer le mail de bienvenue", tu le fais à n'importe qui qui ouvre le Sheet, sans qu'il ait sorti la carte bleue.
Cas d'attaque : j'ouvre le Sheet de paiement, je vois le formulaire Stripe, je le ferme sans payer. Mon compte vient de passer Pro pendant 30 jours. Gratuitement.
Ce qu'il faut faire à la place : la seule source de vérité pour "le paiement est passé", c'est un webhook Stripe (checkout.session.completed), reçu côté serveur. Le frontend, lui, peut juste afficher un toast "Paiement en cours". Toute la logique business (changer le plan, envoyer le mail, ouvrir l'accès) se déclenche dans le handler du webhook, jamais avant.
Problème 4 : le succès vient d'un paramètre URL
returnUrl: `${window.location.origin}/admin/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
Quand le paiement est fini, Stripe redirige le user sur cette URL. Tu peux être tenté, sur la page /admin/billing, de faire :
if (searchParams.get('checkout') === 'success') {
// marquer comme Pro / afficher confetti / etc.
}
Sauf que ?checkout=success est juste une chaîne de caractères dans l'URL. N'importe qui peut visiter /admin/billing?checkout=success&session_id=truc directement, sans passer par Stripe. Et déclencher la logique "succès".
C'est exactement le même problème que le précédent : tu fais confiance au client pour quelque chose qui doit être validé côté serveur.
Ce qu'il faut faire à la place : sur la page de retour, si tu veux confirmer le paiement, tu prends le session_id et tu appelles ton serveur avec. Le serveur, lui, demande à Stripe (via API) le statut réel de cette session. Et il répond "oui ou non". Le client ne devine pas le statut, il le demande.
Problème 5 : environment envoyé depuis le client
environment: getStripeEnvironment(),
Le client dit au serveur "tu travailles en test ou en live ?". C'est l'inverse de ce qui devrait se passer. Le serveur connaît son propre environnement (via une variable d'env, par exemple NODE_ENV ou un flag dédié). Il n'a pas à le recevoir du client.
Le risque : si le serveur fait confiance à ce paramètre, on peut potentiellement basculer entre l'env de test et l'env de prod en modifiant la requête. Selon comment c'est implémenté côté serveur, ça peut aller du benin (paiement de test refusé) au cassant (clés mélangées, comptes Stripe croisés).
Ce qu'il faut faire à la place : retire le champ. Le serveur sait où il vit.
Bonus : ce qui ne casse pas la sécurité, mais reste à nettoyer
- Pas de gestion d'erreur visible. Si
fetchClientSecretthrow, leEmbeddedCheckoutreste blanc. L'utilisateur voit un Sheet vide et ne sait pas pourquoi. Il faudrait au minimum un toast d'erreur et un état de fallback. - Pas de loading state. Entre le clic sur "Upgrade" et l'apparition du formulaire Stripe, il y a un appel réseau. Pendant ce temps, le Sheet est ouvert mais vide. Un spinner suffit.
- Le titre est en anglais ("Upgrade to Pro") dans une app a priori bilingue. Hard-codé, pas i18n. Détail, mais c'est la même paresse partout dans le fichier.
- Les interfaces sont inline dans la signature du composant. Les types des props sont déclarés à la volée. Pour un objet aussi central que "ce qu'il faut pour démarrer un checkout", ça mérite un fichier dédié (un
checkout.types.tspar exemple), histoire d'être réutilisable, testable, et lisible sans avoir à parser une signature à rallonge. - Stripe est câblé en dur dans le composant.
getStripe,EmbeddedCheckoutProvider,EmbeddedCheckout: tout est référencé directement. Si demain tu veux passer sur Lemon Squeezy, Paddle, ou ajouter un mode SEPA direct pour des clients enterprise, il faut refactorer la moitié du fichier. Une couche d'abstraction (un hookusePaymentSession, ou un composant<PaymentProvider />agnostique) permettrait de switcher de provider sans toucher à la UI. Cela peut parfois être overkill mais mérite d'être étudié en fonction de l'ampleur de l'application que l'on est en train de construire.
La règle qui résume tout
Si tu ne dois retenir qu'une chose de cet article :
Tout ce qui touche à l'argent ne se valide jamais depuis le client. Mais avec un Webhook.
Le client peut être pratique pour démarrer une action (ouvrir un formulaire, afficher une UI). Mais à la seconde où tu changes de l'état important côté serveur (un plan, un solde, un accès), la décision se prend côté serveur, sur la base d'événements signés (webhook Stripe) ou de données qu'il lit lui-même (session auth, base de données).
Tout ce qui vient du body de la requête utilisateur est suspect par défaut.
Cette règle, elle ne se devine pas en regardant un tutoriel. Elle se devine quand tu as déjà eu le problème. Et l'IA, elle, n'a jamais rencontré le problème. Elle te génère du code qui marche, pas du code qui résiste.
Ce que ferait un audit sur ce fichier
Voilà à quoi ressemblerait, concrètement, l'audit que je fais sur ce genre de code :
- Lecture sécurité : je trace chaque champ envoyé au serveur et je me demande "qu'est-ce qui se passe si un attaquant le modifie ?". Là, j'ai trouvé 4 champs problématiques en 30 secondes.
- Lecture cycle de vie : je regarde quand chaque callback est appelé. Le coup du
onCompleteprématuré, c'est typique. Un dev IA ne pense pas en termes de cycle de vie, il pense en termes de "fonction qui semble logique à appeler ici". - Lecture serveur : je vais lire le code de
create-checkout(la fonction Supabase) pour voir si elle compense les problèmes du client. Souvent oui partiellement, souvent non sur le point critique. - Plan de fix priorisé : je te donne 3 niveaux. Ce qui doit être patché aujourd'hui (les failles money-out), ce qui peut attendre une semaine (UX), ce qui est cosmétique.
Sur ce composant précis, le plan tiendrait sur une page. Et les changements serveur prendraient une demi-journée à un dev senior.
Pour être clair : l'IA n'est pas l'ennemi
Je ne suis pas en train de dire qu'il faut arrêter de générer du code avec une IA. Le composant ci-dessus est relativement bien structuré. Il fonctionne techniquement. Le problème, ce n'est pas l'IA, c'est de l'expédier en prod sans relecture.
L'IA fait un premier jet qui marche. Elle ne fait pas du code qui tiendra en prod les 10 prochaines années.
Si tu vibe codes ton SaaS et que tu touches au paiement, à l'auth, ou aux données utilisateurs, c'est exactement le moment de faire repasser un humain qui pourra tout vérifier.
Tu veux que je regarde ton code ?
Si tu as un composant de paiement, d'auth, ou un flow critique généré par IA, et que tu veux savoir ce qui s'y cache avant qu'un utilisateur le trouve avant toi, découvre l'offre Audit. Je lis le code, je te liste les vrais risques, et je te donne un plan de fix priorisé.
Et si tu veux d'autres audits ligne par ligne dans ce style, inscris-toi à la newsletter. Pas de spam, juste les pièges qui valent la peine d'être évités.

