In this article
August 6, 2026
August 6, 2026

Stop Using Email as a Primary Key—Before It Bites You

Email addresses get reassigned and recycled. If you key identity or link accounts on email, you built an account-takeover path yourself. Here's the fix.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Email feels like a natural primary key. It's unique, and every identity provider hands it to you right there in the token. So it ends up in a UNIQUE constraint on your users table, and it becomes the thing you match on when someone signs in through a new provider. That decision is quietly one of the most dangerous shortcuts in application auth.

Email is a mutable profile attribute that happens to look like an identifier. Treat it as a primary key and you inherit every way an address can change hands. Some of those ways hand an attacker a working session on an account that isn't theirs.

An email address is a lease, not a name

The core problem is that nobody owns an email address permanently. They rent it.

Companies reassign email addresses after employees leave. The jordan@company.com who filed expense reports last year is a different human than the jordan@company.com reading them this year. Same string, different person. Consumer providers do the same thing: dormant addresses get recycled and handed to new account holders. The address is a lease. The tenant changes.

If your users table treats email as the identity, then whoever holds the lease today inherits everything the previous tenant did. The row doesn't know a handoff happened. It just sees a matching string and serves up the account.

IMAGE: A single rounded rectangle in cool blue at the center representing an account record, with three human-figure circles in warm amber connected to it one after another over time along a horizontal arrow; only one figure connected at a time, the previous ones fading; flat, minimal, generous whitespace

OIDC already gives you the right key

The identity providers you integrate with already solved this, and the answer is sitting in the token you're throwing away.

In OpenID Connect, the sub (subject) claim is the stable, immutable identifier for a user, and email is a mutable profile attribute. Per the OpenID Connect Core spec, the subject identifier scoped to the issuer is the value a relying party can rely on. Email is one more field in the profile payload, no more load-bearing than the user's display name or avatar URL.

So the key you want is the pair (iss, sub): the issuer that vouched for the user, plus the subject identifier that issuer assigned. That pair is stable across email changes, name changes, and everything else in the profile. Store it. Key on it.

// Don't: email as the identity
const user = await db.user.findUnique({
  where: { email: claims.email },
});

// Do: the issuer + subject pair is the identity
const identity = await db.identity.findUnique({
  where: {
    provider_subject: {
      issuer: claims.iss,
      subject: claims.sub,
    },
  },
  include: { user: true },
});

Your schema follows from that. A users table for the human, and an identities table for each provider login that resolves to a human, keyed on issuer and subject rather than email.

CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE identities (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id),
  issuer      TEXT NOT NULL,   -- the OIDC `iss`
  subject     TEXT NOT NULL,   -- the OIDC `sub`
  email       TEXT,            -- profile data, not identity
  UNIQUE (issuer, subject)
);

Email lives on the identity row as profile data you can display and update. It is never the thing you look up on.

The takeover you build yourself

Here's where the shortcut turns into a vulnerability. The convenience feature that always sneaks in is auto-linking: a user signs in through a new provider, you see an email that matches an existing account, so you connect them to that account automatically. One identity, many logins, no friction. It also happens to be an account-takeover primitive.

Auto-linking accounts by matching email lets an attacker with an unverified email claim on one provider take over an account created through another provider. Walk the steps:

  1. A victim has an account with you, created by signing in through Provider A using victim@example.com.
  2. An attacker signs up at Provider B, a provider that doesn't verify email ownership, and sets their profile email to victim@example.com.
  3. The attacker signs in to your app through Provider B. Your app sees a matching email and auto-links the new login to the victim's existing account.
  4. The attacker is now inside the victim's account, having proven ownership of nothing.

The victim did nothing wrong. Your app did the reaching-across for them, on the strength of a string that Provider B never checked.

IMAGE: Two provider nodes as blue circles on the left, an app service as a blue rounded rectangle in the center, and a single account record as a rounded rectangle on the right; from the lower provider a warm amber arrow carries a small matching-token shape into the app and onward to the account, bypassing a verification checkpoint drawn as a broken gate; flat, minimal, high contrast, generous whitespace

This failure has a name. NoAuth vulnerabilities happen when an application trusts user attributes like email addresses without ensuring the identity provider thoroughly verified them. An email claim is self-asserted until someone checks it, and not every provider checks it.

Safe linking, if you link at all

The fix has two acceptable settings, and no third.

The first: don't link on email at all. Every provider login is its own identity, resolving to its own user. If a person wants to connect a second provider, they do it while authenticated: signed in, in your settings UI, with an explicit "connect account" action. Then you attach the new (iss, sub) to their existing user. Identity comes from the authenticated session, never from a matching string.

