Skip to content

Security basics

"Database error saving new user" in Supabase, and where to look

"Database error saving new user" means something attached to your Supabase signup failed and it was all rolled back. The real error is in your logs.

Vlad Tkachenko12 min read
A signed register at a front desk with the newest line already struck through, and behind it a second ledger standing closed in a room with a barred door.

In short

  • "Database error saving new user" means Supabase accepted the signup, something attached to it failed, and the whole thing was undone. No account was created.
  • The message is deliberately vague, and the real one is already recorded. Supabase's own troubleshooting answer sends you to the Auth logs first, then to the Postgres logs for the line underneath.
  • Nearly always it is a trigger on auth.users that cannot reach the table it writes into. The fix is a security definer function, and the part to get right is how much that function is allowed to do.

Somebody tries to create an account on your app and gets a reply that is not about them:

Database error saving new user

Nobody can sign up. The form worked when you built it, your project is up, and the one thing you have to go on is a sentence naming no table, no column and no reason.

Here is the part that guide after guide gets wrong: this message was built to tell you nothing, and your real error is already written down somewhere else. Search the string and you are handed a block of SQL to paste, usually somebody's version of a function that writes to a table with their column names in it. Pasting it either works or adds a second problem to the first. The line that says what failed is sitting in your own project's logs, and it names the table.

What "Database error saving new user" means

Supabase accepted the new account and then undid it, because something attached to the signup failed.

A signup writes one row, into a table called auth.users that Supabase manages for you. Most projects attach something to that write: a trigger that copies the new person into a profiles table of your own, a constraint, a foreign key. When the attached thing raises an error, the database rolls the whole operation back, and that includes the row in auth.users. There is no half-made account left behind. There is also nothing in the reply saying which attached thing it was.

Think of a registration desk with a back office behind it. Somebody signs the register at the desk, and a clerk takes the new name through to copy it into a second ledger in the back. If the clerk cannot get into the room where that ledger is kept, the registration does not stand: the line at the desk is struck out and the person is told there was a problem. What nobody tells them is that the problem was a locked door forty feet away.

The vagueness is deliberate, and it is the right call. Your signup form is open to the internet. A message that named your tables would be describing your database to anybody willing to type an email address into it.

If the reply says email rate limit exceeded instead, your database is not the problem: Supabase refused to send the confirmation email, and that error has its own page.

The row in auth.users is written and then taken back out, so nothing survives the failure except a line in a log the reply never mentions.

Where is the real error?

In your project's logs, in two steps, and Supabase's own troubleshooting answer gives the order.

Reproduce it once before you go looking, so you have a timestamp to match against. Try a signup on your live app, let it fail, and note the time.

Then, in the Supabase dashboard:

  1. Open Logs and read the Auth logs. Find the entry at that time. It carries the error Auth itself recorded, which is more specific than the sentence your app was given.
  2. If that entry points at the database, go back to Logs and open the Postgres logs. The line at the same timestamp is your real error, written in the ordinary vocabulary of Postgres: a column, a relation, a type, a permission.

Supabase's troubleshooting note for this message lists three causes. You have a trigger or trigger function on the auth.users table that has an error. You have added a constraint on auth.users which is not being met. Or you are using Prisma and it has broken the permissions on auth.users. The same note is where the two-step log route above comes from.

For an app built in Lovable, Bolt, Replit or v0, the first cause covers most of them, because copying a new signup into a profiles table is the pattern every builder reaches for and a trigger is how it gets done.

Cause 1: the trigger cannot reach your table

The database role that runs your trigger is not allowed outside the auth schema, and your trigger writes outside it.

Supabase explains this on its own discussion board, and the wording is worth having exactly. The database role managing authentication, supabase_auth_admin, only has the permissions it needs to perform its duties, so when a trigger operated by supabase_auth_admin interacts outside the auth schema, it causes a permission error.

That is the clerk's pass. It opens the front office, because the front office is where their work is. The second ledger sits in a room the pass was never cut for, and a locked door reports nothing about why it is locked.

