How To Solve Self-Referral Issues In PostHog For Web3 Analytics

Your own subdomains showing up as top referrers isn't an attribution glitch — it's usually broken session persistence. Here's how to diagnose it, with the console commands and PostHog queries to run yourself.
We were pulling a routine referrer breakdown when something odd showed up in the top ten: our own subdomains. Not once, but three separate internal domains — the marketing site, the app itself, and a rewards page — all ranking as referring domains, some of them ahead of Google and X. That's the kind of finding that looks like an attribution problem. It isn't. It's a session-identity problem wearing an attribution costume, and it's a lot more common in PostHog setups than the dashboards let on. This post walks through the actual console commands and PostHog queries used to diagnose it, including what the output should look like and what to flag when it doesn't.
Why this bites web3 products especially hard
Most products get a stable user ID the moment someone signs up. Web3 products don't work that way. A visitor can browse and trade for days before ever connecting a wallet — and the moment they do connect is often the first time PostHog has anything durable to anchor an identity to. Everything before that depends entirely on the session layer holding together on its own, across reloads and across subdomains.
That's why a persistence bug is more expensive here than in a typical SaaS funnel — it's not the cause of what we found, but it's exactly the kind of moment that makes the underlying bug worth chasing down.
Ruling out the obvious suspects first
Suspect 1: cookie domain misconfiguration. Open the dev console on the subdomain in question and run:
document.cookie
.split('; ')
.filter(c => c.startsWith('ph_'))
Example output:
["ph_a1B2c3D4e5F6g7H8i9J0kLmN_posthog=%7B%22distinct_id%22%3A%2201a17bce-4f2a...%22%7D"]
If you see a ph_<project-key>_posthog cookie on both subdomains with matching distinct_id and $device_id, cookie sharing looks fine on the surface. Don't stop there — parse the cookie value:
const raw = document.cookie
.split('; ')
.find(c => c.startsWith('ph_'))
?.split('=')[1];
JSON.parse(decodeURIComponent(raw))
Example output:
{
distinct_id: "01a17bce-4f2a-77e1-9c3d-6b8e2f0a91cc",
$sesid: [1735489200000, "01a17bce-51d0-7ab2-8e44-3f9c1d6b0a22", 1735489200000],
$initial_person_info: { r: "$direct", u: "https://rewards.yourdomain.com/" }
}
Watch out for: the $initial_person_info.u value. This tells you which URL the cookie was originally created on — not which subdomains it's currently visible from. In the example above, you're standing on app.yourdomain.com, but the cookie says it was created on rewards.yourdomain.com. That means this cookie was inherited via the shared .yourdomain.com cookie domain, not written locally by the subdomain you're testing. Any visitor who never touches rewards.yourdomain.com first gets no cookie at all here — the sharing you saw was borrowed, not generated.
Suspect 2: the session idle timeout. In PostHog, build a trend on the $pageview event, breakdown by session duration bucket (PostHog exposes $session_duration in insights), over the last 30 days, on the affected subdomain. If sessions were resetting because of the idle timeout, you'd expect a spike in the bucket right below the timeout threshold. If that bucket is flat or one of the smallest relative to the others, the timeout isn't your cause — move on.
The diagnostic toolkit: console commands to run on each subdomain
Run all of these in an incognito window, on each subdomain, before doing anything else. Incognito guarantees no inherited state, which is the only way to see what each subdomain does for a genuinely fresh visitor.
1. Check current identity and session:
posthog.get_distinct_id()
posthog.get_session_id()
Note both values, then reload the page and run them again.
Example output — first run:
"01a17c40-88b3-7f21-a5d0-9e4c7b1a3d02" // distinct_id
"01a17c40-8a19-7d44-b2e7-1f6a0c9d8e55" // session_id
Example output — after reload, healthy subdomain:
"01a17c40-88b3-7f21-a5d0-9e4c7b1a3d02" // same distinct_id ✅
"01a17c40-8a19-7d44-b2e7-1f6a0c9d8e55" // same session_id ✅
Example output — after reload, broken subdomain:
"01a17c9e-2d05-7a88-9f13-5c0b8e4a7f61" // brand new distinct_id ❌
"01a17c9e-2f30-7b12-8e04-3a9d1c6b0f88" // brand new session_id ❌
Watch out for: any change at all in either value after a plain reload. A healthy subdomain returns identical IDs. If both values are different every time you reload, that subdomain isn't persisting identity — it's minting a fresh anonymous ID on every page load.
2. Check what's actually in storage:
Object.keys(localStorage).filter(k => k.startsWith('ph_'))
Example output — healthy subdomain:
["ph_a1B2c3D4e5F6g7H8i9J0kLmN_posthog"]
Example output — broken subdomain:
[]
Watch out for: an empty array. That means PostHog isn't writing to localStorage on this subdomain at all, regardless of what the cookie check showed earlier. A subdomain can appear to have a cookie (inherited, per Suspect 1) while having nothing in localStorage of its own — that combination is a strong signal you're dealing with a subdomain that never writes its own state.
3. Check the SDK's own persistence config:
posthog.config.persistence
Example output — subdomain A:
"memory"
Example output — subdomain B:
"localStorage+cookie"
Watch out for: this is the smoking gun if it exists. A value of "memory" means the SDK was explicitly told to never write to cookies or localStorage on that subdomain. Run this exact command on every subdomain you're auditing and compare results side by side — an inconsistency here (some subdomains say memory, others say localStorage+cookie) explains everything you saw in checks 1 and 2 without needing anything further.
4. Confirm it's not tied to login/auth state:
// before connecting a wallet:
posthog.get_distinct_id()
"01a17d10-6f22-7c99-a04b-8d2e1f7a6c33" // anonymous UUID
// after connecting:
posthog.get_distinct_id()
"0x7a3f9c1e4b6d8025f1a9c3e7b4d6f8025f1a9c3e" // now the wallet address
// now refresh the page:
posthog.get_distinct_id()
Example output — broken subdomain:
"01a17d88-9b04-7e21-8f3c-2a6d0b9e5f77" // brand new anonymous UUID, despite wallet still connected in the UI ❌
Example output — healthy subdomain:
"0x7a3f9c1e4b6d8025f1a9c3e7b4d6f8025f1a9c3e" // same wallet address, persisted ✅
Watch out for: the ID reverting to a fresh anonymous UUID after refresh even though your app still shows the wallet as connected. That rules out a consent-gate or an identify-triggered persistence upgrade — if it only broke before auth, you'd expect it to stay fixed after auth. Seeing it break again on reload, post-auth, confirms the setting is a blanket, unconditional one.
Phasing the test in PostHog's live event stream
Console checks tell you what the SDK is doing locally. To confirm what PostHog's servers actually receive, tag your test traffic so it's easy to isolate in the live events view, then run a scripted, multi-phase walkthrough.
Setup: append a throwaway query parameter to every test URL, e.g. ?ph_test=phase1. In PostHog, go to Activity → Live events and filter on:
$current_url contains ph_test
This keeps your test events isolated from real traffic while you work.
Phase 1 — cold load, no auth
Load https://app.yourdomain.com/page?ph_test=phase1_no_auth in a fresh incognito tab. Do nothing else. Find the $pageview in the live stream.
Example event:
event: $pageview
distinct_id: 01a17e20-3c88-7f01-9d4a-6b2e8c1f0a55
$session_id: 01a17e20-3e19-7b44-8c02-1f9a6d3e0b77
$referring_domain: $direct
Phase 2 — trigger identify, same tab, no reload
Complete whatever action triggers posthog.identify() in your app. Do not reload. Find the resulting $identify event.
Example event — working correctly:
event: $identify
distinct_id: 0x9c4e1a7f3b6d0258e1a9c3f7b5d0e2f4a8c6d1e9
$session_id: 01a17e20-3e19-7b44-8c02-1f9a6d3e0b77 // same as Phase 1 ✅
Watch out for: if $session_id here doesn't match Phase 1's, identify() itself is breaking the session even within a single page load — a different (and rarer) failure mode than the one this post is chasing, but worth ruling out before moving on.
Phase 3 — reload, same tab, still authenticated
Reload the same URL (optionally with a new tag, e.g. ?ph_test=phase3_after_refresh). Look at the $pageview that fires immediately on load, before your app's own auto-identify logic has had a chance to run.
Example event — broken subdomain:
event: $pageview
distinct_id: 01a17e91-0a4c-7d33-9f6b-2e8a1c4d7f00 // brand new UUID ❌
$session_id: 01a17e91-0c15-7f22-8b3a-4d9e6f1a2c88 // brand new session ❌
$referring_domain: $direct
Watch out for: a brand-new distinct_id and $session_id on this event, despite the user still being authenticated in the UI. This is the tell. It means every page load has a genuine anonymous window — however brief — before your app re-establishes identity, and if that window is wide enough or frequent enough, it's where orphaned sessions and self-referrals both originate.
Also note: don't panic over $referring_domain: $direct here — browsers don't send a Referer header on manual refreshes, so this reads $direct in Phase 3 regardless of persistence mode. Self-referrals in production come from actual link clicks between subdomains, not refreshes — that's Phase 4.
Phase 4 — the actual cross-subdomain CTA test
This is the one that reproduces the self-referral directly. Tag your entry URL, e.g. https://marketing.yourdomain.com?ph_test=cta_test, then click through your real "Get Started"-style CTA into the app subdomain. Pull both $pageview events from the live stream and compare.
Example — originating subdomain:
event: $pageview
URL: https://marketing.yourdomain.com/?ph_test=cta_test
distinct_id: 01a17f02-7b19-7c40-8e5d-3a6f1c9b0d22
Example — destination subdomain, broken:
event: $pageview
distinct_id: 01a17f0a-9d33-7e18-8c4f-1b6a3d9e2f05 // brand new UUID ❌
$referring_domain: marketing.yourdomain.com // self-referral, confirmed ❌
Watch out for:
-
A
distinct_idon the destination event that doesn't match the originating one — confirms identity isn't carrying across. -
$referring_domainpopulated with your own marketing subdomain — that's the self-referral, reproduced on demand. -
If your CTA opens in a new tab (
target="_blank"), check whether$referring_domaininstead shows$directon the destination event. New tabs don't inherit theRefererheader, so a broken session here hides inside your direct-traffic bucket rather than your self-referral bucket — same bug, different label. A clean self-referral report doesn't mean this is fixed everywhere; check direct-traffic composition too.
Phase 5 — check whether an existing workaround is actually being read
If your team has already tried to pass identity across subdomains via a custom URL parameter, check what's actually arriving:
new URLSearchParams(window.location.search).get('your_custom_param_name')
"01a17f02-7b19-7c40-8e5d-3a6f1c9b0d22" // present in the URL
Then check whether PostHog picked it up:
posthog.get_distinct_id()
"01a17f0a-9d33-7e18-8c4f-1b6a3d9e2f05" // doesn't match the param above ❌
Watch out for: the custom parameter being present and correct in the URL, while distinct_id on load is a completely different value. That mismatch means the workaround is being silently ignored — PostHog only auto-reads its own bootstrap parameter convention, and a same-named custom parameter does nothing on its own no matter how correct its value is.
Root cause: persistence hardcoded to memory mode
Once you've run the above, the pattern is usually unambiguous: one or more subdomains have posthog.init() called with persistence: 'memory', while a correctly configured subdomain uses the default (localStorage+cookie). Memory mode writes nothing to the browser — every reload, every new tab, every subdomain jump starts from zero, and it stays that way regardless of auth state, which is exactly what Phase 3 above is designed to expose.
The compounding bug: a workaround that never actually worked
It's common to find that someone already noticed the cross-domain identity gap and built a fix — usually passing the outgoing session's distinct_id through a URL parameter on cross-domain links. The problem shows up in Phase 5: the parameter often uses a custom name instead of PostHog's own bootstrap convention, so it's carried in the URL but never read by the SDK.
Combine that dead workaround with memory-mode persistence on the receiving subdomain, and you get the self-referral directly: a visitor navigates internally, the destination subdomain mints a brand-new anonymous identity with zero shared context, and the browser reports the previous internal page as the referrer.
The fix
Fix 1 — read the parameter PostHog actually expects. Rather than a custom name, pass identity across subdomains using PostHog's own bootstrap approach so the receiving SDK can pick it up automatically:
// on the outgoing link/button:
const url = `https://app.yourdomain.com/page?ph_distinct_id=${posthog.get_distinct_id()}&ph_session_id=${posthog.get_session_id()}`;
Keep any existing custom parameter alongside it if it serves another purpose — just don't rely on it for identity continuity.
Fix 2 — remove memory-mode persistence.
// before
posthog.init('<project-key>', {
persistence: 'memory', // remove this
});
// after
posthog.init('<project-key>', {
session_idle_timeout_seconds: 7200, // set deliberately, don't leave default
});
Leave any subdomain that's already correctly configured untouched — it's your control group and proof the rest of the setup is capable of working the same way.
Verifying the fix
Re-run the exact same phased test:
-
Phase 3 repeat: reload after auth, confirm
distinct_idand$session_idnow persist instead of resetting. -
Phase 4 repeat: click the cross-subdomain CTA again, confirm
distinct_idcarries over and$referring_domainon the destination no longer shows the originating subdomain. -
Live referrer breakdown: rebuild your original
$referring_domaintrend and confirm internal subdomains drop out of the top results. -
Pageviews-per-session ratio: on the previously broken subdomain, this should rise to match whatever subdomain was already configured correctly — a ratio at or below roughly 1:1 was your original tell that sessions were resetting far more often than they should.
Why teams end up here anyway
This isn't usually negligence. The most common path: a genuine privacy-conscious instinct — don't persist anything before the user has done something that signals consent — implemented as a blanket persistence: 'memory' setting rather than a conditional one, with no later step added to upgrade it once that consent-equivalent action happens. Add subdomains built at different times by different people, plus a workaround that assumed the wrong parameter name, and you get a setup where each piece made sense in isolation but nothing stitches together.
Takeaway
posthog.config.persistence and a five-minute incognito test will tell you a lot. If you're chasing a self-referral problem, don't start in the attribution reports — start in the console, check what's actually surviving a reload, and work backward from there.