DEV Community

Cover image for The Real Story of Account Linking — How I Learned Auth Is a Flow, Not a Login
Nimesh Thakur
Nimesh Thakur

Posted on

The Real Story of Account Linking — How I Learned Auth Is a Flow, Not a Login

Auth is not a login. Auth is a flow. The moment you understand this, everything clicks.


01 — It Started Simple

When I first built authentication, I only had one provider: password login. User signs up with email and password. Done. One table. One row. Life was easy.

Then the product needed Google login. Then GitHub. Then SMS OTP. Then Apple. Suddenly I had five ways for someone to prove they are who they say they are — and I had no idea how to connect them all to the same person.

My first instinct was naive — and almost every developer does this. When someone logs in via Google, I would just look at the email Google gave me. If that email already existed in my users table, return that user. If not, create a new one. No linking. No resolution. Just a simple email lookup.

I thought this was enough. It wasn't.


02 — The Crack in the Wall

I started reading docs — Clerk's account linking guide, Auth0's user account linking strategy. And slowly I realized: my simple email-lookup approach was a security hole.

⚠️ The Attack

An attacker signs up with your email address using a password — but never verifies it. Now your email exists in the system, unverified, with a password hash attached. Later, you try to log in with Google (which has verified your email). If the system just sees "email exists, return user" — the attacker's unverified account is now yours. Account takeover. No notification. No trace.

This is why you can't just match on email. You need to understand who owns what, what is verified, and what needs explicit permission to link. That's when I started redesigning everything.


03 — The New Architecture: Five Tables, Five Responsibilities

Before, I had one big table with email, password, everything crammed together. After reading and thinking, I split it into five clean boundaries. Each table owns exactly one job.

Table Owns Why It's Separate
users Who is this person? Single source of truth for identity
credentials What's their password? Secrets need their own boundary
accounts What external IDs map to them? Links Google/GitHub/OTP → one user
sessions Are they logged in now? Active session state
refresh_tokens Can they stay logged in? Long-lived auth with rotation

The Full Table Map

Table Key Columns Role
users id, email, email_verified Source of Truth
credentials user_id, password_hash Secret Boundary
accounts provider, provider_user_id, user_id Linking Backbone
sessions session_token, expires_at, revoked Session Boundary
refresh_tokens token_hash, rotation_count Rotation Boundary

💡 Key Insight: Password login does not create an accounts row. Only external providers (Google, GitHub, OTP) do. The credentials table is the password's world. The accounts table is the external world. They never touch each other directly.


04 — Auth Is a Flow, Not a Single Function

This was my biggest mental shift. I used to think of auth as one function: "verify credentials, return user." But when you have multiple providers, auth becomes a state machine. It has steps. It can pause. It can ask for more information. It can fail at different points for different reasons.

I introduced the concept of an AuthFlow — a temporary state that lives between "the user clicked login" and "the session is created."

The Auth Flow — Step by Step

1️⃣  User clicks "Login with Google"
    └─ AuthFlow created with a unique flowId

2️⃣  Provider authenticates
    └─ Google verifies user → returns identity (sub, email, verified?)

3️⃣  resolveAccount() runs
    └─ The brain. Decides: return user? Create? Or pause?

4a ❌ LINK_REQUIRED — Flow pauses
    └─ Email exists with verified password
    └─ Flow saves state, throws error with flowId
    └─ Frontend shows: "Log in with your password first"

4b ✅ OK — Flow completes
    └─ User resolved. AuthFlow deleted. Session created.

5️⃣  linkAccount() — If paused at 4a
    └─ User proves password ownership
    └─ OAuth identity linked. Flow deleted. Session created.
Enter fullscreen mode Exit fullscreen mode

The Code: authenticate()

const authenticate = async (providerName, payload, opts) => {
  // Step 1: Get the provider (Google, GitHub, password, etc.)
  const provider = getProvider(providerName);

  // Step 2: Provider gives us an identity
  const identity = await provider.authenticate(payload);

  // Step 3: Resolve — find or create user
  const { type, user } = await opts.Account.resolveAccount(identity);

  // Step 4a: If linking is needed, pause the flow
  if (type === "LINK_REQUIRED") {
    await updateAuthFlow(opts.flowId, {
      status:  "LINK_REQUIRED",
      intent:  "ACCOUNT_LINK",
      identity
    });
    throw Error("Email already linked. Authenticate with password first.");
  }

  // Step 4b: Flow complete — clean up and create session
  await deleteAuthFlow(opts.flowId);
  return await opts.Session.createSession(user, opts);
};
Enter fullscreen mode Exit fullscreen mode

05 — resolveAccount — The Brain of It All

This is where the real logic lives. Every login attempt from an external provider goes through here. It's a decision tree, and every branch matters.

The Decision Tree

Is the provider "password"?
├── Yes → Find user by ID. Return OK ✅
└── No ↓

