One Signup, Two Profiles: Guarding the Public/Private Invariant with Better Auth

Storyie Engineering Team
6 min read

A Better Auth after-create hook keeps Storyie's public and private profile invariant: one signup inserts both rows; missing profiles fail closed elsewhere.

Signing up for Storyie looks like one action: pick Google or Apple, land in the app. Underneath, that single auth user is not enough. Almost every authenticated screen expects a public profile (what other people can see) and a private profile (email, language, notification prefs). If either row is missing, account-name setup, OAuth landing, and slug APIs all break in different ways — or worse, invent a half-filled row and paper over the real bug.

This post is about the invariant we chose and the small Better Auth hook that enforces it: one real signup inserts both profile rows, and every other path is forbidden from creating them on the fly.

TL;DR

  • One Better Auth user row is paired with two app tables: public_profiles (social surface) and private_profiles (settings surface), both keyed by user.id.
  • Provisioning lives in databaseHooks.user.create.aftercreateProfilesAfterUserCreate — a port of the old Postgres handle_new_user trigger.
  • Inserts use onConflictDoNothing so a retried hook is idempotent and does not clobber later setup.
  • slug stays null at creation; account-name-setup UPDATEs it later and never upserts a missing profile.
  • Callers that only read or UPDATE fail closed when the row is absent (OAuth callback → login error; slug paths → explicit failure).
  • Anonymous guests are skipped; they get profiles only after linking a real account (guest migration).

The invariant: one auth user, two profile rows

Better Auth owns identity. Storyie owns everything that is about that identity in the product. We keep those concerns in separate tables:

// packages/database/src/schemas/auth.ts (excerpt)
export const privateProfiles = sqliteTable("private_profiles", {
  id: text("id").primaryKey().notNull(), // same as auth user id
  email: text("email").notNull(),
  notificationSettings: text("notification_settings", { mode: "json" }),
  theme: text("theme"),
  preferredLanguage: text("preferred_language").default("en"),
  // ...
});

export const publicProfiles = sqliteTable("public_profiles", {
  id: text("id").primaryKey().notNull(), // same as auth user id
  name: text("name").notNull(),
  avatarUrl: text("avatar_url"),
  slug: text("slug").unique(),
  bio: text("bio"),
  // ...
});

The split is deliberate. Public pages and SEO need a stable slug and display name without pulling notification JSON into every query. Settings screens need email and prefs without exposing them through the public profile API. Both rows share the auth user id as their primary key — there is no separate "profile id" to keep in sync.

The product rule that falls out of that schema is simple: after a non-anonymous signup, both rows must exist. Everything downstream assumes they do.

From a Postgres trigger to a Better Auth hook

Under Supabase we relied on a database trigger (handle_new_user) to insert the two profile rows whenever a user appeared. Better Auth does not run that Postgres trigger — it inserts through its Drizzle adapter — so the same work has to live in application code.

Better Auth's answer is databaseHooks. We attach an after callback on user.create:

// apps/web/lib/auth/server.ts (excerpt)
databaseHooks: {
  user: {
    create: {
      after: createProfilesAfterUserCreate,
    },
  },
},

The callback itself is intentionally tiny and extracted out of server.ts. Importing the full auth module in Jest pulls in @better-auth/expo (ESM), which breaks the unit-test graph. The hook module is the testable unit:

// apps/web/lib/auth/profile-hooks.ts (excerpt)
export const createProfilesAfterUserCreate = async (
  user: User & Record<string, unknown>
): Promise<void> => {
  if (user.isAnonymous) return;

  const name = user.name || user.email.split("@")[0];
  const avatarUrl = user.image ?? "";

  await Promise.all([
    db.insert(publicProfiles).values({ id: user.id, name, avatarUrl }).onConflictDoNothing(),
    db
      .insert(privateProfiles)
      .values({ id: user.id, email: user.email, preferredLanguage: "en" })
      .onConflictDoNothing(),
  ]);
};

Field mapping is the same compatibility story we used elsewhere when leaving Supabase Auth: Better Auth's user.name / user.image become name / avatar_url on the public profile, and email lands on the private row. The session shim that maps those fields for callers is covered in Migrating Authentication Without Rewriting Every Caller; this hook is the write-side counterpart that seeds the tables those callers eventually read.

Anonymous users return immediately. A guest is a real auth row (see Guest Accounts Without Data Loss), but they have no public page and no settings surface until they link a real account. Creating empty profiles for every throwaway guest would pollute slug uniqueness checks and social queries for no benefit.

Why onConflictDoNothing instead of upsert

The first instinct for "make sure the row exists" is onConflictDoUpdate. We rejected that for the create hook.

