Diagnostic guide
canceled subscription still has access
A cancellation is the quietest kind of bug, because it looks like nothing happened. Nobody files a support ticket for access they still have. It only surfaces when someone else notices the pattern, and by then it has usually been running for a while.
most likely causes, in order
01customer.subscription.deleted is not in the events your webhook listens for+
how it looks
Every cancellation, going back to when the webhook was first set up, still shows as active in your app. Nothing about it looks broken from Stripe's side.
how to check
Stripe Dashboard → Developers → Webhooks → your endpoint. Look at the list of events it is subscribed to. If customer.subscription.deleted is not on it, Stripe never sends the event at all: there is nothing to retry.
the fix
Add the event, then handle it: look up the customer and revoke access. Past cancellations will not replay automatically, because the event itself was never sent; find and fix those by hand.
02The handler only reacts to cancel_at_period_end, not the actual end+
how it looks
Someone who canceled and finished out their paid month is still active weeks later. A subscription canceled immediately, with no remaining time, behaves correctly.
how to check
Read the handler for customer.subscription.updated. If it only checks cancel_at_period_end and does nothing when the subscription later actually ends, it is watching the schedule, not the result.
the fix
cancel_at_period_end just marks intent; the access change belongs on customer.subscription.deleted, which fires when the period actually ends (or immediately, if canceled without waiting). Revoke access there.
03The app's own cancel button updates the database before Stripe confirms it+
how it looks
Your cancel button appears to work (the screen updates), but the row flips back to active on the next unrelated write, or never matches what Stripe actually has.
how to check
Look at what the cancel button calls. If it writes your access column directly and only afterward calls Stripe's API, a failed or slow Stripe call leaves the two sides disagreeing with no error visible to the customer.
the fix
Cancel in Stripe first, and let the webhook be the only thing that changes the access column. Show a pending state until the webhook confirms it, rather than assuming success.
04The webhook runs but is blocked from writing (RLS / permissions)+
how it looks
The handler executes without throwing, logs look clean, and the row does not change.
how to check
In a Supabase build, confirm the webhook uses the service-role key. The anon key has no authenticated user in a webhook context, so a row-level security policy silently drops the update.
the fix
Use the service-role key server-side only, never in code that reaches the browser.
check it yourself
Access that has outlived its own recorded end date. Run this in your SQL editor, adjusting the table and column names to match your schema.
select email, stripe_customer_id, subscribed, subscription_end from public.subscribers where subscribed = true and subscription_end is not null and subscription_end < now();
what this does not tell you
This finds anyone whose stored end date has already passed. It cannot find someone who canceled immediately, with no remaining paid time and no end date ever recorded: that row looks identical to someone who never subscribed in the first place.
Read-only, both sides. Three findings free, no account.
related
Stripe webhook not updating database
Your Stripe webhook returns 200 but the row never changes. The five causes, how to tell them apart, and the SQL to find everyone already affected.
Refunded customer still has access
A refund in Stripe does not cancel the subscription and does not touch your app on its own. What has to happen for a refund to actually end access, and why your own table can't tell you who it already missed.