At DinaWaste (formerly known as Campusportalen) we build a multi-tenant portal that organizations use for everything from dinner ordering to bulletin boards, activities and requests. Until recently, it was built the way a lot of modern apps are built: a React frontend talking straight to Supabase. Supabase handled the database, the authentication, file storage and realtime, and our frontend just... asked the database for things.
The first version of the system was built by my co-founder Rasmus, with a lot of help from AI. When it came to picking the stack, the AI recommended Supabase, with Row Level Security handling who can access what (more on that in a bit), and it was by far the easiest way to get going, so he went with it. Which is a completely reasonable call. You can get really good results with AI, and heck, I use it a lot myself when coding. But if you let an AI choose your architecture, Supabase is one of the most typical picks there is: one service for the database, auth, storage and realtime, and a frontend that can talk to all of it directly. It's a fantastic way to get something running fast. It's just not always what you'd end up with if you sat down and read up on the options first. Which is exactly why you should always read up on stuff before building anything with AI.
And honestly, that setup did a great job for a long time. It let us ship fast. But we're building a mobile app now, and once you start thinking about two clients sharing the same rules, the cracks start to show. So over roughly 10 days, I moved all of our data access behind our own API, handed migrations over to Drizzle, and moved every single user off Supabase Auth and onto Better Auth. All of that without asking anyone to reset their password.
In this blog post I'll be doing a deep dive into how I did it, why the order mattered more than I expected, and the bugs I found along the way.
The Problem
Before the migration, the browser read and wrote the database directly through Supabase's Data API. The only thing standing between a user and every row in the database was Row Level Security (RLS).
If you don't know what RLS is, it's a Postgres feature that lets you attach rules to a table, deciding which rows a given user may see or change. You can read about it in the Postgres docs, or in Supabase's guide, which explains it really well.
RLS is genuinely a great feature. The problem was how much we leaned on it:
-
Our business rules lived in SQL Who can read a bulletin post, who can manage an activity, who counts as an admin. All of it was policies,
security definerfunctions and triggers. Over its lifetime, the schema had defined 186 different RLS policies. -
None of it was easy to test Testing a policy means spinning up a database, faking a signed-in user, and checking which rows come back. We had tests, but every rule change was a lot more work than it had to be.
-
None of it could be shared with a mobile app The mobile app would have had to talk to the database the exact same way, and inherit every quirk.
-
Everything was tied to Supabase Auth This one turned out to be the most important, so it gets its own section.
Individually, none of these were dramatic. Combined, they meant the most important logic in our product was the hardest logic to change.
Why the order mattered
My first thought was to start with authentication. Supabase Auth was the most "locked in" part, and the mobile app needed a shared auth setup anyway.
Then I actually looked at the policies. Here's one of them, deciding who may view images on the bulletin board:
create policy bulletin_member_select
on storage.objects for select to authenticated
using (
bucket_id = 'bulletin'
and exists (
select 1
from public.organizations organization
join public.organization_memberships membership
on membership.organization_id = organization.id
where organization.slug = (storage.foldername(name))[1]
and organization.status = 'active'
and membership.profile_id = (select auth.uid())
and membership.status = 'active'
)
);It parses the organization out of the file's folder name, joins two tables, and then checks the membership against auth.uid(). And auth.uid() is the problem. It's Supabase Auth's function for "who is the signed-in user", read from Supabase's own JWT.
It shows up 159 times in our migration history.
So every single authorization rule we had depended on Supabase Auth being the thing that signs people in. If I had swapped auth first, every policy would have stopped working at once. The order wasn't really a choice: first move authorization out of the database, then move authentication.
Step one: Replacing RLS with a typed API
The goal was simple to describe: the browser should never talk to the database again. Every read and write goes through a versioned REST API at /api/v1, and that API is the only thing that knows the rules.
I built it with Hono, mounted inside our TanStack Start app, and Drizzle for the database. Every feature got a pair of files: activity.routes.ts for the HTTP side, and activity.service.ts for the logic.
So how did authorization work now?
Instead of policies, every route declares what it needs, right next to the route:
const read = guard({ module: "activities", permission: "activity.read" });
const join = guard({
module: "activities",
permission: ["activity.read", "activity.join"],
});
const manage = guard({ module: "activities", permission: "activity.manage" });
export const activityRoutes = new Hono<OrganizationEnv>()
.get("/", read, async (c) =>
c.json(
await listActivities(getDatabase(), c.var.access, c.req.valid("query")),
),
)
.post("/", manage, async (c) =>
c.json(
await createActivity(getDatabase(), c.var.access, c.req.valid("json")),
201,
),
);(Validation trimmed to keep it readable.)
Before a request reaches guard, a middleware has already resolved the user and their access to the organization in the URL. guard then just checks the requirement. A disabled module answers 404 (as far as that organization is concerned, the feature doesn't exist), and a missing permission answers 403.
The fun part is the types. guard is generic over the module:
interface Requirement<M extends OrganizationModuleKey> {
module?: M;
feature?: FeatureOf<M>;
permission?: OrganizationPermissionKey | readonly OrganizationPermissionKey[];
}So if you write guard({ module: "activities", feature: "images" }) and activities doesn't have an images feature, it doesn't compile. Compare that to a typo inside a SQL policy, which compiles just fine and silently returns zero rows.
The frontend uses a typed hono/client, whose types are inferred straight from the server routes. If I change what an endpoint returns, every caller that breaks fails the typecheck. No codegen, no schema file to keep in sync.
Locking the front door
Moving the logic into an API doesn't help much if the browser can still talk to the database directly. So the API connects as its own dedicated database role, campus_api, and the roles Supabase gives to browsers (anon and authenticated) lost every privilege they had. Not a single table. We have a SQL test that asserts exactly that, so it can't quietly come back.
This has one funny side effect. Supabase's Security Advisor now reports "RLS disabled" on every single table in the database. For once, a dashboard full of red warnings was exactly the plan.
The migration itself
This was a hard cutover. The old app breaks the moment the migration commits, so it shipped in a maintenance window, together with the new app. Normally you'd do these changes in small backwards-compatible steps, but for a one-off rewrite like this, all that compatibility code would just be thrown away a week later. We control the window, so we used it.
To be safe, the migration first snapshots every table into a legacy_20260925 schema, and only then starts tearing things down. Removing every policy was, almost disappointingly, just a loop:
do $drop_policies$
declare
policy record;
begin
for policy in
select schemaname, tablename, policyname
from pg_catalog.pg_policies
where schemaname = 'public'
loop
execute format('drop policy %I on %I.%I', policy.policyname, policy.schemaname, policy.tablename);
end loop;
end
$drop_policies$;Every policy we had, gone in 13 lines. The whole thing runs in one transaction with a lock timeout, so if anything fails, the old schema is left untouched.
So how did cutover night go? Surprisingly seamless, without any (major) issues. A few things popped up in Sentry over the following days, but nothing detrimental. That said, "seamless" still meant staying up until 4am on a weekday, making sure everything went accordingly. And as a bonus, I had school at 8am. (Yes, I'm still a high school student.)
Bugs I found along the way
Rewriting every data path is a great way to find bugs you didn't know you had. A few of my favourites:
- HEAD requests downloaded entire images. Hono answers
HEADthrough theGETroute, so my separateHEADhandlers never ran, and everyHEADrequest streamed the whole file. - Logging out on another device didn't log you out here. A revoked session didn't send the browser back to the login page. Now the API's
401counts as signed out. - The kitchen summary could be sent twice. It decided whether a summary was stale using the app's clock, and the database's clock didn't always agree. Now it asks the database.
- For a short while, every member was an admin.
member.readwas accidentally counted as an admin permission, so the admin area let everyone in. Very democratic, very not okay. Luckily this one was caught on the branch, before it ever reached production.
The Results
The API cutover deleted about 36,000 lines and added about 24,000. Every authorization rule is now TypeScript, tested by driving the real API against a real Postgres database (PGlite in tests), and the web app and the coming mobile app share the exact same rules.
A small detour: Letting Drizzle own migrations
The day after, I fixed something that had annoyed me for a while. We used Drizzle for queries, but migrations were still hand-written SQL, with the Drizzle schema kept in sync by hand. So we were maintaining the schema twice, and Drizzle's best feature, generating migrations from the schema, went unused.
Now you change the TypeScript schema, run pnpm db:generate, and commit the SQL it writes. One runner applies migrations everywhere: locally, in CI, in the test database and in production.
The interesting part is what the runner refuses. Drizzle would happily do these three things without telling you:
- Apply a migration that was edited after it was already applied somewhere
- Apply a migration that's older than the last applied one (a branch that fell behind
main) - Run against a database that's ahead of the code
All three are now hard errors. And a bonus discovery: drizzle-kit exits with code 0 when it crashes. So our schema check doesn't trust the exit code, it waits for drizzle-kit to actually print its all-clear. I didn't expect to write code that reads a tool's success message, but here we are.
Step two: Moving every user to Better Auth
With authorization out of the database, auth.uid() was no longer holding anything up. Time to move authentication.
I picked Better Auth, which stores everything in our own database, and has an official Expo plugin for the mobile app.
The password problem
Here's the thing about passwords. Supabase stored them as bcrypt hashes, and Better Auth uses scrypt by default. You can't convert one hash into another, because the whole point of a hash is that you can't get the password back out of it.
So the obvious options were:
- Force everyone to reset their password
- Keep bcrypt forever
Option one is a terrible experience, and a support nightmare. Option two means carrying Supabase's choices around forever.
So how did I fix this?
There's a third option. The cutover migration copies every account over with the same id, email and bcrypt hash. Then, the first time someone signs in, we already have their password in hand for that one moment. So we check it against the old bcrypt hash, and if it matches, we re-hash it with scrypt and swap it in.
Here's the whole thing:
/** A bcrypt hash, which is what Supabase Auth stored. Better Auth's own hashes are `salt:key`. */
const isLegacyHash = (hash: string) => /^\$2[aby]\$/.test(hash);
export function legacyAwareVerify(db: Database) {
return async ({
hash,
password,
}: {
hash: string;
password: string;
}): Promise<boolean> => {
if (!isLegacyHash(hash)) return verifyPassword({ hash, password });
// Only a legacy hash needs bcrypt, so only then is it loaded.
const { compare } = await import("bcryptjs");
if (!(await compare(password, hash))) return false;
await db
.update(accounts)
.set({ password: await hashPassword(password) })
.where(
and(eq(accounts.providerId, "credential"), eq(accounts.password, hash)),
);
return true;
};
}A few details I'm quite happy with:
- The update matches on the old hash itself. bcrypt hashes are salted, so no two accounts share one, and the hash identifies exactly one account. And if the user happens to change their password at the exact same moment, the hash no longer matches, so their change wins.
- bcrypt is only loaded when it's needed. Once you've signed in once, your account never touches it again.
- The code has an expiry date. Like milk. The file, and the
bcryptjsdependency, get deleted in the first release after 2027-04-01. Anyone who still hasn't signed in by then can use the normal forgot-password email.
Everyone did have to sign in once more after the cutover, but with the password they already had.
Less than a week after the cutover, 80% of the copied accounts had already been quietly upgraded to scrypt, without anyone noticing a thing.
Sessions
Supabase Auth gave the browser a JWT. Now the web app uses an httpOnly cookie, which JavaScript on the page can't read at all, and the mobile app keeps the same session in the phone's secure storage.
The session is read from the database on every request. No caching, no "valid for the next 15 minutes". That costs a query per request, but it means that when someone clicks "log out all devices", or an admin deactivates an account, it takes effect on the very next request.
Cookies do bring one classic problem: the browser sends them on its own, even when the request comes from some other website. So the API refuses any write whose Origin isn't our app (or an organization's own custom domain).
Keeping Better Auth in a box
I really didn't want to end up in the same position again, with an auth library woven into every corner of the codebase. So only one package, @campus/auth, is allowed to import Better Auth, and ESLint enforces it:
const betterAuthImport = {
group: ["better-auth", "better-auth/*", "@better-auth/*"],
message:
"Only packages/auth may import Better Auth. Use @campus/auth/server, @campus/auth/client or @campus/auth/native.",
};The API, the web app and the mobile app all go through that one package. If we ever need to replace Better Auth, it's one package to rewrite, not the entire codebase.
One gotcha
Better Auth lets you send the password reset email from a callback, sendResetPassword. The catch is that if your callback throws, Better Auth logs the error and swallows it. That's a great feature right up until an invitation email silently never arrives, and the API happily reports success.
So instead, Better Auth just issues the token, and our API sends the email itself, where a failure actually surfaces.
The Results
The only things that changed for users were one extra sign-in, and platform staff having to set up their authenticator app again (their two-factor setups weren't carried over). Nobody had to reset their password.
Final Results
Across these changes:
- The browser no longer talks to the database. Every read and write goes through one typed API
- 186 RLS policies were replaced by authorization in TypeScript, checked by the compiler and tested against a real database
- Migrations are generated from the schema, and the runner refuses the cases that would silently go wrong
- Every user moved from Supabase Auth to Better Auth, without a single password reset
- The web app and the coming mobile app share the same API, the same rules and the same auth package
And Supabase is still here. It hosts our Postgres database, our file storage and our realtime signals, and it does those really well. What changed is that the rules about who can do what now live in our own code, where we can test them.
Lessons Learned
Most of the work here wasn't caused by Supabase doing anything wrong. It came from building on top of convenient defaults, and slowly building more and more on them:
- authorization written in the database because that's where the tool put it
- every rule tied to one auth provider's
auth.uid() - migrations and schema maintained twice, because that's how it started
None of these hurt when the product was small. But each one made the next change harder, and they were all tangled together, so the order you untangle them in suddenly matters a lot.
So the lesson I've learned from this: use managed platforms for the infrastructure, but own the rules. The database can be someone else's problem. Who's allowed to see what should be yours.
A note on AI
I can't really write this post without mentioning how it was built. I did this migration together with Claude Code, Anthropic's coding agent, and honestly, a migration at this scale in 10 days would have been close to impossible without it. That's 186 policies to replace, every data path in the app to rewrite, and every user to move, while also going to school.
Now that might sound a little ironic, after I just blamed AI for picking Supabase in the first place. But that's kind of the point. The difference was never whether AI was involved. It was who made the decisions.
Before anything was written, I read up on the options and picked the architecture, and the order to do it in. The hard cutover instead of small compatible steps, re-hashing on sign-in instead of forcing password resets, cookies instead of tokens, those were my calls. Claude did a huge part of the typing, the testing and the digging, and found plenty of the bugs above. But it worked inside a plan I understood, and every change had to pass the exact same tests and checks as anything else.
So AI didn't replace reading up. If anything, it made reading up the most important part of the job.