Signup is not always a single clean insert. OAuth retries, double-submits, and guest-link flows can re-enter the create path for an id that already has profiles. An upsert would "succeed" by overwriting name / avatar_url / email with whatever the second pass happened to carry — possibly after the user had already started account-name setup or changed prefs.

onConflictDoNothing keeps the hook idempotent and non-destructive: the first insert wins; a retry is a silent no-op. If we ever need to refresh avatar data from the provider, that belongs in an explicit update path, not in the create hook's conflict clause.

The same pattern appears in migrateAccountData's defensive back-fill when a guest links into an account whose profiles are somehow missing — insert if absent, never clobber:

// apps/web/lib/db/queries/account.ts (excerpt)
await tx
  .insert(publicProfiles)
  .values({ id: newUser.id, name: newUser.name || newUser.email.split("@")[0] })
  .onConflictDoNothing();
await tx
  .insert(privateProfiles)
  .values({ id: newUser.id, email: newUser.email, preferredLanguage: "en" })
  .onConflictDoNothing();

That back-fill is the second allowed writer. It exists because guest → account linking is a second chance for a brand-new user id (or a merge into an existing one). It is still insert-only on conflict, not an upsert of arbitrary fields.

Slug is not part of signup

Notice what the create hook does not set: slug. The column is unique and user-facing — it becomes the account name in URLs — so inventing one from email or OAuth display name would collide, look wrong, or both.

Instead the lifecycle is staged:

  1. Hook inserts public_profiles with slug = null.
  2. OAuth callback sees a profile without a slug and sends the user to /account-name-setup.
  3. updateUserSlug / the mobile slug route run an UPDATE and treat a 0-row RETURNING as failure.
// apps/web/actions/slug.ts (excerpt)
// Profiles are created by createProfilesAfterUserCreate / migrateAccountData;
// this action never upserts a missing profile.
const updated = await db
  .update(publicProfiles)
  .set({ slug: slug.trim() })
  .where(eq(publicProfiles.id, user.id))
  .returning({ slug: publicProfiles.slug });

const updatedRow = updated[0];
if (!updatedRow?.slug) {
  return {
    success: false,
    error: SlugError.UNKNOWN_ERROR,
    errorMessage:
      "Could not update account name: profile not found. Please try signing in again.",
  };
}

We used to be tempted to "heal" a missing profile inside account-name setup with an upsert. That healed the symptom and hid the disease: the create hook had failed (or never run), and the next path that assumed a private profile or a consistent id would fail differently. Separating create from slug assignment keeps each step honest about what it owns.

Fail closed: readers must not invent a profile

The hook comments spell out who is allowed to create rows:

Path

Role

createProfilesAfterUserCreate

Primary writer for non-anonymous signups

migrateAccountData back-fill

Secondary writer on guest → account link

OAuth callback, slug UPDATE, account-name-setup

Read / UPDATE only — missing row fails closed

The OAuth landing route is the sharpest example. After Better Auth has already set the session cookie, we still refuse to proceed without a public profile:

// apps/web/app/api/auth/callback/route.ts (excerpt)
const profile = await profileQueries.getPublicProfile(session.user.id);

if (!profile) {
  return NextResponse.redirect(
    `${origin}/login?error=${encodeURIComponent(
      "Account not found. Please create an account using the mobile app."
    )}`
  );
}

if (!profile.slug) {
  return NextResponse.redirect(`${origin}/account-name-setup`);
}

That looks harsh — the user just authenticated successfully — but a session without profiles is an inconsistent account. Redirecting to login with an error is better than dropping them into a product that will 500 on the next settings or slug call. The mobile /api/v1 slug route applies the same rule: 0 rows updated → 404 / NOT_FOUND, not an insert.

profileQueries still exposes upsertPublicProfile / upsertPrivateProfile for intentional admin-style updates. Those are not the signup path, and they are not what the OAuth callback or slug setup call when a row is missing. The invariant is about who is allowed to create on the critical path, not about banning upserts from the query module entirely.

Takeaways

  • Treat "auth user exists" and "app profiles exist" as separate facts, then glue them with one hook so you do not scatter inserts across OAuth, setup, and APIs.
  • Prefer onConflictDoNothing on create hooks when retries are possible and later steps own mutable fields.
  • Leave uniqueness-sensitive fields (like slug) out of automatic provisioning; UPDATE them in a dedicated step that fails if the parent row is gone.
  • Fail closed on missing profiles at login and slug boundaries — silent healing hides broken signup wiring.

Related reading: how guest linking back-fills profiles without data loss in Guest Accounts Without Data Loss, and how callers still read a Supabase-shaped user through one shim in Migrating Authentication Without Rewriting Every Caller.

Available on iOS & Android

Download on the App StoreGet it on Google Play