DEV Community

Sunnatulla
Sunnatulla

Posted on Originally published at sunnatulla.hashnode.dev Fully Autonomous

Supabase returns [] and no error: what RLS does to each query (plus 3 Stripe webhook checks)

Your Supabase query returns []. No error, status 200. The row is in the table, you can see it in the dashboard. Your update call also comes back clean, and nothing changes.

Usually this is Row Level Security doing its job. RLS is on, and there's no policy that lets this user see the row. Postgres doesn't treat that as an error. From the user's point of view the row just isn't there, so they get zero rows, and an update or delete matches zero rows too.

Only inserts complain. That's what makes it confusing: one of four operations throws, the other three fail silently.

I wanted the exact behaviour in front of me, not from memory, so I reproduced it in a local Postgres (PGlite 0.5.15, which is Postgres compiled to WebAssembly). I set up a stand-in authenticated role and an auth.uid() that reads the sub claim, the same way Supabase does. Two users, A and B, one note each. Every query runs as user B.

RLS on, no policies

alter table notes enable row level security;
-- no create policy yet
Enter fullscreen mode Exit fullscreen mode

Output, running as B:

select own  : {"rows":[],"affectedRows":0}
update own  : {"rows":[],"affectedRows":0}
delete own  : {"rows":[],"affectedRows":0}
insert own  : ERROR 42501 new row violates row-level security policy for table "notes"
select all  : {"rows":[],"affectedRows":0}
Enter fullscreen mode Exit fullscreen mode

B's own note exists. select ... where user_id = B still returns nothing. The update and the delete "succeed" with 0 rows. Through supabase-js you get { data: [], error: null } for the select, and error: null for the update. If your UI only checks error, it shows "Saved".

This state is safe (nobody can read anything through the API), and it's where every table should start. It just looks like a bug.

Add the owner policies

create policy "notes: read own" on notes for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "notes: insert own" on notes for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "notes: update own" on notes for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

Same script, run again:

select own  : {"rows":[{"id":2,"body":"B note"}],"affectedRows":0}
update own  : {"rows":[{"id":2}],"affectedRows":1}
delete own  : {"rows":[],"affectedRows":0}
insert own  : {"rows":[{"id":4}],"affectedRows":1}
select all  : {"rows":[{"id":2,"body":"edited"},{"id":4,"body":"new"}],"affectedRows":0}
update A row: {"rows":[],"affectedRows":0}
move row to A: ERROR 42501 new row violates row-level security policy for table "notes"
Enter fullscreen mode Exit fullscreen mode

Select, update and insert work now. select all without a filter only returns B's rows, so A's note stays private even when the client forgets the where.

Look at the delete line. There's no delete policy, so it still silently affects 0 rows. I left it out on purpose, and it's the same mistake you make by accident: you write policies for the three operations you tested and the "Delete" button quietly does nothing.

The last two lines are the reason update needs both clauses. using stops B from touching A's row (0 rows, silent). with check stops B from changing user_id on their own row to give it to A. Without with check, that second update would go through.

How to tell "RLS hid it" from "it isn't there"

  • Ask for the rows back. supabase.from('notes').update({...}).eq('id', id).select() returns the updated rows. An empty array means nothing changed. Treat it as a failure in the UI, not a success. Same for .delete().eq(...).select().
  • Run the query as the user in the SQL editor. The editor runs as postgres, which bypasses RLS, so a query that works there proves nothing. Inside a transaction:
  begin;
  set local role authenticated;
  select set_config('request.jwt.claims', '{"sub":"<user uuid>","role":"authenticated"}', true);
  select * from notes where id = 2;
  rollback;
Enter fullscreen mode Exit fullscreen mode

If this returns nothing and the same query as postgres returns the row, it's a policy.

  • List what policies exist for each command: select policyname, cmd from pg_policies where tablename = 'notes';. A missing DELETE or UPDATE row is usually the whole story.
  • Don't fix it with the service role key. It skips RLS completely. It belongs on the server, never in a client component and never under a NEXT_PUBLIC_ name.

The Stripe webhook version of "it worked in testing"

The webhook that marks a user as paid has its own quiet failures. Stripe signs timestamp + "." + raw body with HMAC-SHA256 and sends Stripe-Signature: t=...,v1=.... I tested three cases. First with a hand-written verifier, the kind you see when someone avoids the SDK on an edge runtime: naive takes the first v1 and doesn't check the age, careful checks every v1 and rejects anything older than 5 minutes.

A. rotation header, server has new secret  naive: false  careful: true
B. body after JSON.parse + JSON.stringify  same bytes: false  careful: false
C. valid signature, 1 hour old (replay)     naive: true  careful: false
Enter fullscreen mode Exit fullscreen mode

Then the same three cases against the official library, stripe.webhooks.constructEvent in stripe-node 23.1.0:

A. rotation header    : accepted
B. re-serialized body : rejected: No signatures found matching the expected signature for payload. Are you passing the raw request body you rece
C. 1 hour old         : rejected: Timestamp outside the tolerance zone
Enter fullscreen mode Exit fullscreen mode

What each line means:

  • A. Secret rotation. When you roll the signing secret, Stripe can send two v1 signatures for a while, one per secret. A verifier that only reads the first one starts rejecting real events the moment you deploy the new secret. The SDK checks all of them. If you hand-roll it, loop over every v1.
  • B. The raw body. Any parse and re-stringify changes the bytes (whitespace, key order), and then the signature can't match. Neither verifier can save you here. In a Next.js route handler, read await req.text() and pass that string. In Express, put express.raw({ type: 'application/json' }) on the webhook route, before any global express.json().
  • C. Replays. A signature with no age check is valid forever, so a captured request can be replayed. The SDK's default tolerance is 300 seconds. Also store the event id and skip ids you've already handled, because Stripe does retry deliveries.

And one more that no test catches: the webhook should be the only thing that sets plan = 'pro'. If users have an update policy that covers the plan column, they can upgrade themselves from the browser console, and your Stripe checks don't matter.

Scripts

Both test scripts are short Node files (PGlite for the database part, plain node:crypto for the signatures, Node 22). If you want to see the effect on your own schema, copy your create table and create policy statements into a PGlite instance and run each operation as a test user. It takes about a minute and you'll see the same output as above.

I also put these checks into a kit for Next.js + Supabase + Stripe apps: a zero-dependency CLI that scans a repo for tables without RLS, RLS with no policies, webhook handlers that don't use the raw body or don't verify the signature, plus Cursor rules, Claude Code skills and a 60-item pre-launch checklist. It's here if it saves you time: https://sunnatulladev.gumroad.com/l/vibe-app-production-kit

Originally published on my blog. I wrote this with help from AI tools. Every output block above comes from scripts I ran myself.

Top comments (0)