PostHog Data Handling
Overview
Manage analytics data privacy in PostHog. Covers property sanitization before event capture, user opt-out/consent management, data deletion for GDPR compliance, and configuring PostHog's built-in privacy controls.
Prerequisites
-
PostHog project (Cloud or self-hosted)
-
posthog-js and/or posthog-node SDKs
-
Understanding of GDPR data subject rights
-
Privacy policy covering analytics data
Instructions
Step 1: Configure Privacy-Safe Event Capture
import posthog from 'posthog-js';
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: 'https://us.i.posthog.com', autocapture: false, // Disable to control what's captured capture_pageview: true, capture_pageleave: true, mask_all_text: false, mask_all_element_attributes: false,
// Sanitize properties before sending sanitize_properties: (properties, eventName) => { // Remove PII from all events delete properties['$ip']; delete properties['email'];
// Redact URLs containing tokens
if (properties['$current_url']) {
properties['$current_url'] = properties['$current_url']
.replace(/token=[^&]+/g, 'token=[REDACTED]')
.replace(/key=[^&]+/g, 'key=[REDACTED]');
}
return properties;
},
// Respect Do Not Track respect_dnt: true, opt_out_capturing_by_default: false, });
Step 2: Consent-Based Tracking
// Cookie consent integration function handleConsentChange(consent: { analytics: boolean; marketing: boolean; }) { if (consent.analytics) { posthog.opt_in_capturing(); } else { posthog.opt_out_capturing(); posthog.reset(); // Clear local data } }
// Check consent before identifying users function identifyWithConsent( userId: string, traits: Record<string, any>, hasConsent: boolean ) { if (!hasConsent) return;
// Only send non-PII traits const safeTraits: Record<string, any> = { plan: traits.plan, signup_date: traits.signupDate, account_type: traits.accountType, };
// Explicitly exclude PII // Do NOT send: email, name, phone, address posthog.identify(userId, safeTraits); }
Step 3: GDPR Data Deletion
// Server-side: delete user data for GDPR requests
async function deleteUserData(distinctId: string) {
const response = await fetch(
https://us.i.posthog.com/api/person/${distinctId}/delete/,
{
method: 'POST',
headers: {
Authorization: Bearer ${process.env.POSTHOG_PERSONAL_API_KEY},
'Content-Type': 'application/json',
},
body: JSON.stringify({
delete_events: true, // Also delete all events from this user
}),
}
);
if (!response.ok) {
throw new Error(Failed to delete user data: ${response.status});
}
return { deletedUser: distinctId, status: 'completed' }; }
// Find person by property for deletion lookup
async function findPersonByEmail(email: string) {
const response = await fetch(
https://us.i.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/persons/?properties=[{"key":"email","value":"${email}","type":"person"}],
{
headers: {
Authorization: Bearer ${process.env.POSTHOG_PERSONAL_API_KEY},
},
}
);
const data = await response.json(); return data.results?.[0]?.distinct_ids?.[0]; }
Step 4: Property Filtering for Exports
// Filter sensitive properties from HogQL exports async function safeExport(query: string) { const BLOCKED_PROPERTIES = ['$ip', 'email', 'phone', 'name', 'address'];
const response = await fetch(
https://us.i.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/query/,
{
method: 'POST',
headers: {
Authorization: Bearer ${process.env.POSTHOG_PERSONAL_API_KEY},
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: { kind: 'HogQLQuery', query },
}),
}
);
const data = await response.json();
// Strip blocked columns from results if (data.columns && data.results) { const blockedIndexes = data.columns .map((col: string, i: number) => BLOCKED_PROPERTIES.some(b => col.includes(b)) ? i : -1) .filter((i: number) => i >= 0);
data.results = data.results.map((row: any[]) =>
row.filter((_: any, i: number) => !blockedIndexes.includes(i))
);
data.columns = data.columns.filter((_: string, i: number) => !blockedIndexes.includes(i));
}
return data; }
Error Handling
Issue Cause Solution
PII in events Autocapture sending form data Disable autocapture, use manual capture
Consent not respected opt_out not called Check consent state on every page load
Deletion failed Wrong distinct_id Look up person by email first
IP in events Not stripped Use sanitize_properties to remove $ip
Examples
GDPR Subject Access Request
async function handleSAR(email: string) { const distinctId = await findPersonByEmail(email); if (!distinctId) return { found: false };
// Export their data (filtered)
const data = await safeExport(
SELECT event, timestamp, properties FROM events WHERE distinct_id = '${distinctId}' LIMIT 1000 # 1000: 1 second in ms
);
return { found: true, events: data.results.length };
}
Resources
-
PostHog Privacy Controls
-
PostHog GDPR
Output
-
Configuration files or code changes applied to the project
-
Validation report confirming correct implementation
-
Summary of changes made and their rationale