The second, if you must link during sign-in: require a verified email plus explicit user confirmation, or don't do email-based linking at all. Both conditions, together. The provider must assert email_verified: true, and the user must confirm the link through a channel you control.

async function resolveUser(claims: OidcClaims) {
  // 1. The identity is always (iss, sub).
  const existing = await findIdentity(claims.iss, claims.sub);
  if (existing) return existing.user;

  // 2. New login. Only consider linking on a VERIFIED email...
  if (claims.email && claims.email_verified === true) {
    const candidate = await findUserByVerifiedEmail(claims.email);
    if (candidate) {
      // ...and never link silently. Make the user prove intent.
      return requireExplicitLinkConfirmation(candidate, claims);
    }
  }

  // 3. Otherwise it's a brand-new user. No matching, no reaching across.
  return createUserWithIdentity(claims);
}

The unverified branch simply doesn't exist. An email_verified that is absent or false never links to anything. It creates a new user or gets rejected, full stop. That single guard closes the takeover.

IMAGE: A decision flow reading left to right: an incoming login token as a small shape enters a diamond checkpoint; one branch in cool blue leads to an existing account record only after passing a second confirmation gate; another branch in neutral gray loops to a freshly created record; a warm amber branch representing an unverified claim terminates at a solid stop block; flat, minimal, generous whitespace

"Verified" is also just a claim

There's a weaker link in that code than it looks. email_verified: true is itself an assertion from the provider, so taking it at face value means trusting that the provider checked carefully and checked recently. WorkOS's guidance is to not assume a token's email is verified just because the provider sent it, and to run your own verification.

The recency problem is the real one. GitHub lets users add external email addresses without revalidating them over time, so someone who left a job but kept access to an old work address becomes a security gap. An email_verified stamped two years ago describes an address the user may no longer control. The lease turned over and the flag didn't.

The stronger bar is proving inbox access at the moment of linking, and that's the bar WorkOS identity linking is built on. It does dedupe credentials across providers using email as the unique identifier, but with access to the inbox as the source of truth rather than the claim. Email verification is required by default for authentication to succeed, and a new credential attaches to an existing user only when WorkOS can verify the user has access to the inbox that credential references. If it can't, the flow doesn't complete. The docs describe the same attack this post walks through:

"WorkOS considers it a security risk if the user cannot verify access to their email. Some identity providers allow creating accounts with any email address. For instance, an IT contact of an organization with the domain apple.com could make an account for billg@microsoft.com. If access to billg@microsoft.com is not verified, the IT contact could sign in to the application as that user."

Enterprise gets a shortcut that doesn't weaken the rule. A verified domain implies the ability to verify every user on that domain, so SSO users whose email matches a verified domain are treated as verified. Anyone signing in through SSO on a domain that isn't verified still goes through email verification. Same bar, cheaper proof. Lessons in safe identity linking goes deeper on the tradeoffs.

When the address does change hands

Keying on (iss, sub) fixes your lookups, but you still store email for notifications, support search, and admin views. Those go stale the moment the address changes upstream, and a stale contact address is its own quiet failure.

AuthKit now syncs email changes from the identity provider automatically: from an SSO profile the change lands on the user's next login, and from Directory Sync on the next directory update. With both connected, Directory Sync wins, and an SSO login won't overwrite a directory-sourced email. Enterprise-managed users can't edit their own email at all. The update user API rejects it, because their IdP is the authoritative source.

Two of the documented behaviors are worth copying no matter what stack you're on.

Collisions are refused, not resolved. If the incoming email already belongs to another user in the environment, the change is skipped; the user keeps its current email and other attribute updates still apply. That prevents accidental account merges when two users at the same IdP swap or share addresses. It's the recycled-address scenario from the top of this post, arriving as a routine profile update.

Verification expires with the address. When an email change takes effect, any linked OAuth identity whose email no longer matches is removed, because it was verified against the old email. Proof of inbox access is a fact about a moment in time, not a permanent property of an account.

One sharp edge to plan for: an email change on its own doesn't revoke active sessions. If your threat model needs the previous holder pushed out the instant the address moves, pair the email-change event with an explicit session-revocation call. Otherwise you've corrected the record and left the door open.

The one-line version

Email is how you contact a user. It is not who they are. The moment you make it the primary key or the linking key, you've tied identity to a value that gets reassigned by employers and recycled by providers. You've also handed anyone who can claim that string an unverified path into someone else's account. Key on (iss, sub), keep email as profile data, and never link two logins without proof of inbox access and a human saying yes.

A whimsical, minimal scene: a unicorn in simple clean shapes, light cool-toned body with a warm accent horn, standing beside a small bowl filled with faceted ruby-like gemstones. T