ResourcesGuidesSaaS Analytics GuideConceptsIdentifying users

Concepts

Identifying users

How Amplitude turns clicks and logins into one user, and where that identity quietly breaks.

Stipe LelasStipe Lelas
May 20, 202612 minRaw .md

Most Amplitude implementations have a broken identity graph, and the team finds out months later. The symptoms arrive disguised as other problems. User counts run higher than the database. Funnels lose people between two steps that share a session. Retention looks worse than the product deserves. By the time someone traces it back to identity, the bad data is already history, and history is the part you can't fully repair. Getting identification right is cheap at the start and expensive once the data is already wrong.

A user is not a person

A user, in any analytics tool, is not a person. It's whatever the tool can stitch together from the signals it receives. Some of those signals are anonymous, like the cookie in a browser. Some are explicit, like the user ID your code sends after login. Amplitude has its own rules for combining them, and the rules don't always match how your product team pictures a user.

The gap between those two definitions is where the trouble starts. Your team thinks "one person, one account." Amplitude thinks in terms of the IDs it has actually seen and how they connect. The rest of this chapter is about the IDs Amplitude uses, the decisions you make that feed them, and the handful of places the connection breaks.

The three IDs Amplitude keeps track of

Every event Amplitude receives carries identifiers. Three of them decide who the event belongs to.

IDWho sets itWhenWhat it's for
device_idThe SDK, automaticallyFirst load in a browserAn anonymous identifier, persisted in client-side storage
user_idYou, in codeAfter the user authenticatesYour stable identifier for the actual person
Amplitude IDAmplitude, internallyContinuouslyThe canonical ID that ties device IDs and user IDs together and drives every count

The device_id is created the first time the SDK runs in a browser, before anyone logs in or even signs up. It rides along on every event and survives as long as the storage holding it survives.

The user_id is the value you set once you know who the person is. You control it, and the value you choose is the single most important decision in this chapter. The next section is about that choice.

The Amplitude ID is the one most teams never look at, and it's the one that actually matters. It's an internal canonical ID that Amplitude assigns and uses to count users. Two device IDs and a user ID can all resolve to one Amplitude ID. When a report says "4,000 users," it's counting Amplitude IDs.

Amplitude connects these with a small set of merge rules:

  1. A fresh device_id with no user ID gets its own Amplitude ID and starts collecting anonymous events.
  2. The first time that device_id sends a user_id, Amplitude links them. The anonymous events merge into that user's Amplitude ID. This one-time merge is what attaches a pre-signup browsing session to the account it became.
  3. The same user_id seen on two different devices resolves both to one Amplitude ID. That's how cross-device tracking works with no extra configuration. Keep the user_id consistent and Amplitude does the joining.
  4. Amplitude will not retroactively reassign a device's earlier anonymous events to a second person. That protects you on shared devices, and it's why clearing identity on logout matters.

Follow one user through the model

Walk one real user through it. She lands on your marketing site from an ad. The SDK generates device_id a1b2 and logs page views against a fresh Amplitude ID. She reads two posts, leaves, comes back the next day on the same laptop with the same a1b2, and signs up. Your code calls setUserId("usr_8842"). Amplitude links a1b2 to usr_8842, and yesterday's anonymous page views now belong to her account.

A week later she logs in on her phone. New device_id c3d4, same usr_8842. Amplitude folds the phone into the same Amplitude ID. In every report she's one user with one continuous history, across two devices and one anonymous-to-known transition. That outcome depends entirely on the SDK firing during the anonymous session and on usr_8842 being the same value every time. Miss either and she splits into two or three users.

Choosing what to use as the user ID

Use your internal database ID, the primary key your own system assigns when the account is created. It exists before Auth0, Stripe, or anyone else touches the user, it never changes, and it's the same key your engineers already use to find a row. The alternatives all carry a cost.

CandidateThe problem
EmailChanges when the user changes it, and it's PII, so it leaks into every export and every downstream tool
Auth provider ID (Auth0, Clerk, Firebase)Stable while you stay, but switching providers rewrites every ID and splits every user
Random hashPrivacy-safe, but unreadable, so every support ticket and debugging session gets slower
Internal database IDStable, immutable, and yours. The default answer

Email is the tempting one because it's human-readable and you already have it. Resist it. People change their email, and the day they do, Amplitude sees a new user.

Whatever you pick, send the same value everywhere the user is identified, in the browser, on the server, and in the mobile app. The user_id is the join key for the whole identity graph. One surface sending a different value is one user split in two.

When to set the user ID

Set the user_id as early as possible on every authenticated session, not only at the moment of login.

The common mistake is to set it inside the login handler and nowhere else. That handler only runs when someone actively logs in. A user who returns with a valid session cookie never triggers it, so their events fire with a device_id and no user_id until they happen to log out and back in. You lose the connection for the whole session.

The fix is to read the session on every app load and set the user before any event fires.

// On every app load, before tracking anything
const session = await getSession();
if (session) {
    amplitude.setUserId(session.userId);
}

The moments where the user ID needs to be set are signup, login, every returning authenticated session, and right after a profile change that affects user properties. Support impersonation is the one to handle with care.

Clearing identity on logout

If you never clear identity, the next person to use that browser inherits the previous user's device_id and user_id. Their first anonymous actions attach to the wrong account.

amplitude.reset() clears the user_id and issues a new device_id. Call it on logout, and the next user starts as a clean anonymous visitor. Order matters: fire any final event while the user is still known, then reset.

async function logout() {
    amplitude.track("Signed out"); // fire while the user is still known
    await endSession();
    amplitude.reset(); // clears user_id, issues a new device_id
}

