Anonymous to Known: Stitching Pre-Auth Browsing Sessions with PostHog and Auth0
When a visitor browses your site and then signs up, you should be able to answer a straightforward question: what did they look at before they converted? In most customer identity implementations, you can't. The anonymous browsing session belongs to an analytics tracking ID that has no connection to the Auth0 user that gets created at the end of the flow. The conversion happened; the journey that led to it is invisible.
This is a user journey I initially overlooked when I put together a broader sessions and token management demo covering traditional web app sessions, SPA token patterns, BFF architectures, and refresh token flows. Anonymous-to-known sits outside the standard session lifecycle - it involves the analytics layer rather than the identity system, and it deserves its own treatment. Auth0 has native anonymous sessions on the roadmap for later in 2026, which will let the identity system carry pre-auth context through the login flow automatically. Until that ships, this is how you bridge the gap.
Auth0 handles the authentication side of this well - it creates a verified user identity, issues a trustworthy sub, and manages the session across your applications. What it does not do, and has no mechanism to do, is carry forward any awareness of how that user behaved before they authenticated. That gap sits entirely in the analytics layer.
In this post I will detail how to capture an anonymous PostHog distinct_id before the Auth0 login redirect, merge it into the authenticated user after the callback, and query the stitched event timeline back from the PostHog Events API to display it in your own application. I have built this as a live demo within a larger Auth0 session and token management demo to make the mechanics concrete.
The steps we will cover are as follows.
- The attribution gap and why it matters for CIAM
- Why GA4, Segment, and Amplitude can't power an in-app reporting view
- How PostHog's identity model bridges the gap
- The implementation: capturing the ID, surviving the cross-origin redirect, and resolving identity post-callback
- Querying and displaying the merged event timeline
The Attribution Gap in CIAM
Every analytics platform starts the same way: a new visitor arrives, and the platform mints an anonymous identifier for them. In PostHog this is the distinct_id, auto-generated on the first page load. In Google Analytics 4 it is the client_id, stored in the _ga cookie. In Segment it is anonymousId, stored in localStorage.
Events fired before authentication - product views, pricing page visits, cart additions - are all recorded against this anonymous ID. When the user authenticates through Auth0's Universal Login and the callback returns to your application, you have an access_token, an id_token, and a sub that uniquely identifies the user. What you do not have is any automatic connection between the new sub and the anonymous ID that accumulated the pre-auth events.
For CIAM in particular this is a meaningful gap. The pre-signup browsing session is often the richest signal you have: which features a trial user evaluated before paying, which pricing tier a prospect hovered on, which documentation page a new user read before they first logged in. Without the stitch, your analytics sees a conversion event arrive with no preceding context.
Why Not GA4 or Segment
The identity stitch itself is straightforward in most platforms - they all have some form of it. The harder problem is building a reporting view inside your application that proves it worked in real time.
Google Analytics 4 has a User ID feature: once you set user_id on gtag calls, GA4 associates that user's events across devices. For the server-side portion, the Measurement Protocol lets you retroactively link an anonymous client_id to a known user_id. The problem is querying it back. GA4's Data API processes data with a 24-48 hour delay. The Realtime API shows the last 30 minutes, but only aggregate counts - there is no endpoint that returns per-user event history in real time.
Segment has first-class analytics.identify() support and routes that call to every downstream destination simultaneously, which makes it the right choice in production when you want the stitch everywhere at once. It does not have a queryable events API. Segment's data goes into warehouse destinations (Snowflake, BigQuery) or downstream tools - there is no endpoint you can call from your Next.js API route to retrieve the event history for a specific user.
Amplitude has a User Lookup API but full event history access requires an Enterprise plan. Mixpanel has a JQL API that can query per-user events, but the setup complexity and pricing rule it out for a demo.
PostHog is the only free-tier option that provides both first-class identity stitching and a real-time queryable Events API. The free tier includes 1 million events per month, the Events API and Persons API are available on all plans, and the response is immediate - there is no processing delay.
PostHog's Identity Model
PostHog builds a person record for every distinct_id it sees. When you call posthog.identify(userId, properties), PostHog fires a $identify event that merges the anonymous person into the identified person. After the merge, both distinct IDs belong to the same person record - any query by either ID returns the same unified event history.
The anonymous person is not deleted; it becomes one of potentially many distinct_ids on the merged person. This is what makes cross-device attribution possible: if the same user authenticates on a desktop with one anonymous ID and then on mobile with another, each identify() call adds another anonymous ID to the same person record, and the full history across all of them is queryable by the authenticated sub.
The server-side equivalent is the $identify event sent directly to the Capture API:
{
"api_key": "phc_xxx",
"distinct_id": "auth0|xyz789",
"event": "$identify",
"properties": {
"$anon_distinct_id": "ph_abc123"
}
}This is exactly what posthog.identify() does client-side. Running both - the client-side call for future events and the server-side call for the audit record - is safe; PostHog deduplicates.
The Implementation
The main challenge is that the Auth0 login redirect is cross-origin: the browser leaves your domain to visit your Auth0 tenant, and returns via callback. sessionStorage does not survive this; localStorage does.