In the Postgres log this arrives as a permission error naming the table your trigger was writing into.

The fix, and what it hands over

security definer on the function, which runs it with the privileges of whoever created it instead of whoever called it.

Supabase puts it in one line: a security definer function retains the privileges of the database user that created it, and as long as that is the postgres role, your auth triggers should be able to engage with outside tables. The clerk stops carrying their own pass and carries the manager's.

That is a real transfer of authority, so it is worth knowing what you have written before you paste it. Every statement inside that function now runs as the owner. Supabase's own example pins the search path to nothing, with security definer set search_path = '', and that is why the insert in it names public.profiles in full rather than profiles. With an empty search path a bare table name resolves to nothing at all. A function carrying the owner's privileges cannot then have its target quietly swapped for a different table by whatever the caller's search path happens to say.

Which pass the function carries is the whole difference. security definer is the lower lane, and it is the owner's pass rather than a wider version of the caller's.

What this does not license is reaching for security definer the next time something is denied. Keep the function doing one thing and returning, which is what Supabase's version does.

Cause 2: a rule on the table you are writing into

Row level security on your profiles table can refuse the insert, and the signup then fails in exactly the same way.

Supabase's guide to this pattern tells you to turn row level security on for that table, and that is the right instruction. What it means for your trigger is that the insert has to be permitted, either by a policy that covers it or by a function running as somebody the policies do not apply to.

Here is where this stops being a bug and becomes a decision somebody makes at eleven at night. Nobody can register, the error says nothing, and there is a switch in the dashboard, labelled in plain language, that makes the error go away: turn row level security off on profiles. Registration works on the next attempt. What also happens is that a table holding your users, with whatever you keep in it beside their names, starts answering anybody who asks for it.

That part is visible from outside, which is how we can count it. Our scanner reads live apps the way a stranger would. We found a Supabase project behind 8,435 of the apps it has graded. On 3,680 of those the question "can somebody with no account read this table" got a usable answer at all, and 2,096 answered yes. In 394 of them the open table was named like a table of people.

Some of that is deliberate, because a published menu and a page of listings live in tables a stranger is meant to read, and our scanner cannot tell those from a table of customers that was opened to clear an error. It reports what answered. If you would rather see your own answer than reason about it, our free scan reads your live app and tells you which tables replied. It takes about 20 seconds and needs no account: scan your app.

The policy a trigger needs is a good deal narrower than switching the rules off. Turning row level security on for every table covers writing one, and "new row violates row-level security policy" is the message you get when a policy refuses an insert you can see with your own eyes.

The other causes, and how each one reads in the log

Four more, and the log line distinguishes them in a few words.

A table that is not there. Supabase's note covers this: a trigger function referencing a table that does not exist or was deleted. A profiles table dropped and rebuilt under a different name takes every signup with it, and the log names the missing relation.

A constraint on auth.users. A check or a foreign key added to that table which the new row does not satisfy. Worth looking for even when you are sure you added no trigger, because a foreign key between your own table and the auth user is the commonest way this one arrives. Left at its default, the same foreign key is what refuses the delete when that person is removed later, and that message has a post of its own.

A value that is not the right type. This one appears word for word. A trigger copying signup fields out of raw_user_meta_data reads them with ->>, and ->> hands back text whatever the value looked like going in. Writing that text into a date column produced, in one reported case, column "birth_date" is of type date but expression is of type text. The repair is a cast, with the extraction bracketed first:

