Two sign-in bugs — a blank dashboard after login, and a Google authentication that completed but left no session — became the starting point for replacing Orma's entire authentication layer. The investigation started at the symptom and worked backward until it reached the root: the auth system itself was being retired along with the rest of the managed platform it ran on.
The first bug arrived as:
https://xeve.io/ when i click sign in blank page is coming , can u check
A blank page where the dashboard should have loaded after signing in. The investigation pulled up the redirect handling, then the session propagation, and kept going — the sign-in flow worked in isolation but not as a sequence. Patching the immediate issue would have been the right call if the underlying system was going to stay. It wasn't.
The second bug followed:
i see google sign in , i click the account and sign in but nothing happens after
Native Google sign-in on the macOS app was completing the OAuth exchange but not landing a session. Both bugs pointed at the same thing: the auth layer was not reliable across all the paths the app needed it to cover.
The decision was to replace GoTrue — Supabase's authentication service — with Better Auth, an open-source library that runs in the app's own server process.
The constraint that shapes everything
Replacing an authentication layer is not like swapping a library. Orma's schema has 42 application tables. Every one of them carries the user's ID as a foreign key — app sessions, coding sessions, GitHub activity, health samples, Spotify history, location logs. Every piece of data any user has ever sent links back through that ID. A new auth system that issued new identifiers, or a different format, would orphan every row silently. The dashboard would load and show nothing, with no error to explain why.
Better Auth's default identifier format is a nanoid string. Orma's schema expected UUIDs. More importantly: even if the column types had matched, the actual UUID values from GoTrue needed to carry over exactly, byte for byte. A regenerated ID with the same format would still break every foreign key that referenced the original.
The migration was validated against the real production data export. Each profile was checked: was there a matching user row? Were the password credentials present? Were Google OAuth identities matched by the provider's own subject ID — not by email address, because matching by email lets anyone who controls an address at any provider claim an existing account.
Passwords that couldn't be reset
GoTrue hashes passwords with bcrypt. Better Auth uses scrypt. These are different algorithms, and there is no way to convert a bcrypt hash to a scrypt hash without the original plaintext. A migration that simply replaced the stored hashes would have invalidated every password without any indication that it had done so. It would have looked like a working deploy until someone tried to sign in.
The fix was a verify hook in the new auth layer. On a first successful login after the migration, the hook checks the incoming password against the stored bcrypt hash; if it matches, it produces a new scrypt hash and replaces the old one. From the second login onward, it is a normal scrypt check. Nobody is asked to reset a password. The transition happens on first use.
This also meant one specific test was required: a real bcrypt hash run through the auth layer's own verify function, not a fixture, to confirm the hook was actually being called in the path a login takes.
The App Usage dashboard: total time, app count and switches, time by category, and top apps.
The view that kept five policies running
Five row-level security policies in the database — the rules that enforce which rows a user is allowed to read — referenced auth.users, a table that only existed inside Supabase's schema. With GoTrue gone, that table would be gone too.
The solution was to leave it in place as a view over the new public."user" table. The five policies and the accept_org_invite() function kept working without modification. A detail mattered: the security_invoker setting on the view was left at its default of false. Setting it to true would have made those policies fail closed — inviting joins and pending team invitations would have stopped appearing, with no error message.
The original auth.users table was renamed rather than dropped. The cutover stayed reversible.
Two bugs that only appeared against real data
The migration was tested with 13 new tests, bringing the total for the auth layer to 37. Two failures appeared before the migration touched production.
The first: the migration guard aborted immediately, reporting that profiles had no matching user row. The cause was in the guard itself — it was checking whether a deleted_at column existed on the table, rather than whether it was set on each row. That condition excluded every profile, not only the deleted ones. Aborting on that condition was the right behavior. Proceeding would have been worse.
The second: GoTrue's signup trigger creates a profile row for every user as part of registration, and that row persists even after a soft delete. The migration's first pass skipped deleted users to avoid carrying over accounts that were no longer active. But skipping them orphaned their profiles — the foreign key on each profile still pointed at a user ID that would no longer exist. They were carried over with a user row but no account row: the foreign keys hold, they cannot authenticate, and nothing was removed on the strength of a flag that had been set for reasons no longer in the code.
Neither failure came from the migration plan being wrong. Both came from the migration guard making incorrect assumptions about what it was checking, and from a detail about GoTrue's own signup behavior that only surfaced when the check ran against real data rather than a constructed test case.
What the migration unlocked
PR #18 merged on 19 August 2026, the same day the backend migration from Supabase began in earnest. The sign-in bugs that prompted the original investigation were resolved as part of the same work.
Auth now runs in the app's own server process. Session state lives in the same database as every other table. Migrations are in the repository. The test suite covers the edge cases that appeared during the transition. And because the original user IDs survived exactly, the 42 tables that depended on them required no changes at all.
Orma (formerly xeve) is free during early access at orma.xeve.io. The macOS and Windows trackers run without timers; the VS Code extension and Claude Code hook track coding time; the web dashboard has 24+ pages, a correlation engine that computes 19 metric pairs, an AI weekly digest, and team dashboards with role-based views.
