Overview
Cross-domain tracking lets you follow users as they navigate between different domains or subdomains in your product ecosystem. Without it, a user visiting marketing.example.com and then app.example.com would appear as two different users in your analytics.
PostHog handles cross-domain tracking through persistent identifiers stored in cookies or passed via URL parameters. The approach you choose depends on whether you're tracking across subdomains or completely different domains.
Subdomain Tracking
Tracking across subdomains (like app.example.com, blog.example.com, docs.example.com) is straightforward with PostHog.
Configuration
Enable cross-subdomain cookies:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: true, // Share cookies across subdomains
persistence: 'cookie', // Use cookies (default)
cookie_domain: '.example.com' // Optional: explicitly set cookie domain
});
How it works:
- PostHog sets cookies with domain
.example.com(note the leading dot) - Cookies are accessible from
app.example.com,blog.example.com, etc. - User's
distinct_idpersists across all subdomains automatically
Example Setup
app.example.com:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: true,
persistence: 'cookie'
});
// User visits, gets distinct_id: abc123
blog.example.com:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: true,
persistence: 'cookie'
});
// User's distinct_id is still abc123
Verification
Check cookie domain:
// In browser console
document.cookie.split(';').forEach(cookie => console.log(cookie));
// Look for ph_* cookies with domain=.example.com
Check distinct_id consistency:
// On app.example.com
console.log('App distinct_id:', posthog.get_distinct_id());
// On blog.example.com
console.log('Blog distinct_id:', posthog.get_distinct_id());
// Should be the same
Full Cross-Domain Tracking
Tracking across completely different domains (like example.com and partner-site.com) requires passing the distinct_id via URL parameters, since cookies can't be shared.
Method 1: URL Parameter Passing
On source domain (example.com):
// Initialize PostHog
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com'
});
// Add distinct_id to links to other domain
function addPostHogParamsToLink(link) {
const distinctId = posthog.get_distinct_id();
const url = new URL(link.href);
url.searchParams.set('ph_distinct_id', distinctId);
link.href = url.toString();
}
// Apply to all external links
document.querySelectorAll('a[href*="partner-site.com"]').forEach(link => {
addPostHogParamsToLink(link);
});
On destination domain (partner-site.com):
// Initialize PostHog
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com'
});
// Check for distinct_id in URL
const urlParams = new URLSearchParams(window.location.search);
const distinctId = urlParams.get('ph_distinct_id');
if (distinctId) {
// Use the same distinct_id from source domain
posthog.identify(distinctId);
// Optional: Clean up URL
const cleanUrl = window.location.pathname;
history.replaceState(null, '', cleanUrl);
}
Method 2: Automatic Link Decoration
On source domain:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: false,
persistence: 'cookie'
});
// Automatically decorate all outbound links
function decorateOutboundLinks() {
const distinctId = posthog.get_distinct_id();
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (!link) return;
const url = new URL(link.href, window.location.href);
// Check if external domain
if (url.hostname !== window.location.hostname) {
url.searchParams.set('ph_distinct_id', distinctId);
link.href = url.toString();
}
});
}
decorateOutboundLinks();
On destination domain:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com'
});
// Auto-detect and apply distinct_id from URL
function applyDistinctIdFromUrl() {
const params = new URLSearchParams(window.location.search);
const distinctId = params.get('ph_distinct_id');
if (distinctId && distinctId !== posthog.get_distinct_id()) {
posthog.identify(distinctId);
// Remove parameter from URL
params.delete('ph_distinct_id');
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
history.replaceState(null, '', newUrl);
}
}
applyDistinctIdFromUrl();
Multi-Domain Example
Scenario: E-commerce site with separate domains
shop.example.com- Main shopping sitecheckout.payments.com- Third-party checkoutthankyou.example.com- Post-purchase thank you page
shop.example.com:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: true,
cookie_domain: '.example.com'
});
// User browses, adds to cart
posthog.capture('product_added_to_cart', {
product_id: 'SKU_123'
});
// User clicks checkout button → redirect to third-party
const checkoutButton = document.querySelector('#checkout-btn');
checkoutButton.addEventListener('click', () => {
const distinctId = posthog.get_distinct_id();
window.location.href = `https://checkout.payments.com?ph_distinct_id=${distinctId}&cart=xyz`;
});
checkout.payments.com (third-party):
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com'
});
// Read distinct_id from URL
const params = new URLSearchParams(window.location.search);
const distinctId = params.get('ph_distinct_id');
if (distinctId) {
posthog.identify(distinctId);
}
// Track checkout started
posthog.capture('checkout_started', {
total_amount: 99.99
});
// After payment, redirect back to thank you page
const returnUrl = `https://thankyou.example.com?ph_distinct_id=${distinctId}&order=ORDER_123`;
thankyou.example.com:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com',
cross_subdomain_cookie: true,
cookie_domain: '.example.com'
});
// Read distinct_id from URL
const params = new URLSearchParams(window.location.search);
const distinctId = params.get('ph_distinct_id');
if (distinctId) {
posthog.identify(distinctId);
}
// Track purchase completion
const orderId = params.get('order');
posthog.capture('purchase_completed', {
order_id: orderId,
total_amount: 99.99
});
Single Sign-On (SSO) Scenarios
When users authenticate via SSO and are redirected between domains:
SSO Login Flow:
// app.example.com - User clicks "Login with SSO"
const distinctId = posthog.get_distinct_id();
const ssoUrl = `https://sso.provider.com/auth?redirect_uri=${encodeURIComponent(`https://app.example.com/callback?ph_distinct_id=${distinctId}`)}`;
window.location.href = ssoUrl;
// After SSO authentication, user returns to callback URL
// app.example.com/callback
const params = new URLSearchParams(window.location.search);
const distinctId = params.get('ph_distinct_id');
if (distinctId) {
posthog.identify(distinctId);
}
// Now identify user with their SSO user ID
const userId = getUserIdFromSsoToken();
posthog.identify(userId, {
email: user.email,
name: user.name
});
Server-Side Cross-Domain Tracking
For server-rendered pages, maintain distinct_id across domains:
Node.js example:
const { PostHog } = require('posthog-node');
const posthog = new PostHog('YOUR_PROJECT_API_KEY');
app.get('/redirect-to-partner', (req, res) => {
// Get distinct_id from cookie
const distinctId = req.cookies.ph_distinct_id || generateNewId();
// Redirect with distinct_id in URL
const partnerUrl = `https://partner.com/landing?ph_distinct_id=${distinctId}`;
res.redirect(partnerUrl);
});
// Partner site endpoint
app.get('/landing', (req, res) => {
const distinctId = req.query.ph_distinct_id;
if (distinctId) {
// Set cookie with distinct_id
res.cookie('ph_distinct_id', distinctId, {
domain: '.partner.com',
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year
});
// Track event server-side
posthog.capture({
distinctId: distinctId,
event: 'partner_landing_page_viewed'
});
}
res.render('landing');
});
Testing Cross-Domain Tracking
Manual Testing
1. Start on Domain A:
// Open browser console on app.example.com
console.log('Domain A distinct_id:', posthog.get_distinct_id());
// Output: abc123
2. Navigate to Domain B:
// Click link to blog.example.com or partner-site.com
// Open console on new domain
console.log('Domain B distinct_id:', posthog.get_distinct_id());
// Should output: abc123 (same as Domain A)
3. Check PostHog dashboard:
- Go to Persons & Groups > Persons
- Search for your distinct_id
- View event timeline
- Events from both domains should appear under same user
Automated Testing
Playwright example:
const { test, expect } = require('@playwright/test');
test('cross-domain tracking preserves distinct_id', async ({ page }) => {
// Visit domain A
await page.goto('https://app.example.com');
// Get distinct_id
const distinctIdA = await page.evaluate(() => posthog.get_distinct_id());
// Navigate to domain B
await page.click('a[href*="partner-site.com"]');
await page.waitForURL('**/partner-site.com/**');
// Get distinct_id on domain B
const distinctIdB = await page.evaluate(() => posthog.get_distinct_id());
// Should match
expect(distinctIdA).toBe(distinctIdB);
});
Validation and Testing Procedures
Step 1: Verify Cookie Configuration
For subdomain tracking:
// Open browser console on any subdomain
console.log('PostHog Config:', posthog.config);
// Check cookie settings
console.log('Cross-subdomain:', posthog.config.cross_subdomain_cookie);
console.log('Cookie domain:', posthog.config.cookie_domain);
// Inspect actual cookies
document.cookie.split(';').forEach(cookie => {
if (cookie.includes('ph_')) {
console.log('PostHog cookie:', cookie);
}
});
Expected output:
cross_subdomain_cookie: true- Cookies should have
domain=.example.com(with leading dot) - Cookie should be accessible across all subdomains
Step 2: Test Distinct ID Persistence
Create test script:
// test-cross-domain.js
async function testCrossDomainTracking() {
const results = {
domain1: null,
domain2: null,
match: false
};
// Get distinct_id from current domain
results.domain1 = {
domain: window.location.hostname,
distinctId: posthog.get_distinct_id(),
cookies: document.cookie.split(';').filter(c => c.includes('ph_'))
};
console.log('Domain 1 Results:', results.domain1);
console.log('Now navigate to another domain/subdomain and run this script again');
return results;
}
// Run test
testCrossDomainTracking();
Validation checklist:
- Distinct ID matches across domains
- User properties sync correctly
- Events appear under same user in PostHog dashboard
- Session continuity maintained
- URL parameters passed correctly (for cross-domain)
- URL cleaned up after distinct_id extracted
Step 3: Validate Event Attribution
Check PostHog dashboard:
- Navigate to Persons & Groups > Persons
- Find your test user by distinct_id or email
- View their event timeline
- Verify events from all domains appear under one user profile
- Check that properties from different domains are merged
SQL query for validation (if using PostHog's data warehouse):
SELECT
distinct_id,
event,
properties,
timestamp,
properties.$current_url as url
FROM events
WHERE distinct_id = 'YOUR_TEST_DISTINCT_ID'
ORDER BY timestamp DESC
LIMIT 50;
Step 4: Test Cross-Domain Journey
Complete user journey test:
// Journey test script
const journeyTest = {
steps: [],
recordStep(stepName, domain) {
this.steps.push({
step: stepName,
domain: domain,
distinctId: posthog.get_distinct_id(),
timestamp: new Date().toISOString()
});
console.log(`Step ${this.steps.length}: ${stepName} on ${domain}`);
console.log(`Distinct ID: ${posthog.get_distinct_id()}`);
// Track in PostHog
posthog.capture('journey_step_completed', {
step_name: stepName,
step_number: this.steps.length,
journey_id: this.steps[0]?.distinctId
});
},
validate() {
const distinctIds = [...new Set(this.steps.map(s => s.distinctId))];
console.log('Journey Summary:', {
totalSteps: this.steps.length,
uniqueDistinctIds: distinctIds.length,
isValid: distinctIds.length === 1,
steps: this.steps
});
return distinctIds.length === 1;
}
};
// Use throughout your journey
// On marketing.example.com
journeyTest.recordStep('Viewed landing page', 'marketing.example.com');
// On app.example.com
journeyTest.recordStep('Started signup', 'app.example.com');
// On checkout.payments.com
journeyTest.recordStep('Completed payment', 'checkout.payments.com');
// Validate
journeyTest.validate();
Step 5: Browser Compatibility Testing
Test across browsers:
// Browser detection and logging
const browserTest = {
browser: navigator.userAgent,
cookiesEnabled: navigator.cookieEnabled,
thirdPartyCookies: 'unknown',
localStorage: typeof(Storage) !== 'undefined'
};
// Test third-party cookies
const testThirdPartyCookies = async () => {
try {
// This is a simplified test
const iframe = document.createElement('iframe');
iframe.src = 'https://different-domain.com/cookie-test.html';
document.body.appendChild(iframe);
// Check result after load
setTimeout(() => {
browserTest.thirdPartyCookies = 'enabled';
console.log('Third-party cookies:', browserTest.thirdPartyCookies);
}, 2000);
} catch (e) {
browserTest.thirdPartyCookies = 'blocked';
}
};
console.log('Browser Test Results:', browserTest);
testThirdPartyCookies();
Required browsers for testing:
- Chrome (latest)
- Safari (latest) - Test ITP behavior
- Firefox (latest) - Test ETP behavior
- Edge (latest)
- Mobile Safari (iOS)
- Chrome Mobile (Android)
Step 6: Automated Testing
Playwright test example:
const { test, expect } = require('@playwright/test');
test('cross-domain tracking maintains distinct_id', async ({ page, context }) => {
// Start on domain A
await page.goto('https://marketing.example.com');
// Get initial distinct_id
const distinctIdA = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
console.log('Domain A distinct_id:', distinctIdA);
expect(distinctIdA).toBeTruthy();
// Navigate to domain B (subdomain)
await page.click('a[href*="app.example.com"]');
await page.waitForURL('**/app.example.com/**');
// Get distinct_id on domain B
const distinctIdB = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
console.log('Domain B distinct_id:', distinctIdB);
// Should match
expect(distinctIdB).toBe(distinctIdA);
// Navigate to external domain C
await page.click('a[href*="checkout.payments.com"]');
await page.waitForURL('**/checkout.payments.com/**');
// Check URL contains distinct_id parameter
const url = page.url();
expect(url).toContain('ph_distinct_id=');
// Get distinct_id on domain C
const distinctIdC = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
console.log('Domain C distinct_id:', distinctIdC);
// Should still match
expect(distinctIdC).toBe(distinctIdA);
});
test('cross-domain events attributed to same user', async ({ page }) => {
const events = [];
// Intercept PostHog events
await page.route('**/decide/*', route => route.continue());
await page.route('**/batch/*', route => {
const postData = route.request().postDataJSON();
if (postData?.batch) {
events.push(...postData.batch);
}
route.continue();
});
// Journey across domains
await page.goto('https://marketing.example.com');
await page.click('#cta-button');
await page.goto('https://app.example.com/signup');
await page.fill('#email', 'test@example.com');
await page.click('#signup-submit');
// Wait for events
await page.waitForTimeout(2000);
// Verify all events have same distinct_id
const distinctIds = [...new Set(events.map(e => e.properties?.distinct_id))];
expect(distinctIds.length).toBe(1);
console.log('All events attributed to:', distinctIds[0]);
});
Troubleshooting
Common Issues and Solutions
| Problem | Symptoms | Root Cause | Solution |
|---|---|---|---|
| Distinct ID changes between subdomains | Different users appear in analytics for same person | Cookie domain not set correctly | Set cookie_domain: '.example.com' with leading dot in PostHog init config |
| URL parameters stripped on navigation | Distinct ID lost when redirecting between domains | Framework or CDN removes query parameters | Store distinct_id in localStorage as fallback; check redirect configuration |
| Cross-domain tracking works in Chrome but not Safari | Tracking fails only in Safari/iOS | Intelligent Tracking Prevention (ITP) blocking cookies | Use URL parameter passing instead of cookies; implement server-side tracking for critical events |
| Events show up under different users | Same person appears as multiple users in PostHog | Different API keys used per domain | Use same PostHog project API key across all domains |
| Distinct ID not persisting after login | User tracking resets after authentication | identify() called with new ID without alias |
Call posthog.alias(newId, oldId) before identify() to link anonymous and authenticated sessions |
| Third-party domain loses tracking | External domain (e.g., payment processor) shows as new user | Cookies don't work across different root domains | Implement URL parameter passing with ph_distinct_id query parameter |
| Cookie not accessible across subdomains | Each subdomain has separate cookies | cross_subdomain_cookie not enabled |
Enable in config: cross_subdomain_cookie: true |
| Events delayed or not appearing | Events take minutes to show in dashboard | Network issues or ad blockers | Check browser console for errors; implement retry logic; use reverse proxy |
| Distinct ID visible in URL | Privacy concerns about exposed tracking ID | URL parameter passing without cleanup | Use history.replaceState() to remove parameter after reading |
| Mixed HTTP/HTTPS domains | Tracking fails between secure and non-secure sites | Secure cookies can't be accessed from HTTP | Use HTTPS across all domains |
| LocalStorage not accessible | Fallback mechanism fails | User has disabled localStorage or in incognito mode | Add additional fallback to sessionStorage or server-side session |
| CORS errors in console | PostHog requests blocked | PostHog domain not in CORS whitelist | Configure reverse proxy or verify PostHog CORS settings |
| Multiple PostHog instances loaded | Duplicate events or conflicting configs | PostHog initialized multiple times | Ensure single initialization; use posthog.isFeatureEnabled() to check if already loaded |
| Redirect loops | Page keeps redirecting | Logic error in cross-domain redirect code | Add flag to track if distinct_id already processed; use session storage to prevent loops |
| Mobile app to web handoff fails | Tracking lost when opening web from app | Different tracking mechanisms | Implement deep linking with distinct_id parameter; use universal links with tracking data |
Debug Mode
Enable verbose logging:
posthog.init('YOUR_API_KEY', {
api_host: 'https://app.posthog.com',
debug: true, // Enable debug logging
cross_subdomain_cookie: true
});
// Additional debug helpers
posthog.debug(); // Enable debug mode after init
// Log all PostHog activity
window.addEventListener('posthogEvent', (e) => {
console.log('PostHog Event:', e.detail);
});
Network Inspection
Check network requests:
- Open browser DevTools > Network tab
- Filter by "posthog" or "decide"
- Look for batch requests to
/batch/ - Verify request payload contains correct distinct_id
- Check response status (should be 200)
Example network inspection:
// In browser console
const originalFetch = window.fetch;
window.fetch = function(...args) {
if (args[0].includes('posthog')) {
console.log('PostHog Request:', args);
}
return originalFetch.apply(this, args);
};
Cookie Inspection
Manually inspect PostHog cookies:
// Get all PostHog cookies
function getPostHogCookies() {
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const [name, value] = cookie.trim().split('=');
if (name.startsWith('ph_')) {
acc[name] = {
value: decodeURIComponent(value),
domain: 'Check in DevTools > Application > Cookies',
expires: 'Check in DevTools > Application > Cookies'
};
}
return acc;
}, {});
console.table(cookies);
return cookies;
}
getPostHogCookies();
Expected cookies:
ph_<project_id>_posthog- Main PostHog data- Domain should be
.example.comfor subdomain sharing - Should persist across sessions
Validation Checklist
Before deploying cross-domain tracking to production:
- Cookie domain set correctly (with leading dot for subdomains)
-
cross_subdomain_cookieenabled for subdomain tracking - URL parameter passing implemented for different root domains
- URL cleanup implemented (remove
ph_distinct_idafter use) - Tested in all major browsers (Chrome, Safari, Firefox, Edge)
- Tested on mobile devices (iOS Safari, Chrome Mobile)
- Verified in PostHog dashboard (same user across domains)
- Automated tests created for cross-domain flows
- Privacy policy updated to mention cross-domain tracking
- Cookie consent handling works across all domains
- HTTPS used across all domains
- Same PostHog API key used across all domains
- Tested with ad blockers enabled
- Fallback mechanisms in place (localStorage, etc.)
- Debug mode tested in development
- Error handling implemented for missing distinct_id
- Documentation created for team
Best Practices
Do:
- Use subdomain cookie sharing when domains share a root domain
- Pass distinct_id via URL for completely different domains
- Clean up URL parameters after reading them
- Test cross-domain flows in multiple browsers
- Document your cross-domain tracking setup
Don't:
- Assume cookies work across all domains (they don't)
- Forget to handle missing distinct_id gracefully
- Expose user IDs or PII in URL parameters
- Rely on third-party cookies (browsers are phasing them out)
Privacy Considerations
URL parameter security:
- Don't include sensitive user information in URLs
- Use anonymous distinct_ids, not user IDs or emails
- Implement server-side validation for distinct_ids
- Consider encrypting distinct_id in URL if needed
Cookie compliance:
- Inform users about cross-domain tracking in privacy policy
- Honor cookie consent preferences across all domains
- Implement opt-out mechanism that works across domains
GDPR compliance:
// Check consent before tracking
if (userHasGivenConsent()) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
Need more help? Check PostHog's cross-domain documentation or the troubleshooting guide.