A common report from AI-built apps: checkout worked in sandbox, then the first real payments hit a Supabase permission error and nobody got access. The payment went through, and Stripe delivered the event. The database write inside your webhook is what failed.
new row violates row-level security policy for table ..., permission denied for table ..., or Postgres error code 42501 (insufficient_privilege, Postgres error codes).update that sets the user to paid changed zero rows.Row Level Security decides what each request may touch. A Stripe webhook arrives from Stripe's servers with no user signed in. Supabase: "When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), auth.uid() returns null", so a policy like auth.uid() = user_id "will silently fail for unauthenticated users" (Supabase docs: Row Level Security). Postgres itself notes that RLS "silently prevents updating other rows" (Postgres docs: Row Security Policies), which is the zero-rows-changed case.
In testing, the write often ran in a way that carried your own session: you were signed in, the success page did the unlock, or the AI builder's test path called the function with your login. Real customers' webhooks don't carry any of that. Other differences that only show up in production:
anon) key. That key "only reaches what Row Level Security allows" (Supabase docs: API keys).anon.Authorization header to a user JWT, and calling auth functions like signUp on the same client (Supabase troubleshooting).SUPABASE_SECRET_KEYS (or the legacy SUPABASE_SERVICE_ROLE_KEY), which "bypass Row Level Security, so use them in Edge Functions and never in a browser" (Supabase docs: Edge Function secrets).Authorization header into that client, and don't reuse it for user sign-in calls. Supabase's troubleshooting note explains that RLS is enforced based on the Authorization header, not the apikey you passed to createClient (source).anon update profiles). That lets anyone mark themselves paid. Adding service_role to a policy does nothing either: "Service role will never run the policies to begin with" (source).import Stripe from "npm:stripe";
import { createClient } from "npm:@supabase/supabase-js@2";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}");
const adminKey = secretKeys["default"] ?? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!adminKey) throw new Error("Supabase secret key missing"); // never fall back to the publishable key
// Separate admin client: no user session, no Authorization header from the request
const admin = createClient(Deno.env.get("SUPABASE_URL")!, adminKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
Deno.serve(async (req) => {
const body = await req.text();
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body, req.headers.get("stripe-signature") ?? "", Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined, Stripe.createSubtleCryptoProvider(),
);
} catch {
return new Response("bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const s = event.data.object;
const { data, error } = await admin
.from("entitlements")
.upsert({ user_id: s.client_reference_id, plan: "pro", stripe_customer_id: s.customer })
.select();
if (error || !data?.length) {
console.error("fulfillment write failed", error);
return new Response("retry", { status: 500 }); // Stripe retries; fix and resend
}
}
return new Response("ok", { status: 200 });
});
Pattern from Supabase's Edge Function secrets docs and Stripe example (secrets, example). Rename the table and columns to match your app, and keep the paid flag in a table users can't edit (why).
Live Stripe payments fail to unlock because the webhook's Supabase write is blocked by Row Level Security. Fix it without weakening RLS: 1) In the Stripe webhook function, verify the Stripe signature on the raw body first and return 400 if it fails. 2) Create a separate Supabase admin client from SUPABASE_SECRET_KEYS (or SUPABASE_SERVICE_ROLE_KEY) and SUPABASE_URL, with persistSession false, and never pass the request's Authorization header or any user token to it. 3) If the secret key is missing, throw an error instead of falling back to the publishable or anon key. 4) Write the paid status with that admin client, select the written row back, and return 500 if the write errors or changes no rows so Stripe retries. 5) Do not add any RLS policy that lets anon or other users write paid status. 6) Make sure the secret key is never used in frontend code.
Once the fix is deployed, resend the failed checkout.session.completed events instead of asking customers to pay again (how to resend an event), then confirm each account shows as paid. Stripe's fulfillment guide also recommends running the same fulfill function from your success page, server-side, as a second path (Stripe docs: Fulfill orders).
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkThe webhook writes to Supabase without a signed-in user, so auth.uid() is null and policies like auth.uid() = user_id deny the write. Write from the server with the Supabase secret key (or legacy service_role key) after verifying the Stripe signature.
In testing the write often carried your own session (signed in, success-page unlock, or a test call with your login). Real webhooks carry no user session. A missing secret key in production, with code that falls back to the public key, causes the same failure.
A secret or service_role key bypasses RLS only when the request carries no user access token. If a user session or Authorization header gets into that client (SSR cookie client, a forwarded header, or signUp on the same client), it runs under that user's policies. Use a separate admin client.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-11-03. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.