auth · tutorial

One Auth Flow, Three Databases, Zero Forced Logouts

Same login on all three databases: identical user rows, adapter per engine, sessions that survive the switch. The parity pattern inside.

A street locksmith at work on a London pavement, photographed by John Thomson in 1877
A street locksmith at work on a London pavement, photographed by John Thomson in 1877 · Photo: John Thomson, no known copyright restrictions, via Wikimedia Commons

Auth welded to one database

The app starts on SQLite.

Fast.

Local.

Perfect.

Users accumulate.

Then scale, a co-founder or compliance demands Postgres, and auth is welded to SQLite-isms.

Integer row IDs as user IDs.

Session lookups that assume file locking.

Migration becomes surgery on the beating heart.

User IDs change shape.

Sessions invalidate everywhere.

Everyone logs in again on cutover day.

A database decision, supposed to be infrastructure, becomes a user-facing event.

Auth should never know which engine runs.

Same rows.

Same actions.

Portable humans.

Welded or portable

Moment Welded to one database Portable
Adding a second engine Rewrite the adapter and the IDs Write one adapter
Switching engines A global forced re-login Sessions can survive, if IDs carry over
User ID shape Row ID, ObjectId or UUID Opaque text everywhere
An agent adds a field One engine, so drift Every engine in the same change
A big scale-up A panic migration Move the rows, change one setting

Welded auth fails at the worst moment. Three reasons.

ID shapes diverge silently. SQLite integers, Mongo ObjectIds, Supabase UUIDs. Sessions, foreign keys and URLs all embed the shape. Opaque text IDs, generated once and never interpreted, cross every engine unchanged.

Forced re-logins burn trust. A cutover-day mass logout reads as a breach, an outage or incompetence. Signed sessions validate by signature with no database lookup, so a flip does not by itself invalidate live logins, provided user IDs carry over.

Agents entrench the first engine. Every session without a parity rule deepens one engine's idioms. The longer that goes on, the more expensive freedom gets.

What to do

Fix the user record identically on every engine.

An opaque text ID.

A unique email.

A flag for paid access.

A plan type.

Same names, same types, same constraints, decided once per entity.

Email is unique everywhere. Identity must be singular across engines, or merges haunt you.

Give each engine its own sign-in adapter, behind the same behavior.

The auth library talks to the adapter and never to a database directly. It cannot tell which one runs. New fields land on all three in the same change, or the change is incomplete.

Keep identity in the session. Check the user on each guarded request.

The session carries only the user's ID. A guard reads that user's row to decide access. Signed sessions validate without a lookup, and one indexed read decides what the person may do.

Moving engines becomes: move the rows, change the database setting, and users keep shopping.

The engine-flip drill

Check Action Pass looks like
Row parity Compare the three user definitions Same fields, same constraints
Behavior parity Run the same sign-in on each engine The same result everywhere
Live sessions survive Flip the setting on staging mid-day No forced re-logins
A new field Add one on all engines One change, every engine

Drill the flip on staging every so often. A portability claim never rehearsed is marketing. Rehearsed, it is architecture.

The cost

Parity triples the adapter code. Three small pieces instead of one. Opaque IDs also give up native conveniences, like autoincrement ordering.

Pay it once, up front. Freedom compounds across every later decision.

Know this first

Parity triples the adapter code. Opaque IDs give up native conveniences.

That cost is paid once, up front.

Why BoringPush is the way to do this

People that move.

A user is the same record on SQLite, MongoDB and Supabase. BoringPush ships a sign-in adapter for each.

Switch engines. Your users stay.

FAQ

Does portable auth slow down logins?

No. Signed sessions validate with a signature check, no query.

The guard's single indexed read costs microseconds on every engine. Users cannot tell engines apart by latency; only your hosting bill can.

How do Supabase row-level policies fit?

BoringPush talks to Supabase from the server with a privileged key, so access rules live in the app's guards. Row-level policies are extra armor you can add on top, but they are not what the kit relies on.

What migrates passwords if there are none?

Nothing. Passwordless sign-in (Google plus magic links) means no credential material exists to move. OAuth identities re-link by email on the first login after a flip, and magic links work against migrated rows right away.

Should sessions store the engine name?

No. Engine-blindness is the feature. Log the engine below the adapter if you need it for debugging, never inside tokens.

How do I fix drift when one engine falls behind?

Freeze writes to the drifted field, export all three, compare them with a program rather than by eye, and write one convergence change per engine. Do it on staging first, and prevent the next drift with regular comparisons.

When is single-engine auth fine?

When you are sure the engine will never change: a Supabase-only shop, a stable SQLite-forever solo, or a prototype with a disposal date. The honest test: will I still run this engine in three years, guaranteed? If you hesitate, keep parity.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading