We were building a small internal dashboard for a South African accounting firm. A handful of named staff, each needing their own login. The data was already moved across. The last job was the people: create the accounts so the team could sign in.
The list of users was fixed and known, so we took the shortcut that looks completely sensible. We wrote the accounts straight into the database by hand. The records landed. Every name was there, every email was there. The list of users looked exactly like a list of users should look. By every check we could run, the accounts existed.
Then the team tried to log in, and the system threw an error.
The half a user
Here is the part that took us a while to see. When you create an account by hand, you fill in the obvious thing: the person's details. Who they are. But signing in needs a second thing as well — a separate record that links the person to their way of getting in, the email-and-password key that actually opens the door.
Think of it like adding someone to a building. Writing their name in the staff register is necessary, but on its own it gets them nothing. They also need an access card cut and tied to their name. Skip that step and you have a person who exists on paper and stands locked outside the door.
That is exactly what we had built. Half a user each. Present in the records, fully visible, looking complete — but with no key on file for the login to check against. So when someone tried to sign in, the system went looking for that key, found nothing, and fell over.
And nothing in the records warned us. The account looked finished because, on its own terms, it was. The missing piece lived somewhere we simply weren't looking, demanded by a rule the shortcut never mentioned.
Stop writing by hand, start asking properly
The fix was to stop creating accounts by hand and use the tool the platform provides for exactly this. That tool creates both halves of the user together — the details and the access key — in the right order, with everything wired up the way the login flow expects. We switched to it, and the errors stopped at once. Nothing else needed touching.
The proper tool isn't a nicety bolted on top. It's the thing that knows the rules the records don't show you.
That is the real lesson, and it reaches well past this one project. A login system is not just a place to store names. It is a set of quiet rules about what has to be true for a person to actually get in, and most of those rules are invisible if you only look at the records. The moment you write to it by hand, you've quietly made yourself responsible for every rule the platform was keeping for you — and you almost certainly don't know the whole list.
So the rule we hold to now is simple: login accounts get created through the proper tool, never written in by hand — whether you're adding one person or a whole batch. Read from the records freely. But to create a user, ask the system to do it. The few seconds you save reaching past it buy you an error that tells you nothing, on the one part of the system where being locked out is the entire failure.
Under the hood
The project was a Supabase-backed dashboard. We seeded the user accounts with a raw SQL INSERT into auth.users — the obvious move when the user list is a known, fixed set.
The catch is that Supabase doesn't keep a user in one table. auth.users holds the account record, and auth.identities holds the link between that account and each authentication method, email-and-password included. The two are bound by referential integrity: provision a user correctly and both rows are written together. Insert straight into auth.users and you get exactly half a user — visible in the table, but with no identity row for the sign-in path to resolve against. The login flow looks for the identity, finds nothing, and returns a 500. The error tells you a server-side failure happened and nothing about which row is missing, which is what made it slow to diagnose.
The fix was to provision through Supabase's Admin API — POST /auth/v1/admin/users — instead of touching the tables directly. It writes the auth.users and auth.identities rows together, in the order the auth system expects, with password hashing and linkage handled the way the sign-in flow assumes. Switching user creation over to it cleared the 500s with no further changes.
The transferable point: a managed auth schema enforces invariants (which rows must co-exist, which derived fields must be populated) that are invisible from the table definition alone. Writing to it with raw SQL makes you silently responsible for the full set of those invariants — so create auth users through the Admin API or the dashboard, never by direct insert, whether it's one account or a bulk load.