Ilya Shkura
EN UK RU

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.
The order page as received: a brand-new account opens a guest's order by its number and sees the name and the delivery address
As received: a brand-new account opens a guest's order by its number. The customer is made up; the shop holds test data only.

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 fixed order page: the same order number opened by a stranger's account now says We couldn't open this order
After the fix: the same number, the same stranger's account, and the order stays closed.

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?"

The full case, with pictures before and after and the 22-page report: An online shop built with AI, checked and fixed. Everything else found in that shop: A shop that looked finished.

Want these cases checked in your app? Apps built with AI: every problem comes with the steps that show it, what it exposes, and a test of the fix.