HomeGuidesSupabase error details that leak rows RLS hides
Guide
Supabase error details that leak rows RLS hides
Supabase error.details can show another user's row, email and Stripe id included. What we saw leak, what Postgres already hides, and the fixes we tested.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Your RLS policy says Alice can only read her own profile. Then she calls a credits transfer function with a negative amount, and the error comes back with Bob's row in it: his email, his plan, and his Stripe customer id. Nothing in the API let her read that row. The error message did.
The Supabase client hands you every error PostgREST sends: error.code, error.message, error.details, and error.hint. PostgREST forwards errors from PostgreSQL with those same fields, and Postgres puts row contents in details for several constraint errors. Most of the time Postgres hides values the user should not see. This guide shows where that protection held in our test, where it did not, and the fixes we ran.
Every result below came from PostgreSQL 17.11 behind PostgREST 16.5 (the API layer Supabase uses) with @supabase/postgrest-js 2.109.0 and Supabase style anon, authenticated, and service_role roles. We did not test on a hosted Supabase project.
Checked against the PostgreSQL, PostgREST, and Supabase docs linked below on October 27, 2026. VibeLocked is independent and not affiliated with Supabase.
The short version
- Direct requests on RLS tables mostly hide values. For a signed in user, Postgres returned
details: nullon unique, not null, and check errors, and a foreign key detail without the key. - Code that runs as the table owner does not. A
security definerfunction, a foreign keySET NULLaction, and a definer trigger all returned full rows indetails, including another user's. - RAISE sends what you write. Data in the message,
DETAIL, orHINTreached the client as is. - Status codes are an oracle. 409 versus 201 told a user whether an email or a hidden team id exists.
- Fix it in three places: safe errors in functions, mapped errors in server routes, and a client that never renders
error.details.
What we tested
Two users. Bob has a profile with email, plan, credits, and a Stripe customer id. Alice cannot read it.
create table public.profiles (
id bigint generated always as identity primary key,
user_id uuid not null unique references auth.users (id),
email text not null unique,
handle text not null unique,
plan text not null default 'free' check (plan in ('free', 'pro')),
credits int not null default 0 check (credits >= 0),
stripe_customer_id text
);
alter table public.profiles enable row level security;
create policy "own profile read" on public.profiles
for select to authenticated using ((select auth.uid()) = user_id);
Insert and update policies use the same check. There is also a waitlist table that anon can insert into but nobody can read, a teams table where each user sees only their own team (Bob owns team 7), a notes table with a foreign key to teams, and an invoices table with RLS off and column grants that hide internal_note.
What Postgres hides on its own
Signed in as Alice, straight through the client:
| Request | Status | code | details |
|---|---|---|---|
Insert profile with bob@example.com |
409 | 23505 | null |
Insert profile with handle bob |
409 | 23505 | null |
| Set her own email to null | 400 | 23502 | null |
Set her own credits to -1 |
400 | 23514 | null |
| Insert note into team 8 (does not exist) | 409 | 23503 | Key is not present in table "teams". |
| Update invoice amount to 0 (RLS off, column grants) | 400 | 23514 | Failing row contains (id, owner_id, amount) = (1, aaaaaaaa-..., 0). |
So on tables with RLS, the detail that would normally say Key (email)=(bob@example.com) already exists. or Failing row contains (...) was simply missing. That is by design. In the Postgres 17 source, the function that builds Failing row contains starts with "Check if RLS is enabled and should be active for the relation; if so, then don't return anything." The unique key detail and the foreign key detail have the same check. Without RLS, the invoice row showed only the columns Alice may select, which is the column privilege rule in the same code.
The message still names the constraint and column, like duplicate key value violates unique constraint "profiles_email_key". That tells a user your schema, but not other users' values.
Where hidden data leaked
The protection depends on who is running the statement. RLS is active for the user making the request. It is not active for the table owner, and on Supabase that owner is often postgres. Everything below reached Alice's browser.
A security definer function
AI tools often write this to move credits between users:
create function public.transfer_credits(to_handle text, amount int)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
update public.profiles set credits = credits - amount
where user_id = (select auth.uid());
update public.profiles set credits = credits + amount
where handle = to_handle;
end;
$$;
Alice called supabase.rpc('transfer_credits', { to_handle: 'bob', amount: -100 }):
{
"code": "23514",
"details": "Failing row contains (1, bbbbbbbb-..., bob@example.com, bob, pro, -50, cus_BOBSECRET).",
"hint": null,
"message": "new row for relation \"profiles\" violates check constraint \"profiles_credits_check\""
}
HTTP 400, with Bob's whole row. The function runs as its owner, so Postgres builds the detail for the owner. Turning on FORCE ROW LEVEL SECURITY for profiles did not change it in our test, because the owner there was the superuser postgres.
RAISE with data in it
A handle check that tries to be helpful:
raise exception 'Handle % is already used by %', new_handle, taken_by
using detail = 'owner plan: ' || owner_plan,
hint = 'stripe ' || owner_stripe_id;
Alice got HTTP 400, code P0001, message Handle bob is already used by bob@example.com, details owner plan: pro, hint stripe cus_BOBSECRET. The PL/pgSQL docs say DETAIL supplies an error detail message and HINT a hint message, and PostgREST passed all three through.
A foreign key action
This is the leak from our ON DELETE CASCADE guide. With on delete set null on a not null column, Alice deleted her own team and got HTTP 400, code 23502, details Failing row contains (101, null, bbbbbbbb-..., Bob private note in Alice team). That was Bob's note, which she cannot read.
We also gave notes to a non superuser owner role and turned on FORCE ROW LEVEL SECURITY. The detail still came back with Bob's note. FORCE did not hide it.
A security definer trigger
An after insert trigger on notes, marked security definer, inserted into invoices and broke its check. Alice's note insert came back with Failing row contains (300, aaaaaaaa-..., 0, null). That is all four invoice columns, including internal_note, which Alice has no SELECT grant on. In our test the values were her own plus a null, but any value in that row would have been shown.
A server key
With the service_role key, the same Bob update returned the full row and the waitlist duplicate returned Key (email)=(ceo@bigco.com) already exists. That is fine in a server log. It is a leak the moment a server route sends error back to the browser.
The quieter leak: status codes
Even with details: null, the response itself answers questions:
- Emails and handles. Alice's insert with a taken email got 409
23505. With a free email and handle it got 201. - Waitlists. A signed out insert of an email already on the list got 409
23505. A new email got 201. Anyone with your publishable key can test whether an address signed up. - Hidden parent rows. Alice inserted a note pointing at Bob's team 7, which she cannot see. It returned 201 and the note was created. For team 8, which does not exist, she got 409
23503.
The last one is the covert channel the Postgres row security docs warn about: referential integrity checks "always bypass row security." It is also a data bug, because Alice attached a row to someone else's team.
PostgREST error responses also describe your API. Signed out, a request for profile got 404 PGRST205 with hint Perhaps you meant the table 'public.profiles', and a call to a misspelled function listed the parameters it searched for.
HTTP status by error
What we saw, which matches the table in the PostgREST errors docs:
| code | Meaning | Status |
|---|---|---|
| 23505 | unique violation | 409 |
| 23503 | foreign key violation | 409 |
| 23502 | not null violation | 400 |
| 23514 | check violation | 400 |
| P0001 | plain RAISE | 400 |
| 42501 | permission denied or RLS | 403 signed in, 401 anon |
| PGRST205 | unknown table | 404 |
| PGRST202 | unknown function | 404 |
The codes are listed in the Postgres error codes appendix.
Fix 1: validate first, then return a safe error from functions
Check inputs before the update, catch anything else, log the real error on the server, and raise a message with no data in it:
create function public.transfer_credits_safe(to_handle text, amount int)
returns void
language plpgsql
security definer
set search_path = ''
as $$
declare
v_state text;
v_msg text;
v_detail text;
begin
if amount is null or amount <= 0 then
raise exception 'Invalid amount' using errcode = '22023';
end if;
update public.profiles set credits = credits - amount
where user_id = (select auth.uid());
update public.profiles set credits = credits + amount
where handle = to_handle;
exception when others then
get stacked diagnostics v_state = returned_sqlstate,
v_msg = message_text, v_detail = pg_exception_detail;
raise log 'transfer_credits_safe failed: % % %', v_state, v_msg, v_detail;
raise exception 'Transfer failed' using errcode = 'P0001';
end;
$$;
Alice's -100 and 0 calls both returned 400 with message Transfer failed, details: null, hint: null. The raise log line showed up in the Postgres log with the real code. On Supabase, Postgres logs are one of the log types in the dashboard Logs page.
One thing that surprised us: we also inserted the error into a private error_log table inside the handler. After the call that table had 0 rows. The final RAISE aborts the transaction, and the insert went with it. Log with raise log, or log in a server route, not in a table from the same handler.
The PL/pgSQL control structures docs explain get stacked diagnostics and that changes made inside a block are rolled back when its exception handler runs. A block with an exception handler also costs more to run, so use it on functions that need it.
Fix 2: pick your own message and status
For an expected case, catch the specific error:
exception when unique_violation then
raise exception 'That handle is not available' using errcode = 'P0001';
Alice got 400, code P0001, message That handle is not available, nothing else. A free handle returned 204.
To choose the HTTP status too, PostgREST reads a JSON message and detail when you raise SQLSTATE PGRST, as its errors docs describe:
exception when unique_violation then
raise sqlstate 'PGRST' using
message = '{"code":"HANDLE_TAKEN","message":"That handle is not available","details":null,"hint":null}',
detail = '{"status":409,"headers":{}}';
That returned HTTP 409 with {"code":"HANDLE_TAKEN","details":null,"hint":null,"message":"That handle is not available"}. Your client can switch on error.code === 'HANDLE_TAKEN'.
Never put row values in the message, DETAIL, or HINT of a RAISE that a client can trigger.
Fix 3: remove the existence oracle where it matters
For a waitlist, take direct inserts away and give one function that always answers the same:
revoke insert on public.waitlist from anon;
create function public.join_waitlist(email text)
returns void
language sql
security definer
set search_path = ''
as $$
insert into public.waitlist (email) values (email)
on conflict (email) do nothing
$$;
revoke execute on function public.join_waitlist(text) from public;
grant execute on function public.join_waitlist(text) to anon;
An existing email and a new email both returned 204. A direct insert as anon now got 401 42501. Our SECURITY DEFINER functions guide covers revoking EXECUTE from public and anon for functions anon should not call.
For hidden parent rows, check them in the insert policy, so a hidden team and a missing team look the same:
create policy "insert own notes in own team" on public.notes
for insert to authenticated
with check (
(select auth.uid()) = owner_id
and exists (
select 1 from public.teams t
where t.id = team_id and t.owner_id = (select auth.uid())
)
);
Team 7 (hidden) and team 8 (missing) both returned 403 42501, new row violates row-level security policy. Her own team 1 returned 201.
For emails and handles, decide what is sensitive. A unique constraint on email across all users will always answer "taken" for someone. If your app has tenants and existence across tenants is private, make the key per tenant, such as unique (team_id, email). The Postgres constraints docs say a multi column unique constraint makes the combination unique, not each column. If public handles are part of the product, accept that "taken" is visible, write it down, and rate limit the endpoint. Our rate limiting guide shows one way.
Fix 4: map errors in your server route or Edge Function
When the server calls Supabase with a secret key, the error has everything. Log it, then send a fixed message:
const SAFE: Record<string, [number, string]> = {
'23505': [409, 'That value is not available.'],
'23503': [400, 'Invalid reference.'],
'23502': [400, 'Missing required field.'],
'23514': [400, 'Invalid value.'],
'42501': [403, 'Not allowed.'],
};
function toClient(error: { code: string }) {
console.error('db error', error);
const [status, message] = SAFE[error.code] ?? [500, 'Something went wrong.'];
return Response.json({ error: message }, { status });
}
In our test the service_role update logged the full row with cus_BOBSECRET on the server and mapped it to status 400 with {"error":"Invalid value."} for the client. The same shape works in an Edge Function. Our Edge Functions auth guide covers verifying the caller first.
Fix 5: never render error.details in the browser
The supabase-js docs show if (error) console.error(error). That is fine for development. Do not put error.message, error.details, or error.hint on screen:
const { error } = await supabase.rpc('claim_handle_safe', { new_handle })
if (error) {
const text = error.code === '23505' || error.code === 'HANDLE_TAKEN'
? 'That handle is not available.'
: 'Something went wrong. Please try again.'
showToast(text)
}
Hiding the text in the UI does not hide it from the network tab, so Fixes 1 to 4 are what actually protect the data. This step stops your own app from showing it to every user who hits an error.
Fix 6: client-error-verbosity, if your PostgREST has it
The PostgREST 16 configuration docs list client-error-verbosity. Set to minimal, PostgREST returns only code and message, and server logs are not affected. We did not find it in the v12, v13, or v14 configuration pages. We did not check whether hosted Supabase lets you change it.
With it on, in our test:
- The definer function's 23514 came back with only code and message. Bob's row was gone.
- The SET NULL 23502 came back without the failing row.
- The RAISE message
Handle bob is already used by bob@example.comstill came through, because it is the message. - The typo hint
Perhaps you meant the table 'public.profiles'was gone.
It is a useful backstop, not a fix for RAISE messages or status code oracles.
Common AI generated mistakes
alert(error.message)or a toast witherror.detailsin every form handler.- Server routes that return
{ error }from a service role query straight to the browser. - Helpful RAISE messages that include the other user's email, id, or plan.
security defineradded to make an RLS error go away, which also turns off Postgres's error detail protection for that code.- A global unique email on a table anon can insert into, with the 409 used as the "already signed up" message.
Checklist
- No client facing function puts row values in a RAISE message,
DETAIL, orHINT -
security definerfunctions validate inputs and catch errors, returning a safe message - Real errors are logged with
raise logor on the server, not in a table from the same handler - Server routes and Edge Functions map
error.codeto fixed messages and never forwarddetails - The UI never renders
error.message,error.details, orerror.hint - Waitlist and signup style inserts go through a function that answers the same either way
- Insert policies check that referenced parent rows belong to the user
- No
ON DELETE SET NULLon aNOT NULLcolumn - A test triggers each constraint as another user and checks that
detailsis empty
For grants and policies across the project, use the Supabase RLS checklist. To keep it tested, see our RLS testing guide.
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Codex, Lovable, Bolt, or Replit inside your own project. It asks for code changes only. Do not paste database passwords, connection strings, or keys into the chat.
This project uses Supabase. Do not print, log, or ask me for any secret values. 1) Find every place the frontend shows error.message, error.details, or error.hint from Supabase, and replace it with a fixed message chosen by error.code, logging the full error only in development. 2) Find every server route or Edge Function that returns a Supabase error object to the client, and replace it with a mapping from error.code to a fixed message and status, logging the original server side. 3) List every Postgres function in the migrations that uses RAISE, and rewrite any message, DETAIL, or HINT that includes row values so it uses a fixed message and an explicit ERRCODE. 4) For every security definer function a client can call, add input validation at the top and an exception handler that logs with raise log and raises a generic message. 5) Find inserts into tables like waitlists or invites where a 409 would reveal that an email exists, and move them to a security definer function with on conflict do nothing that returns the same response either way. 6) For insert policies on tables with foreign keys to other users' rows, add an exists check that the parent row belongs to the caller. 7) List every foreign key with ON DELETE SET NULL on a NOT NULL column. Show me the migration before running anything.
More prompts are in the security review prompt guide.
Check what your live app exposes with LeakCheck
LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. On a live app that uses Supabase, it finds the key your app ships and checks whether that key can list rows from your tables, alongside its other checks for exposed files and leaked keys.
Its limits, honestly: it does not send inserts, updates, or function calls, so it cannot trigger the errors in this guide or read your functions. The tests above, run against your own project, are how you check that.
Sources
- PostgREST: Errors (forwarded Postgres errors, HTTP status table, PGRST custom errors, client error verbosity)
- PostgREST: Configuration (client-error-verbosity)
- PostgreSQL: Errors and Messages in PL/pgSQL (RAISE, DETAIL, HINT, ERRCODE)
- PostgreSQL: PL/pgSQL Control Structures (trapping errors, GET STACKED DIAGNOSTICS)
- PostgreSQL: Error Codes appendix
- PostgreSQL: Row Security Policies (referential integrity checks bypass row security)
- PostgreSQL: Constraints (multi column unique constraints)
- PostgreSQL 17 source: execMain.c (no Failing row detail when RLS is active)
- Supabase: Database Functions (security definer and search_path)
- Supabase: Logs (Postgres log type)
- Supabase JavaScript reference: insert (handling errors)
Questions
Can Supabase error messages leak data the user cannot SELECT?
Yes, in some paths. In our test a SECURITY DEFINER function returned a 23514 error whose details were another user's full row, including email and Stripe customer id. A cascade from ON DELETE SET NULL on a NOT NULL column did the same with a 23502 error. A function that put data into RAISE sent it to the client in message, details and hint.
Does RLS hide the Failing row contains detail?
When the request itself hits a table with RLS enabled, yes. In our test direct inserts and updates by a signed in user on RLS tables returned details null for 23505, 23502 and 23514, and a 23503 detail without the key value. The Postgres source returns no detail when RLS is active for the current user. Code that runs as the table owner, such as a security definer function or a foreign key action, does not get that protection.
Can a unique constraint reveal that an email exists?
Yes. Even with details null, the status tells the story: an insert with a taken email got 409 and code 23505, and a free email got 201. That works as an existence check for signed out visitors too, for example on a waitlist table. A function that uses on conflict do nothing and always returns the same response removes that signal.
How do I hide database errors from the Supabase client?
Return your own error from functions and server routes, keyed by error code, and log the original on the server. In functions, catch the error and RAISE a safe message with an ERRCODE, or use PostgREST's PGRST custom error to also pick the HTTP status. In the browser, never render error.details, error.hint or error.message. PostgREST 16 also documents client-error-verbosity = minimal, which in our test removed details and hint but kept the message.
What HTTP status does PostgREST return for constraint errors?
In our test 23505 and 23503 returned 409, 23502 and 23514 returned 400, a plain RAISE (P0001) returned 400, and 42501 returned 403 for a signed in user and 401 for anon. That matches the status table in the PostgREST errors docs.