(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,

Rows inserted by hand. Supabase's note also covers accounts created directly with SQL rather than through the Auth API, where required columns in the auth schema are left as NULL instead of empty strings. If somebody seeded users with an insert statement, that is a different repair from any of the above.

One more line you may meet there: duplicate key value violates unique constraint. It means the row your trigger writes was already there, for example because your app inserts the same profile after sign-up, and that error has a post of its own.

And one more: null value in column … violates not-null constraint. Your trigger wrote an empty value into a column that requires one, usually a sign-up field the form never sent, and that one has a post of its own too.

The last one you may meet is insert or update on table "profiles" violates foreign key constraint. It usually means the trigger was created before insert, so the person it writes a profile for is not saved yet. Supabase's pattern below uses after insert, and that message has a post of its own.

The working version, in full

Start from Supabase's published pattern rather than from a search result, because four things in it are load-bearing and a paraphrase tends to drop one.

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

The four are security definer, the empty search_path, the fully qualified public.profiles, and the return new that lets the signup carry on. Your own column names go into the insert, and every column you name there has to exist and has to accept what ->> gives it.

Supabase's guide adds the sentence worth repeating: if the trigger fails, it could block signups, so test your code thoroughly. Create one real account on your live app after any change to it.

Keeping your signup working after you change it

A signup is the part of your app you stop testing on the day it works. You registered once, you have an account, and after that you sign in. The next prompt that touches your tables can break registration for everybody while your own session goes on behaving normally.

Reeve Monitor looks for you:

  • all nine checks every hour, on up to three apps
  • an email the day a re-scan grades your app lower than the one before
  • whether the app is up, every 60 seconds
  • a monthly report of what it saw

A rule switched off to clear an error leaves a table of people readable. Reeve Care keeps a copy of your Supabase database where no change to your rules can reach it.

  • an encrypted copy every day, kept outside your Supabase account
  • each copy verified before it counts, by counting the rows in every table
  • a one-click restore that saves what is there now before it replaces anything
  • your uploaded files as well, once you connect a Storage credential
  • everything Monitor does

What to do today

What to do

  • Reproduce one failed signup and note the time before you change anything.
  • Read the Auth log entry at that timestamp, then the Postgres log line at the same time if the Auth entry points at the database. That line names the column, relation or permission that failed.
  • Check your trigger function carries security definer and names its target in full, as public.profiles rather than profiles.
  • If a policy on that table is refusing the insert, write a policy for it. Switching row level security off clears the error and leaves that table readable by anybody who asks.
  • Create one real account on your live app after any change to a signup trigger. Your own session hides a broken registration from you completely.

Start with the table your trigger writes into, then read the policies on every table you touched the same week. The 10-minute security checklist covers what else tends to be left open in a newly launched app, and the Supabase safety guide goes through the rest of what a stranger can reach.

FAQ

What does "Database error saving new user" mean?

It means Supabase wrote the new person into its own auth.users table, something attached to that write failed, and the database undid the whole operation including the new row. So no account was created and there is nothing half-made to clean up. The message names no table and no reason on purpose, because a signup form is open to the internet and a specific error would describe your database to anybody who tried an email address.

Where do I find the real error?

In your project's logs, and Supabase's own troubleshooting answer gives the order. Try one signup, let it fail, and note the time. Open Logs and read the Auth entry at that timestamp, which carries what Auth itself recorded. If that entry points at the database, open the Postgres logs at the same time: that line names the column, the relation or the permission that actually failed.

Does row level security on my profiles table break signup?

It can. If your trigger inserts into a table with row level security on and no policy allows that insert, the insert is refused and the signup fails with the same vague message. The fix is a policy for it, or a function that runs with privileges the policies do not apply to. Turning the rules off on that table also clears the error, and it leaves a table of your users readable by anybody who asks for it.

What is security definer and is it safe?

It makes the function run with the privileges of whoever created it instead of whoever called it, which is what lets a trigger run by the auth role write to a table of your own. It is the pattern Supabase publishes, and it is a real transfer of authority, so the function should do one narrow thing. Supabase also pins the search path to nothing in its example, which is why the insert names public.profiles in full rather than profiles.

I never created a trigger. What else could it be?

Three other things. A constraint or a foreign key added to auth.users that the new row does not satisfy. A trigger function that references a table that was renamed or deleted. And Supabase's note names Prisma specifically, because it can break the permissions on auth.users. The Postgres log line tells you which of them you have.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.