Does provider + provider_user_id exist in accounts table?
├── Yes → Return that user. OK ✅
└── No ↓

Did the provider give us a VERIFIED email?
├── No  → Create new user with just provider_user_id. OK ✅
└── Yes ↓

Does that email already exist in users table?
├── No  → Create new user + account row. OK ✅
└── Yes ↓

⚡ Does this existing user have a password credential?
├── Yes + email IS verified     → LINK_REQUIRED ❌ (pause flow)
├── Yes + email is NOT verified → DELETE that credential. Ghost account.
│                                  Link and continue. OK ✅
└── No password at all          → Safe to link directly. OK ✅
Enter fullscreen mode Exit fullscreen mode

Why delete the unverified credential? If someone registered with your email but never verified it, that account could belong to an attacker. When Google (or any trusted provider) confirms you own that email, the unverified password account is a ghost. Keeping it means the attacker could still try to recover it. So we remove it.

The Code: resolveAccount()

const resolveAccount = async (identity: AuthResult) => {

  // ── Password provider is simple: just find the user ──
  if (identity.provider === "password") {
    const user = await opts.User.findById(identity.user_id);
    return { type: "OK", user };
  }

  // ── Already seen this provider before? Return that user ──
  const existing = await opts.Account
    .getByProviderAndId(identity.provider, identity.provider_user_id);

  if (existing) {
    const user = await opts.User.findById(existing.user_id);
    return { type: "OK", user };
  }

  // ── New provider identity. Check if email is verified ──
  const email    = identity.provider_email;
  const verified = identity.metadata?.provider_email_verified;

  if (email && verified) {
    const existingUser = await opts.User.findByEmail(email);

    if (existingUser) {
      const hasCred = await opts.Cred.getCred(existingUser.id);

      if (hasCred) {
        // ⚡ The critical split
        if (existingUser.email_verified)
          return { type: "LINK_REQUIRED" }; // Must prove password ownership

        // Unverified + has password = ghost account. Delete it.
        await opts.Cred.delCred(hasCred.id);
        await opts.User.setEmailVerified(existingUser.id);
      }

      // Safe to link — no password conflict
      await opts.Account.insertAccount({ ...identity, user_id: existingUser.id });
      return { type: "OK", user: existingUser };
    }
  }

  // ── Nothing matched. Create a brand new user ──
  const newUser = await opts.User.create({
    email: email,
    email_verified: !!verified,
    full_name: identity.metadata.name
  });
  await opts.Account.insertAccount({ ...identity, user_id: newUser.id });
  return { type: "OK", user: newUser };
};
Enter fullscreen mode Exit fullscreen mode

06 — linkAccount — Completing the Paused Flow

When resolveAccount returns LINK_REQUIRED, the flow pauses. The frontend shows a password prompt. The user proves they own the existing account. Then linkAccount() picks up where we left off.

const linkAccount = async (flowId, password, opts) => {
  // Retrieve the paused flow
  const flow = await getAuthFlow(flowId);
  if (!flow || flow.status !== "LINK_REQUIRED")
    throw Error("Flow not found or not in LINK_REQUIRED state");

  // Prove password ownership
  const passwordProvider = getProvider("password");
  const primaryIdentity = await passwordProvider.authenticate({
    email: flow.identity.provider_email,
    password
  });

  // Link the OAuth identity to the password user
  const user = await opts.Account.linkAccount(
    primaryIdentity,   // The password user (proven owner)
    flow.identity      // The OAuth identity waiting to link
  );

  // Clean up and create session
  await deleteAuthFlow(flowId);
  return await opts.Session.createSession(user, opts);
};
Enter fullscreen mode Exit fullscreen mode

07 — The Full Picture: Who Does What

Function Job Returns
authenticate() Entry point. Orchestrates the entire flow. Session ✅ or Pause ❌
resolveAccount() Decides what to do with an identity. OK or LINK_REQUIRED
linkAccount() Resumes a paused flow after password proof. Session ✅

08 — What I Took Away

The hardest part wasn't the code. It was understanding why each decision exists.

  • Why can't I just match on email? Because emails are not proof of ownership — verification is.
  • Why do I need a separate credentials table? Because secrets need their own boundary.
  • Why does the flow need to pause? Because sometimes auth requires two steps, not one.

The Three Rules

1. Never trust an unverified email as identity. An attacker can register it first.

2. Separate secrets from identity. Passwords, tokens, and hashes have their own table. Users are just users.

3. Auth is a flow, not a function. It can pause, branch, and resume. Design for that.

Once I saw auth this way — as a state machine with clean boundaries — everything became logical. Each table has a single job. Each function has a single responsibility. The complexity didn't disappear. It just became visible, and visible things are things you can reason about.


If you've been doing the "just check email" approach — it's okay. Now you know. The architecture above is what I moved to, and it handles every edge case I've thrown at it.

Top comments (0)