Written with OpenAI Codex

Authentication systems often store users in two places:

  • An identity provider such as Clerk
  • An application database managed through Prisma

That arrangement works well until the two systems disagree.

We recently found an edge case where a Clerk user still existed, but the corresponding Prisma User row did not. The user remained authenticated, yet their application account became unusable.

The redirect loop

Every dashboard route followed roughly this pattern:

const { userId } = await auth();

if (!userId) {
  redirect('/sign-in');
}

const user = await prisma.user.findUnique({
  where: { clerkId: userId },
});

if (!user) {
  redirect('/sign-in');
}

Redirecting an authenticated user to /sign-in seemed reasonable at first. However, the applicationโ€™s middleware correctly prevented signed-in users from viewing the sign-in page:

/dashboard
    โ†“ Prisma user missing
/sign-in
    โ†“ Clerk session exists
/dashboard

The browser repeated that cycle until it reported too many redirects.

Why the account could not recover

The missing database row affected more than dashboard access.

Account deletion and Settings both relied on a helper that required the Prisma user:

if (!user) {
  throw new Error('User not found');
}

Consequently, the user could not delete their Clerk account from the application. Their email remained claimed by Clerk, so creating a replacement account with the same address was impossible.

Production also relied on Clerk webhooks to create users. If the original webhook failedโ€”or someone manually deleted a database rowโ€”the application had no sign-in recovery path.

Treating the database row as recoverable state

Clerk remained the authentication source of truth, so the missing Prisma record could be reconstructed from the authenticated Clerk profile.

We introduced a lookup-first helper:

export async function getOrCreateUserFromClerk(clerkId: string) {
  const user = await prisma.user.findUnique({
    where: { clerkId },
  });

  if (user) {
    return user;
  }

  const clerkUser = await currentUser();

  if (!clerkUser) {
    throw new Error('Could not fetch user from Clerk');
  }

  const primaryEmail = clerkUser.emailAddresses.find(
    (email) => email.id === clerkUser.primaryEmailAddressId
  );

  return syncClerkUserToDatabase({
    clerkId,
    email: primaryEmail?.emailAddress ?? '',
    firstName: clerkUser.firstName ?? '',
    lastName: clerkUser.lastName ?? '',
    imageUrl: clerkUser.imageUrl,
  });
}

The existing synchronization service already handled the difficult parts:

  • Reconciling users by Clerk ID or email
  • Creating the Prisma user
  • Creating a default budget
  • Handling concurrent synchronization attempts
  • Recovering from unique-constraint races

Dashboard routes and Settings now call this helper instead of redirecting when the database row is missing.

Existing users still require only a database lookup. Clerk is contacted only when recovery is necessary.

Testing the real failure

Unit tests covered three important paths:

  1. An existing Prisma user is returned without contacting Clerk.
  2. A missing user is recreated from the primary Clerk email.
  3. Failure to retrieve the Clerk user throws locally instead of starting another redirect.

We also reproduced the original problem manually:

  1. Created a test account.
  2. Recorded its Clerk ID and Prisma user ID.
  3. Deleted only the Prisma user from a development PostgreSQL database.
  4. Kept the Clerk session authenticated.
  5. Opened /dashboard.

Instead of entering a redirect loop, the application recreated the Prisma user.

An important recovery detail

Deleting the Prisma user also cascade-deleted related application data. The synchronization service could restore the user and default budget, but not the deleted profile, transactions, envelopes, or onboarding state.

Because the reconstructed user had onboardingCompleted = false, the dashboard layout sent the account to /onboarding.

That was a useful distinction:

  • The authentication failure was repaired.
  • The redirect loop was eliminated.
  • The account became recoverable.
  • Irretrievably deleted application data was not silently fabricated.

Automatically marking the user as onboarded would bypass the setup flow but leave them with an empty, potentially confusing dashboard. Sending the reconstructed account through onboarding provides a safer recovery path.

Update โ€” September 10, 2026: Review caught an ordering detail: the dashboard layout must recover the user before checking onboarding status. Recovering only inside a page could let the first request show an incomplete dashboard. The merged fix performs recovery in both places, with a regression test covering webhook mode.

We also shared the primary-email selection and synchronization logic between sign-up and recovery, while preserving sign-upโ€™s ability to update existing users. Missing-email validation, a friendly error screen, and cleanup of the remaining webhook-mode recovery guards are tracked separately.

The broader lesson

An authenticated identity and an application user record are different things.

When an external identity provider is authoritative, a missing local mirror should not automatically mean โ€œunauthenticated.โ€ Redirecting to sign-in can create loops because the identity provider still knows the user is signed in.

A safer boundary is:

No authentication session
    โ†’ sign in

Authentication session + local user
    โ†’ continue

Authentication session + missing local user
    โ†’ reconcile or show a terminal recovery screen

Distributed identity data will eventually drift. Designing an explicit reconciliation path turns that drift from an account-bricking failure into a recoverable state.


Leave a Reply

Your email address will not be published. Required fields are marked *