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
accountsrow. Only external providers (Google, GitHub, OTP) do. Thecredentialstable is the password's world. Theaccountstable 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.
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);
};
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 ✅
⚡ 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 };
};
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);
};
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)