Skip to main content
ASoc
Guide

Supabase "New Row Violates Row-Level Security Policy": Zero INSERT Policies, On Purpose

Five tables, five SELECT-only policies, zero INSERT policies anywhere — the exact error this schema guarantees, and where writes actually happen instead.

The ASoc Team9 min read

"New row violates row-level security policy for table" means Postgres has RLS turned on for that table and no policy grants the write you just attempted — RLS defaults to deny, not allow. The usual fixes are adding an INSERT policy, checking that the request actually carries a session (auth.uid() isn't null), or routing the write through a SECURITY DEFINER function instead. This storefront's schema doesn't have that first option: it ships zero INSERT policies on any table, on purpose.

The census

$ grep -n "create policy" supabase/migrations/*.sql
0001_commerce_init.sql:65:create policy "own profile"   on public.profiles          for select using ((select auth.uid()) = id);
0001_commerce_init.sql:66:create policy "own orders"    on public.orders            for select using ((select auth.uid()) = user_id);
0001_commerce_init.sql:67:create policy "own slots"     on public.entitlement_slots for select using ((select auth.uid()) = user_id);
0001_commerce_init.sql:68:create policy "own downloads" on public.download_events   for select using ((select auth.uid()) = user_id);
0008_refund_requests.sql:38:create policy "own deliveries" on public.download_deliveries for select using ((select auth.uid()) = user_id);

Five tables have row-level security enabled — profiles, orders, entitlement_slots, download_events, download_deliveries — and five policies exist for them. All five are for select. Not one for insert, for update, or for delete policy exists anywhere in this schema. The migration that creates the first four says so directly, in a comment left for whoever reads the file next:

-- RLS: read-own only; NO write policies for anon/authenticated — all writes go
-- through server code using the service-role key (which bypasses RLS). R-2/R-13/R-3.

Which means the exact error behind this keyword isn't a bug this codebase occasionally hits — it's the guaranteed outcome of any client attempting supabase.from("orders").insert(...) from the browser or a signed-in session, on any of these five tables, every time. Postgres RLS is deny-by-default: enabling it on a table with policies for select only makes every other command fail closed, because "no policy exists for this command" and "no policy grants this row" produce the same result. That's the mechanism, and it's not this codebase's mechanism to fix — it's the one being relied on.

Where the writes actually happen

Nothing in the browser ever inserts a row here. Three separate write paths exist, and all three run on the server with credentials a client never holds.

Two SECURITY DEFINER RPCs, called only from the LemonSqueezy webhook handler:

-- supabase/migrations/0003_commerce_rpcs.sql
create or replace function public.create_order_with_slots(...)
returns table(order_id uuid, created boolean)
language plpgsql security definer set search_path = public, pg_temp as $$
  ...
$$;

revoke execute on function public.create_order_with_slots(...) from anon, authenticated, public;
revoke execute on function public.refund_order(text) from anon, authenticated, public;

security definer runs the function with the privileges of whoever created it, so it can insert into orders and entitlement_slots despite RLS — that's the standard escape hatch. What's easy to miss is the line after it: EXECUTE is explicitly revoked from anon and authenticated too, so even a signed-in user calling supabase.rpc("create_order_with_slots", ...) directly gets a permission error before RLS is ever consulted. Only the service-role key — held by src/lib/lemonsqueezy/webhookDb.ts, never shipped to a browser — can call it. set search_path = public, pg_temp pins the resolution path so the function can't be tricked into running against an attacker-controlled schema, the injection class this pattern exists to close.

One SECURITY DEFINER RPC for the one place a client-adjacent write actually needs to happen — recording a download:

-- supabase/migrations/0007_atomic_download_rate_limit.sql
-- Root cause (CWE-367 TOCTOU / OWASP A04): the download endpoint enforced the
-- hourly limit with a non-atomic check-then-act — read downloads_in_last_hour(),
-- then a SEPARATE insert of the audit row. Under concurrency, N requests could
-- all read the same count, all pass the `< limit` check, and all insert, so a
-- burst of parallel requests bypassed the cap.
create or replace function public.record_download_within_limit(...)
returns int language plpgsql security definer set search_path = public, pg_temp as $$
  perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
  select count(*)::int into recent from public.download_events
    where user_id = p_user_id and created_at > now() - interval '1 hour';
  if recent >= p_limit then return -1; end if;
  insert into public.download_events (...) values (...);
  return recent + 1;
$$;

This one is a real defect this codebase shipped and fixed, not a hypothetical. The original download route counted a user's downloads in the last hour, then inserted a new row in a second, separate round trip — a textbook check-then-act race. Fire several download requests in parallel and every one of them could read the same pre-insert count, pass the limit check, and insert anyway, so the hourly cap was only a suggestion under concurrency. The fix collapses the count and the insert into one plpgsql transaction, serialized per user with pg_advisory_xact_lock so concurrent requests for the same user queue instead of racing. EXECUTE is revoked from anon/authenticated here too — only src/app/api/download/route.ts's admin client calls it.

And one place that skips the RPC layer entirely, because the admin client is the boundary:

// src/app/api/download/route.ts
async listActiveSlots(userId) {
  // The admin client bypasses RLS — this explicit `user_id`/`status`
  // filter IS the security boundary (R-5), not defense-in-depth on top
  // of RLS.
  const { data, error } = await admin
    .from("entitlement_slots")
    .select("kind, product_slug, framework, status")
    .eq("user_id", userId)
    .eq("status", "active");
  ...
},
async recordDelivery(event) {
  const { error } = await admin.from("download_deliveries").insert({
    user_id: event.userId,
    product_slug: event.productSlug,
    framework: event.framework,
    version: event.version,
  });
  if (error) throw error;
},

createAdminClient() holds the service-role key, so it never touches RLS at all — not "policy denies it," but "the policy engine doesn't run for this connection." The comment on listActiveSlots says the quiet part out loud: once you're on the admin client, the .eq("user_id", userId) filter you write by hand is the entire access boundary. Get that filter wrong and RLS won't catch it, because RLS was never in the request's path. That's the actual risk this schema's shape is managing — not "will an insert get rejected," which is guaranteed, but "did the one hand-written filter in the one function that bypasses everything get written correctly."

What a client is actually allowed to do

TableRLS enabledPoliciesA signed-in user's own client can
profilesyes1 (select)Read their own row
ordersyes1 (select)Read their own orders
entitlement_slotsyes1 (select)Read their own entitlements
download_eventsyes1 (select)Read their own download history
download_deliveriesyes1 (select)Read their own delivery log
(everything else)Nothing — no other table is exposed to anon/authenticated at all

Zero rows in the "insert/update/delete" column, by design, for every table this schema defines.

Troubleshooting the error, if you're not on this schema

SymptomCauseFix
Insert fails immediately, table has RLS onNo policy exists for the insert command on that roleAdd create policy ... for insert with check (...), or route the write through a security definer function
Insert works for one user, fails for anotherThe policy's with check expression evaluates to false for that row (commonly auth.uid() != user_id)Confirm the row being inserted actually belongs to the caller, or the policy is doing its job correctly
Error appears only from the browser, not from a server scriptThe server script is using the service-role key, which bypasses RLS entirely; the browser is on the anon/authenticated key, which RLS does apply toConfirm which key each caller uses — mixing them up is the most common cause of "works locally, fails in prod"
RLS is on, a policy exists, insert still failsThe session has no JWT — auth.uid() is null, and null = anything is null, not trueConfirm the client is actually authenticated before the insert, not just that a policy exists
Need a write real users should never do directlyPolicy authoring keeps growing to cover every legitimate write pathConsider a security definer RPC with EXECUTE revoked from client roles instead — one audited function beats N ad-hoc policies

Frequently asked questions

Why not just add an INSERT policy instead of routing everything through RPCs? A for insert policy on orders would have to encode "only after a payment provider confirmed this" — a fact that lives outside the row being inserted and can't be expressed as a with check predicate on that row alone. A security definer function can require its own inputs, run inside a transaction with entitlement_slots, and have EXECUTE revoked from every role except the one caller that should ever invoke it. A policy can't be revoked from a caller the same way; it either matches the row or it doesn't.

Does a security definer function need RLS on its target tables at all, if it bypasses RLS anyway? Yes — RLS is still what protects the selects a normal signed-in session runs directly, and it's the deny-by-default backstop if a future migration adds a table and someone forgets to lock down writes on it explicitly. security definer is scoped to the one function that declares it; RLS still governs every other path.

Is security definer alone enough, or does it need anything else? set search_path = public, pg_temp on every one of them here — without a pinned search path, a security definer function can be tricked into resolving an unqualified table or function name against a schema the caller controls, running attacker code with the function owner's privileges. Revoking EXECUTE from anon/authenticated is the other half; a security definer function with default EXECUTE grants is callable by anyone who can reach the API.

What actually stops a signed-in user from calling record_download_within_limit directly and downloading in a tight loop? The revoke execute ... from anon, authenticated, public line at the bottom of 0007_atomic_download_rate_limit.sql. Postgres checks function-level EXECUTE privilege before it runs a single line of the function body, so the advisory-lock/count/insert logic inside never has to defend against being called out of context — the grant system already refused the call.

Templates in this post

ASoc Amplify (a social-media marketing landing page), ASoc Atelier (a design-studio portfolio) and ASoc Axiom (an AI services landing page) all ship on the framework editions this storefront sells — the RLS posture above is the storefront's own backend, not something bundled into the templates themselves.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the wider comparison this posture is part of, see Supabase vs Firebase for a Template-Based SaaS; for the entitlement model these tables back, Next.js License Key: Why This Storefront Doesn't Have One.

Keep reading

Guide9 min read

Is Tailwind CSS Worth It? 881 Class Attributes, One 78 KB File

Answered with three measurements instead of taste: 133 hand-written lines, 14 KB gzipped across 488 pages, and the AA contrast bug the token scale made easy to ship.

Read more