Identity resolution
Contents
TL;DR
- Identity resolution is your engineering problem – PostHog consumes what you give it. If your identity data is incoherent, every downstream feature inherits that incoherence.
- Think in layers – stable IDs, authentication IDs, and device IDs serve different purposes. Know which layer you're operating at.
- The golden path: assign a stable ID early and never change it – mint it in one place, pass it to every environment. This eliminates an entire class of problems across Feature Flags, Experiments, Session Replay, and Product Analytics.
- Pass the ID across transitions explicitly – web to mobile, marketing site to product, client to server. If two environments generate IDs independently, they will diverge.
- Link IDs explicitly – PostHog can merge what you tell it to merge. It can't infer that two IDs belong to the same person.
- Verify your implementation – check person profiles, not assumptions.
Identity resolution is your engineering problem
Identity resolution is the problem of coordinating a single, stable representation of a user across every environment your product runs in – browser, mobile, server, third-party integrations – and passing that coordinated data to every system that needs it. PostHog is one of those systems.
PostHog doesn't know your users. It knows entities tagged with a distinct_id and properties. It can merge what you tell it to merge. It can't resolve identity for you. If the data you send is incoherent – different IDs for the same person across SDKs, IDs that change mid-session, merges that happen too late – every downstream feature inherits that incoherence. Flags flip. Experiments double-count. Funnels misattribute. Session replays fragment. These aren't PostHog bugs. They're input problems.
The fix is designing your identity flow so that every system receives consistent, stable identity data from the moment a user first appears. PostHog's merge pipeline and SDKs do occasionally have real bugs – check status.posthog.com if something looks wrong. But incidents are straightforward to spot (they affect many customers at once). If you're the only one seeing the problem, look at your inputs.
This guide, PostHog AI, and support exist to help you work through this. But identity across multiple environments and transitions can grow complex. If you'd rather make it our problem, that's what professional services are for.
The identity layer model
Think of your user's identity as having three layers. Each serves a different purpose, and the problems happen when layers collide.
Most applications start at Layer 2 – the SDK generates an anonymous ID – and replace it with a Layer 1 ID on login. That replacement is where identity breaks happen. The anonymous ID produced one set of flag evaluations, analytics events, and session recordings. The authenticated ID produces another. Unless you explicitly link them, PostHog treats them as two separate people.
The further your implementation is from Layer 0, the more coordination work you take on. Layer 2 to Layer 1 transitions require identify() or alias() calls at exactly the right time. Layer 0 eliminates the transition entirely.
Choosing your identity strategy
There are three common approaches. The golden path is listed first because it eliminates the most problems. The others are valid when constraints prevent the golden path, but each adds coordination complexity.
Golden path: stable ID from first touch
Assign a durable ID the moment you first see a user. This ID never changes, even across authentication boundaries or devices. The key is to mint the ID in a stable place – ideally your server – and pass it to every environment that needs it.
Server as source of truth (recommended)
If you have a server, mint the ID there. It travels to every client via bootstrap, URL parameters, cookies, or your app's own session mechanism. When you move from mobile to web, the server already knows who you are and passes the same ID forward.
No anonymous-to-identified transition ever happens. The hash input is stable from the first millisecond on every device.
Client-side stable ID
If you don't have a server (static sites, SPAs without a backend), you can generate and persist the ID on the client:
Or adopt PostHog's anonymous ID as your canonical identifier:
Either way, when you log in, you attach authentication properties without changing the ID:
identify() here sets properties on the same identity – the distinct_id never changes. No merges needed. No timing dependencies. Flags, Experiments, and analytics all operate on a single stable identity from the start.
Passing the ID across environment transitions
The stable ID only works if it travels with you. How it travels depends on the transition:
- Web to mobile app – Pass the
distinct_idand$session_idas query parameters in download or deep links. When the app opens, parse the parameters and initialize PostHog with that ID. Including the$session_idmaintains session continuity so Session Replay and analytics don't fragment at the handoff. See linking browser and mobile identities for a full walkthrough with code examples. - Marketing site to product – If the marketing site and product share a domain (or you can share a cookie across subdomains), the stable ID and session persist automatically. If they're on different domains, pass both the
distinct_idand$session_idas URL parameters on the handoff link. - Server to client – Bootstrap the ID at render time (shown above). The client never generates its own ID.
- Client to server – Send the
distinct_idand$session_idin your API calls, session cookies, or request headers. Your server uses the same IDs when calling PostHog server-side. See passing session IDs to server-side events for implementation details. - Mobile to web – Same pattern as web to mobile in reverse. The app generates a link containing the
distinct_idand$session_id; the web app reads them on load. - Server-rendered pages to SPA – If your auth flow is server-rendered (Django templates, Rails views, Next.js pages) and your product is a separate SPA, the identity must survive the redirect. Pass the
distinct_idas a URL parameter or set it in a cookie that both applications can read. On the SPA side, read the parameter at init and bootstrap PostHog with it. For reliability, prefer a shared cookie or server-side session over URL parameters.
The principle is the same in every case: the distinct_id is minted once, stored in one authoritative place, and passed explicitly to every environment that needs it. Pass the $session_id alongside it when you need session continuity across the transition. If two environments are generating IDs independently, they will diverge.
What if you need identified events to start only after login? Two options: (1) defer flag evaluation until identity is resolved – if flag consistency across auth transitions matters to you, only evaluating after login is the cleanest solution, or (2) use the stable ID for flag evaluation from the start but hold off on sending identify() with person properties until login. The stable ID itself doesn't create an identified person profile – that happens when you attach properties.
When this doesn't work: Environments where you genuinely cannot persist or coordinate state – no server, no cookies, no local storage (some embedded webviews, strict compliance regimes that prohibit any user tracking before consent). If you're in this situation, the common path with flag-specific mitigations is the fallback.
Common path: anonymous then identify on login
This is what most applications do. The SDK generates an anonymous ID on first load. When you log in, you call identify() to link the anonymous session to a known identity.
When you call identify('user-456'):
- The SDK sends a
$identifyevent with both the anonymousdistinct_idand the new one - PostHog's person merge pipeline links both IDs to the same person
- Past events from the anonymous ID are attributed to the merged person
- Future events use the new
distinct_id
After a merge, events keep their original distinct_id – it's never rewritten. The merge updates the person_id mapping (and a background squash job updates person_id on event rows), but distinct_id stays as-is. PostHog queries through person_id, so insights, funnels, and timelines show the correct merged view automatically.
If you see an unfamiliar distinct_id in raw events, that's expected – it was captured before the merge. You don't need to worry about this in normal usage.
If you're filtering by a single distinct_id in SQL, you'll miss events from other IDs belonging to the same person. Check the person profile's Distinct IDs tab to see all of them, or use the person_distinct_ids table to map between distinct_id and person_id.
The critical constraint: identify() must run before any flag evaluations, conversion events, or other actions that need to be attributed to the right person. If it runs after, those events belong to the anonymous person until the merge pipeline processes them – and for flags, the hash already ran against the wrong ID. See resolve identity before evaluating flags for why this matters.
When this works: Standard login flows where you can guarantee identify() runs early enough.
When it creates problems: Auth flows where flag evaluations or critical events fire before identify() can run – splash screens with feature-gated content, server-side flag checks that happen before the client has identified.
Server-side linking with alias
alias() links two distinct IDs without changing the current session's identity. Use it when your server knows two IDs belong to the same person but the client hasn't identified yet.
After the alias, both IDs resolve to the same person. Events captured under either ID are connected.
When this works: Server-driven authentication flows, backend systems that create user records before the client knows about them.
Cross-environment consistency
Identity coordination means the same user resolves to the same person everywhere your product runs. This requires attention to three things:
Same distinct_id string everywhere. Browser, server, mobile – the exact same string. If your browser SDK uses "user-456" and your server SDK uses "USER-456", PostHog treats them as two different people.
Same person properties available at evaluation time. If you target a flag on plan_type: "pro", every SDK evaluating that flag needs access to that property. Server-side local evaluation requires you to pass properties explicitly. Client-side evaluation fetches them from PostHog, but only after the SDK initializes.
GeoIP awareness across environments. Server-side evaluation uses the server's IP for GeoIP properties, not yours. If you target by geography, set $geoip_disable: true on server-side calls or pass your user's IP explicitly. See property overrides for details.
One function for distinct_id in your backend. PostHog treats "user-456" and "456" as different people – there's no type coercion or fuzzy matching. Establish a single helper (e.g., user.posthog_id) that returns the canonical distinct_id string, and use it at every capture() call site. Mixing identifier types across call sites is the most common backend-specific identity problem.
Link IDs on every platform – the exact timing is secondary. The goal is for every platform to eventually call identify() or alias() so all distinct_id values end up linked to the same person. Once linked, PostHog aggregates events correctly across all of them – insights, funnels, and timelines all resolve through the merged person, regardless of which distinct_id each event was originally captured with. You don't need every platform to identify at the exact same moment. If your iOS app identifies during credential creation and your web app identifies after the SPA mounts, that's fine – the merge still happens. The only case where timing matters is Feature Flags, where the hash input needs to be stable before evaluation.
When identity goes wrong
The symptoms vary by feature, but the root cause is always the same – two distinct IDs that should be one person aren't linked.
| Feature | Symptom | What happened |
|---|---|---|
| Feature Flags | Different values before and after login | Hash input changed |
| Experiments | User in both control and test | Two unlinked persons, each assigned independently |
| Experiments | Exposure exists but conversion missing | Exposure on anonymous ID, conversion on identified ID |
| Session Replay | Recording breaks at login | distinct_id changed mid-session without linkage |
| Funnels | Conversion attributed to wrong user | Events split across unlinked persons |
| Error Tracking | Phantom users with one error each | Transient IDs creating a new person per error |
In every case, the fix is upstream: ensure the right identity strategy is in place and that IDs are linked before the events that need to be connected.
Catch-all distinct IDs
Some applications send server-side events using a shared distinct ID like "system", "backend", or "cron" for events that aren't tied to a specific user – background jobs, system health checks, automated workflows, and similar.
This is a problem when person processing is enabled (the default). PostHog creates a single person profile for that distinct ID, and every event funnels into it. As this profile accumulates thousands or millions of events, it causes:
- Rate limiting – PostHog applies per-distinct-ID rate limits to protect the ingestion pipeline. A catch-all ID will hit these limits, causing events to be dropped.
- Increased costs – identified events (with person processing) cost up to 4x more per event than anonymous events. Sending high-volume system events as identified is an unnecessary expense.
- Unusable data – a single person profile containing events from unrelated system processes has no analytical value.
To fix this, disable person processing for these events. Set $process_person_profile to false and PostHog skips the person lookup entirely. The event is ingested and stored without creating or updating a person profile.
If you do need these events tied to a real user (e.g., a background job running on behalf of a specific user), use that user's actual distinct ID instead of a shared one.
How to verify identity is correct
Pick any user in PostHog. Click into their person profile. You should see:
- One person, not multiple – search for their email. If you find two person records, they haven't been merged.
- All expected distinct IDs on the same person – check the Distinct IDs tab. Both anonymous and identified IDs should be listed.
- Events from all SDKs – browser events and server events should appear on the same person timeline.
- The
$identifyevent – should appear with both the anonymous and identified distinct IDs, confirming the merge happened.
If you see two separate persons for the same real user, trace backwards: which identify() or alias() call is missing or happening too late?
PostHog silently rejects certain distinct IDs during person merges. Blocked values include: null, undefined, None, 0, anonymous, guest, distinct_id, id, email, true, false, [object Object], NaN, empty strings, and quoted variants of all of these.
If your application generates IDs that could collide with these values (e.g., the string "null" as a user ID), person merges will fail silently. You'll end up with split identities and no error message. Use UUIDs or validate IDs against the blocked list before sending.
Further reading
- Production-ready Feature Flags – why identity matters for flags and Experiments (the pure function model)
- Keeping flag evaluations stable – flag-specific workarounds when you can't fully control the identity flow
- Identifying users – PostHog's
identify()API reference