Notes · October 2026
Any new account could read every guest order.
How “can’t tell” became “yes”: one empty field in an access check, the fix, and a two-minute check for your own app.
A knitwear shop was built with Claude Code from 17 messages in plain words, on Supabase. It is a sample shop built for a case, with made-up customers and test data. Customers can order without an account. After a guest orders, they see the order page through a link with a secret key in it. Customers with an account open their orders from the account, and the owner sees every order.
That is three ways in, and the check for one of them had a gap the size of every guest's address.
What happened
Anyone who made an account could open any guest order by its number. Making an account took a minute and needed no email confirmation. The order page then showed the guest's name, street, city, email, phone and what they bought.
- Order numbers run in sequence, so the next one to try is easy to guess.
- Counting down 60 numbers opened 29 orders: all the guest orders in that range.
- About four in ten of the shop's orders were guest orders.
Signed out, the same page refused. That is what makes this kind of hole easy to miss: a quick try without an account looks safe.
Why: three questions and an empty answer
Before showing an order, the database asked three questions. Does the link carry the order's key? Is this order yours? Is this the owner? If none of them is yes, refuse.
if not (
(p_token is not null and v_order.access_token = p_token)
or (auth.uid() is not null and v_order.user_id = auth.uid())
or public.is_owner()
) then
return null; -- refuse
end if;
The line reads correctly, and that is the trouble. A guest order has no account, so its user_id is empty (NULL in SQL). In SQL, comparing anything with an empty value gives neither yes nor no. It gives "unknown".
So for a stranger who is signed in, the three answers were no, unknown and no. "No or unknown or no" is unknown, and "not unknown" is still unknown. An if runs only on yes, so the refusal never ran, and the order came back. Signed out, the second question was a plain no, which is why the page refused then.
The rule on the orders table itself was fine. A Supabase row-level security rule like "user_id = auth.uid()" treats unknown as no and hides the row. But this check sat in a database function that runs with the owner's rights and skips those rules, so the function's own if not (…) decided, and it turned the safe default around.
The fix
Each question now gives a plain yes or no before they are combined. An order with no account never passes the "is this yours?" question, and the order opens only when the answer is exactly yes.
v_allowed :=
coalesce(p_token is not null and v_order.access_token = p_token, false)
or coalesce(auth.uid() is not null and v_order.user_id is not null
and v_order.user_id = auth.uid(), false)
or coalesce(public.is_owner(), false);
if v_allowed is not true then
return null;
end if;
The page's data also stopped carrying the internal keys the shop uses to recognise a repeated order.
The case is now a test that runs against the database with every change. A stranger's account asks for a guest order by its number with no key, with a made-up key, and for the 40 numbers before it.
Where else this hides
Look for two things together. The first is a record with no owner: a guest order, a draft, an invitation nobody has accepted yet, a record left behind by a deleted user. The second is code that reads data with full rights and checks access itself. In Supabase that is a security definer function or an edge function using the service role key. In Firebase it is a cloud function using the Admin SDK. Where the two meet, an empty owner can turn "can't tell" into "yes".
Check your own app in two minutes
- Make two accounts, A and B. As A, create something private: an order, an invoice, a booking. As B, open it by its address or number. It should be refused.
- Now do the same with a record that has no owner, such as an order placed without signing in or a draft. Open it as B, and again signed out.
- If the numbers in the address run in sequence, try a few next to yours.
- If anything opens, ask whoever built the app one question: "Which functions or server routes read data with full rights, and what does each do when the owner field is empty?"