How much this matters depends on who shares the device. Consumer apps run on phones and laptops that pass between people, on kiosks, and on family computers, so they need reset done right. B2B SaaS mostly runs on one person's work machine, where two users rarely share a browser, so the stakes are lower. It costs nothing to do correctly either way.

Stitching anonymous sessions to known users

This is the part that breaks most often, and it's usually the most interesting data you have.

A user lands on the marketing site, browses, signs up, and becomes known. The events from before signup should attach to the same record as the events after. Amplitude does that automatically, but only when the SDK was loaded and firing during the anonymous session. The one-time merge has nothing to attach if a1b2 was never collecting events in the first place.

Teams that load Amplitude only inside the authenticated app throw away the entire pre-signup journey. That journey, which ad brought the user in, what they read, how long they took to convert, is exactly what growth analysis needs. Load the SDK on the public pages, not just behind the login wall.

The common reason this fails is structural. The marketing site and the app are separate codebases or separate deploys, and the SDK only ever made it into one of them. That's also a cross-domain problem, which the next section covers.

Crossing domains and subdomains

A device_id lives in storage scoped to a single domain. It does not automatically follow the user from one domain to another.

Subdomains can share it. If the marketing site is on yourbrand.com and the app is on app.yourbrand.com, set the cookie domain in the SDK config so both read the same device_id.

amplitude.init(API_KEY, undefined, {
    cookieOptions: { domain: ".yourbrand.com" },
});

Entirely separate domains can't share client-side storage at all. If marketing is on yourbrand.com and the app is on yourbrand.io, no cookie setting bridges them. The only thing that connects the two is a login event that ties both device IDs to the same user_id. The anonymous activity on each domain stays separate until that login happens.

Decide whether to engineer around it based on what you'd lose. If the two domains only meet after login anyway, login already bridges them through the user ID, and the small anonymous gap on the second domain is fine to accept. Build a bridge only when a pre-login journey across the two domains is something you actually need to measure.

User properties vs. event properties

Two calls get conflated in code review, and the difference decides what analysis you can run later.

setUserId attaches the user ID. An Identify call sets user properties on that user. A user property describes the person or account and tends to change slowly. An event property describes one specific action.

// User property: describes the person, latest value wins
const identify = new amplitude.Identify();
identify.set("plan", "pro");
identify.set("company_size", "50-200");
identify.setOnce("initial_signup_date", "2026-05-27");
amplitude.identify(identify);
 
// Event property: describes this one action
amplitude.track("Plan upgraded", { from_plan: "free", to_plan: "pro" });

Plan tier, signup date, company size, and role belong on the user. They answer "what is true about this person right now." The button clicked and the amount charged belong on the event. They answer "what happened in this moment."

The trap is putting event-level data into user properties. A user property is overwritten every time you set it, so only the latest value survives. Store a transaction amount as a user property and you can only ever see the last purchase. A cohort like "users on the pro plan" works because plan is a user property. A cohort like "users who made a purchase over $500" works because amount is an event property. Swap them and both analyses break. Use setOnce for values that should never change after they're first recorded, like the original signup date.

Identifying on the server

Most teams start fully client-side, which is fine until accuracy starts to matter. Server-side identification survives ad blockers and JavaScript failures, so the identity events that count, like signup and subscription changes, arrive reliably.

The catch specific to identity is that a server-side event has to carry the user_id, the device_id, and the session_id to land on the same Amplitude ID and the same browsing session. Send only the user_id and the event attaches to the right person but floats free of their session. Send a different user_id than the browser uses and you create a duplicate user. The general trade-off between the two sources is in Client-side vs. server-side tracking, and the mechanics of passing IDs through are in Authentication tracking.

Auditing your setup

You don't need to understand every possible bug. A short symptom list covers most of what goes wrong, and two checks tell you whether your own setup is sound.

Symptoms of a broken graph

SymptomLikely cause
User count far above your databaseAnonymous users aren't merging into known users at login, usually because the SDK loads only after authentication, so the anonymous device_id never receives a user_id
A funnel drops between two steps in one sessionThe user_id is being set mid-funnel, splitting one person into two records partway through
Retention looks impossibly badThe device_id resets on every visit, from blocked storage or a reset() on load, so every return reads as a brand-new user
One person appears as several usersThe user_id isn't consistent across surfaces, or the server sends a different value than the browser

Each row points at one place in the code, which is usually faster than reading dashboards in circles.

Two checks that catch most problems

First, trace five known users. Pick five real accounts, look each one up in Amplitude with the User Lookup view, and confirm the pre-signup events sit on the same record as the post-signup events with a continuous timeline. If the early activity is missing or lives on a separate user, your anonymous-to-known stitch is broken.

Second, compare totals. Put the total Amplitude user count next to your source-of-truth count from the database. A small gap is normal and expected from test accounts, deleted users, and ad blockers. A large gap means something upstream is wrong, and the symptom table tells you where to look first.

When identity is already broken

Most readers are here rather than building fresh, so it's worth being clear about what a fix can and can't recover.

Future events are fully recoverable. Deploy a correct implementation and everything from that point forward is clean.

Historical events are only partly recoverable. Amplitude has tooling to remap device IDs to user IDs and merge identities after the fact, and bulk reprocessing can repair some of the damage. But lossy data stays lossy. Some users will remain split forever because the signal needed to join them was never recorded in the first place.

The honest fix is often to accept a clean break. Deploy the correct setup, mark the date, and measure against that line going forward instead of trying to perfectly reconstruct a past that was never captured. A trustworthy dataset that starts today beats a patched one nobody believes.