Who Owns the User Row?
You already have a users table. Two dozen foreign keys point at it, half your
queries JOIN against it, and your auth middleware turns its rows into principals.
Now you’re adopting Auther, a centralized identity provider — and suddenly a third
party owns identity. So who owns the row?
That’s the question this whole part of the course turns on. When you adopt a centralized IdP, you stop owning authentication: Auther runs sign-up, sign-in, password hashes, 2FA, session tokens, and key rotation. Your app gets a signed JWT with claims, and that’s it. (If you haven’t yet, the Auther overview explains what the server itself does.)
But your app database is not a blank slate, and you can’t hand it a JWT and walk away. The one decision that makes the entire integration tractable is this: for every piece of data on the user row, decide who is allowed to be the source of truth. Get that right and schema, sync, and failure handling all fall out of it.
What you already have
Section titled “What you already have”In the running reference example — the hono-node-template-2026 monorepo — the
schema you start with looks like this. The auth-related tables come straight from
better-auth; the rest are ordinary domain tables with foreign keys back to
users.id.
users (id, email, name, emailVerified, image, status, roleSlugs, ...)sessions (user_id → users.id ON DELETE CASCADE, platform, activeOrganizationId, ...)accounts (user_id → users.id ON DELETE CASCADE, password, providerId, ...)verifications (identifier, value, expiresAt)two_factors (user_id → users.id ON DELETE CASCADE)jwks (public_key, private_key, ...)notifications (user_id → users.id ON DELETE CASCADE)push_tokens (user_id → users.id ON DELETE CASCADE)notification_preferences (user_id → users.id ON DELETE CASCADE)audit_logs (actor_id → users.id)organizations (owner_id → users.id)members (user_id, org_id)auth_relations (subject_id, object_id, relation) -- plain textEvery one of those arrows assumes the row on the other end exists locally. And your application code is riddled with references that assume the same — filters, lookups, cascading deletes, and the helper that turns a user into a principal:
const conditions: SQL[] = [eq(notifications.userId, userId)];
// apps/server/src/modules/users/service.tsdb.query.users.findFirst({ where: eq(users.id, id) });
// apps/server/src/modules/auth/instance.tsawait db.delete(schema.sessions).where(eq(schema.sessions.userId, session.userId));
// apps/server/src/auth/middleware.tsconst principal = principalFromUser(c.get("user"));Now Auther wants to run identity. Faced with that, every engineer reaches for one of two shortcuts first — and both are traps.
The two tempting shortcuts (both wrong)
Section titled “The two tempting shortcuts (both wrong)”Frame it as ownership, column by column
Section titled “Frame it as ownership, column by column”The way out is neither “all Auther” nor “all local” — it’s both, decided one column at a time. The correct framing is ownership: for each column on the user row, pick a single source of truth. Some fields are pure identity and belong to Auther; some are pure product state and stay in your app; a few are genuinely split.
Done for the whole row, that decision is the centerpiece of the entire integration — this one table is the contract everything else is built on:
| Column | Authoritative in | Notes |
|---|---|---|
id | Auther | Local row uses Auther’s ID verbatim as PK |
email, emailVerified | Auther | Your app mirrors for joins/display |
name, image | Auther | Your app mirrors |
| password / argon2id hash | Auther | Your app never stores |
| 2FA secret / backup codes | Auther | Your app never stores |
status (active/inactive) | Auther | Auther’s admin plugin (banned + banReason) |
failedLoginAttempts | Auther | Auther’s login-security plugin |
lockedUntil | Auther | Same |
twoFactorEnabled | Auther | Inferable from Auther’s userinfo endpoint |
onboardingCompletedAt | Your app | Pure product state; Auther doesn’t know about it |
notificationPreferences | Your app | Your app |
roleSlugs | Split | Platform roles → Auther; app-domain roles → your app |
activeOrganizationId | Your app | Tenant selection is an app concern (see the Edge Cases Catalog) |
With ownership decided, the next question is structural: should a local users
table exist at all, and if so, what lives in it? That’s the subject of the
four integration archetypes.