Ayobami ZenthosGet in touch
All insights

The browser never sets the price

How BelleFood keeps prices, payment status and points out of the customer's reach, with one ordering function and column guards in Postgres.

BelleFood dish pageBelleFood home screenBelleFood order tracking

Anything a customer’s browser sends can be edited. Open the developer tools, change "price": 5775 to "price": 1, and a careless app will happily charge one naira for a plate of jollof.

When the app talks straight to the database, as BelleFood does through Supabase, this matters even more. There is no hand-written API in the middle to forget a check. So the rule I built around is simple: the browser can ask for things, but the database decides what they cost and what state they are in.

Orders can only be created one way

Customers cannot insert into the orders table at all. The permission is removed outright:

-- Orders are only ever created by place_order, which prices them itself.
revoke insert, delete, truncate on public.orders from anon, authenticated;

The only way to place an order is a database function, place_order. The cart it receives is just product IDs and quantities. Every price comes from the products table, at the moment of ordering:

select id, name, price, store, category, is_combo, images, is_published, in_stock
  into prod from products where id = item_id;
if prod.id is null or not prod.is_published or not prod.in_stock then
  raise exception 'item unavailable';
end if;
subtotal := subtotal + prod.price * qty;

The function checks everything a customer could get wrong or try on purpose: an empty cart, more than 60 lines, a quantity outside 1 to 99, an item that has been unpublished or sold out, a delivery area that does not exist, a payment method the shop has switched off. Delivery fees and free-delivery thresholds come from the shop’s own settings, not the request.

If the browser sends a price, it is simply never read.

Some columns are never the customer’s to change

After an order exists, a customer still needs to update it in one small way: confirming they received it. Row Level Security lets them update their own order, but that alone would let them update anything on it, including payment_status.

So a trigger runs before every update and quietly puts the protected columns back:

create or replace function public.guard_order_update()
returns trigger language plpgsql as $$
begin
  if public.is_trusted_writer() or public.is_admin() then return new; end if;
  new.total := old.total;
  new.items := old.items;
  new.payment_status := old.payment_status;
  new.payment_reference := old.payment_reference;
  new.points_discount := old.points_discount;
  -- ...every other column that only the system may change

The one change a customer is allowed is checked precisely: receipt can only be confirmed on a paid order that is out for delivery or already delivered. Anything else is reset, silently. There is no error message to probe and nothing to learn from trying.

The same pattern protects customer profiles, so nobody can give themselves loyalty points or rewrite who referred them.

Trusted code, by name

The system itself does need to change these columns: Paystack’s webhook marks an order paid, and staff confirm bank transfers. Those paths are recognised by a single function:

create or replace function public.is_trusted_writer()
returns boolean language sql stable as $$
  select current_user in ('service_role', 'postgres', 'supabase_admin', 'supabase_auth_admin')
      or current_setting('app.privileged', true) = 'on';
$$;

Server code runs as the service role. Database functions that are allowed to change protected fields switch on app.privileged for their own transaction only, with set_config('app.privileged', 'on', true). The true makes it local: the moment that transaction ends, the switch is off again. It cannot leak into another customer’s request.

What I took from it

  • Treat every request as a suggestion. The client says what it wants; the server decides what it gets.
  • Remove the permission rather than validating the input. A table nobody can insert into cannot be inserted into wrongly.
  • Guard columns, not just rows. Row Level Security answers “whose row is this?”, not “which parts can they touch?”.
  • Fail quietly on tampering. A clear error is a gift to someone probing your system.
  • Keep privilege narrow and short-lived. One transaction, one switch, then it is gone.
See BelleFood