Before the redirect, capture and park the PostHog distinct_id:
const distinctId = posthog.get_distinct_id();
localStorage.setItem('ph_pending_distinct_id', distinctId);
localStorage.setItem('ph_pending_scenario', 'signup'); // or 'login'
window.location.href = `/auth/login?returnTo=${encodeURIComponent('/demo/anon-to-known?phase=resolved')}`;The returnTo parameter on Auth0's login route carries the user back to your page with a ?phase=resolved marker after the callback completes.
After the callback, your Next.js page reads searchParams.phase server-side:
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ phase?: string }>;
}) {
const { phase } = await searchParams;
const session = await auth0.getSession();
const initialPhase = phase === 'resolved' && session?.user ? 'resolved' : 'anonymous';
// pass initialPhase and user to the client component
}The client component reads localStorage on mount and fires the resolution call:
useEffect(() => {
if (initialPhase !== 'resolved') return;
const pending = localStorage.getItem('ph_pending_distinct_id');
if (pending && user?.sub) {
// client-side identify for future events
posthog.identify(user.sub, { email: user.email, name: user.name });
// server-side resolve for the audit record + person fetch
fetch('/api/anon-to-known/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distinctId: pending, userId: user.sub, scenario: 'signup' }),
});
}
}, []);The /api/anon-to-known/resolve route fires the server-side $identify event to the PostHog Capture API, waits briefly for PostHog to process the merge, then fetches the resulting person profile and returns it to the client:
const capturePayload = {
api_key: process.env.NEXT_PUBLIC_POSTHOG_KEY,
distinct_id: userId,
event: '$identify',
properties: { $anon_distinct_id: distinctId },
};
await fetch(`${PH_HOST}/capture/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(capturePayload),
});
// brief pause for PostHog to process the merge
await new Promise((r) => setTimeout(r, 1500));
const person = await fetch(
`https://us.posthog.com/api/projects/${projectId}/persons/?distinct_id=${userId}`,
{ headers: { Authorization: `Bearer ${personalApiKey}` } }
).then((r) => r.json());The person response shows the merged state clearly: distinct_ids will contain both the anonymous ph_abc123 and the authenticated auth0|xyz789, confirming the stitch succeeded.
Querying the Merged Timeline
Once the person merge is in place, querying the full event history is a single call against the PostHog Events API filtered by the authenticated sub:
const res = await fetch(
`https://us.posthog.com/api/projects/${projectId}/events/?distinct_id=${userId}&limit=50&orderBy=-timestamp`,
{ headers: { Authorization: `Bearer ${personalApiKey}` } }
);PostHog returns events from all distinct IDs on the person - the anonymous pre-login events appear alongside the post-login events, ordered by timestamp. In the reporting view you can split the timeline at the $identify event to show which events were captured anonymously versus after the identity was known.
For the existing-user login scenario this is where the cross-device benefit becomes visible. If the same user has authenticated before on a different browser, their sub already has historical events in PostHog. The new anonymous session adds a third distinct_id to their person record, and the Events API returns the entire consolidated history: prior authenticated sessions, previous anonymous sessions that were merged on earlier logins, and the new one.
The demo also handles the case where POSTHOG_PERSONAL_API_KEY and POSTHOG_PROJECT_ID are not configured - the resolve route returns a simulated person object showing what the response would look like, so the demo can be run without a PostHog account while still demonstrating the concept.
Conclusion
PostHog's person merge model and real-time Events API make it the practical choice when you want to prove the anonymous-to-known stitch happened and display it within your own application. The stitch itself takes three ingredients: capturing the distinct_id before the Auth0 redirect, parking it in localStorage to survive the cross-origin trip, and calling $identify with the Auth0 sub after the callback. Everything else is querying and display.
For production deployments, Segment is often the better routing layer - one identify() call fans out to GA4, Amplitude, your data warehouse, and PostHog simultaneously. PostHog remains the right choice for the in-app reporting layer, or when the free tier is sufficient for the full analytics workload. The live demo is embedded in the Auth0 session and token management demo if you want to walk through the implementation directly.