Lovable's Stripe integration does not use webhooks by default. Instead, your app asks Stripe directly whether a payment or subscription is active. That single design choice moves the security question away from webhook signature verification and onto three other things: whose identity the entitlement check runs as, whether the cached subscription row is protected by row level security, and whether anything trusts the post-checkout redirect.
TL;DR
Most Stripe security advice starts with "verify your webhook signatures." For a default Lovable app that advice is aimed at an endpoint you do not have. Lovable generates a checkout edge function and a subscription check that queries Stripe directly. So audit these instead: the entitlement check must derive the user from the verified session, not from a client-supplied ID; the subscribers table needs RLS that blocks client writes; and nothing should grant access because the browser landed on /success. Add a webhook only when you need fulfillment or async events, and verify its signature when you do.
What Lovable Actually Builds
When you connect Stripe, Lovable writes the backend code, creates the products and prices you asked for in your Stripe account, and adds buttons and pages to your app. On a Supabase project the moving parts are:
| Piece | Where it lives | What it does |
|---|---|---|
| Stripe secret key | Backend secret (Supabase edge function secrets) | Authenticates server-to-Stripe calls |
| Checkout function | Supabase edge function | Creates the Stripe Checkout session |
| Subscription check | Supabase edge function | Asks Stripe if the user is currently paid |
| Cached status | Supabase table (often subscribers) | Lets the UI gate features without a Stripe round trip |
On key storage, Lovable's default is correct. The docs state the key is stored as a backend secret, never in your app's code, and the connect form accepts only a restricted key (rk_...) or a secret key (sk_...). Publishable keys are rejected there. If you have read that Lovable drops Stripe keys into client code, that is not what the documented flow does. The thing worth checking is not where the key is stored but what the generated functions do with it.
Why the Usual Webhook Advice Mostly Does Not Apply
Lovable's documentation is explicit: "By default, the integration does not use webhooks: your app checks payment and subscription status directly with Stripe." For subscriptions, "your app asks Stripe directly whether the user has an active subscription, so paid features unlock and lock automatically."
That is a defensible design. A forged webhook is impossible when nothing is listening for one, and asking Stripe is always authoritative. But it relocates the risk rather than removing it.
The question changes from "is this event genuine?" to "is this the right user?" A webhook handler authenticates the sender with a signature. A direct check authenticates nothing by itself. Whether it is safe depends entirely on how the function decides which customer to ask about.
Check 1: Whose Subscription Is It?
This is the failure mode that matters most, and it is easy to generate by accident. The check function needs a customer to look up. If it takes that identifier from the request body, any signed-in user can ask about anyone.
// supabase/functions/check-subscription/index.ts
Deno.serve(async (req) => {
const { email } = await req.json(); // <-- attacker controls this
const customers = await stripe.customers.list({ email, limit: 1 });
const subs = await stripe.subscriptions.list({
customer: customers.data[0]?.id,
status: "active",
});
return Response.json({ subscribed: subs.data.length > 0 });
});
Post that endpoint any paying customer's email and it returns subscribed: true. If the UI unlocks on that boolean, the paywall is gone.
The fix is to ignore the body entirely and derive identity from the verified JWT that Supabase already gives the edge function.
// supabase/functions/check-subscription/index.ts
Deno.serve(async (req) => {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response("Unauthorized", { status: 401 });
}
// Validate the caller's token; do not trust anything in the body.
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
);
const token = authHeader.replace("Bearer ", "");
const { data, error } = await supabase.auth.getUser(token);
if (error || !data.user?.email) {
return new Response("Unauthorized", { status: 401 });
}
const email = data.user.email; // <-- from the verified token
const customers = await stripe.customers.list({ email, limit: 1 });
if (customers.data.length === 0) {
return Response.json({ subscribed: false });
}
const subs = await stripe.subscriptions.list({
customer: customers.data[0].id,
status: "active",
limit: 1,
});
return Response.json({ subscribed: subs.data.length > 0 });
});
When you prompt Lovable for this, say so explicitly: "the check-subscription function must read the user's email from the verified JWT, never from the request body." Prompts that just say "check if the user is subscribed" leave the source of identity ambiguous, and the generated code often reaches for the request body because it is the simplest thing that works in testing.
Check 2: Row Level Security on the Cached Status
The direct-check pattern is usually paired with a cache so every page load does not hit Stripe. That cache is a normal Supabase table, and it is a normal RLS problem.
If the table is client-writable, none of the Stripe logic matters. A user opens the browser console, updates their own row, and is premium.
alter table public.subscribers enable row level security;
-- The owner may read their own row.
create policy "read own subscription"
on public.subscribers
for select
using (auth.uid() = user_id);
-- No insert/update/delete policy for authenticated users.
-- Writes happen only from an edge function using the service role key,
-- which bypasses RLS by design.
A missing write policy is not the same as a blocked write. RLS denies by default only once it is enabled. A table with enable row level security never run is fully open to anyone holding the anon key, which is shipped in your frontend. Confirm RLS is on for every table the payment flow touches, not just that your policies look right.
Check 3: Do Not Grant Access on the Redirect
Stripe sends the browser back to your success_url after checkout. That redirect is a navigation, not a proof of payment. A user can visit the success URL directly.
// /success page
useEffect(() => {
// Anyone can load this URL. Nothing here was verified.
markUserAsPro();
}, []);
The success page should trigger the same server-side check as everything else, and show a pending state until it returns. Treat the redirect as a hint that it is worth re-checking, never as the authority.
When You Do Add a Webhook
The direct check cannot see anything that happens after the browser closes. Add a webhook when you need order fulfillment, or to react to disputes, refunds, and failed renewals. Lovable's docs put this on you: "If you need them (for example, to trigger order fulfillment), ask Lovable to set them up," then you create the event destination in the Stripe dashboard and store the signing secret.
Because that endpoint is hand-rolled and publicly reachable, signature verification is now genuinely load-bearing.
// supabase/functions/stripe-webhook/index.ts
Deno.serve(async (req) => {
const body = await req.text(); // raw body, not parsed JSON
const signature = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
// Fulfill. Make this idempotent: Stripe retries.
break;
case "customer.subscription.deleted":
// Revoke access.
break;
}
return new Response("OK", { status: 200 });
});
Two details that bite in edge functions specifically. Read the raw body with req.text(), since parsing to JSON and re-serializing changes the bytes the signature was computed over. And use constructEventAsync, because the synchronous constructEvent relies on a Node crypto path that is not available in Deno.
Webhook endpoints are unauthenticated by nature. Supabase edge functions verify a JWT by default, which will reject Stripe's requests. You have to disable JWT verification for this one function, and once you do, the signature check is the only thing standing between the public internet and your fulfillment logic.
Keys, Briefly
Key handling is the part Lovable's default already gets right, so this is a short section rather than the headline.
| Key | Prefix | Where it belongs |
|---|---|---|
| Publishable | pk_test_ / pk_live_ | Client-side. Safe to expose by design. |
| Restricted | rk_... | Backend secret. Preferred: scope it to only what the app needs. |
| Secret | sk_test_ / sk_live_ | Backend secret only. Full account access. |
| Webhook signing | whsec_ | Backend secret. Only if you added a webhook. |
Prefer a restricted key over the full secret key. Lovable's connect form accepts rk_..., and scoping it to the Checkout, Customer, and Subscription resources means a leak does not hand over refunds and payouts too.
Security Checklist
Before You Take a Real Payment
The subscription check derives the user from the verified JWT, not the request body
RLS is enabled on every table the payment flow touches
The subscribers table has no client insert, update, or delete policy
Writes to cached status happen only via the service role in an edge function
The success page re-checks server-side rather than granting access on arrival
Stripe key is a restricted key where possible, stored as a backend secret
Test mode used until the whole flow is verified end to end
If a webhook exists: signature verified against the raw body with constructEventAsync
If a webhook exists: handlers are idempotent, because Stripe retries
Does Lovable set up a Stripe webhook for me?
No. Lovable's documented default is that the integration does not use webhooks. Your app asks Stripe directly whether a payment or subscription is active. Webhooks are opt-in: you have to ask Lovable to add one, then create the event destination and store the signing secret in Stripe yourself.
If there is no webhook, can someone forge a payment?
Not by posting a fake event, because there is no endpoint listening for one. The equivalent risk moves to your entitlement check: if the function that asks Stripe about a subscription takes a customer ID or email from the client instead of the verified session, a user can ask about somebody else's subscription and unlock paid features.
Where does Lovable store the Stripe secret key?
As a backend secret, not in your app code. Lovable's docs are explicit that the key is stored server-side, and the connect form accepts only a restricted key (rk_) or secret key (sk_). Publishable keys are rejected there. On Supabase projects it syncs to your edge function secrets.
Is the cached subscription status in Supabase a risk?
It is if row level security is wrong. The direct-check pattern usually writes the result to a subscribers table. If that table is client-writable, a user can set their own row to active and skip payment entirely. The table should be readable only by its owner and writable only by the service role.
Do I still need webhook signature verification?
Only once you add a webhook, which you would do for order fulfillment or for events that arrive after checkout ends, like disputes or a failed renewal. At that point the hand-rolled endpoint is yours, so verifying the stripe-signature header with the signing secret is on you.
Shipping payments from a Lovable app?
Scan for exposed keys, open tables, and paywalls that trust the client.