Skip to main content

Products

Compliance Officer Service Expert-led compliance, end to end Compliance Portal Share security documents securely Open-source platform Deploy Probo on your own infrastructure

Resources

Probo stories How teams get compliant with Probo Blog Ideas and guidance from the Probo team Guides & tools Practical compliance guides and free tools Love from Customers What customers say about working with Probo Changelog Latest product updates Download Get the Probo Agent

Company

About The people and vision powering Probo Careers Join the team building Probo Brand assets Official logos and visual resources Security Review our security and compliance posture
Overview Understand Probo and its core concepts Product Explore Probo's GRC capabilities Developers Explore GraphQL, CLI, MCP, n8n, and webhooks Deployment Probo Cloud, self-hosting, and configuration

Explore

GitHub Explore our open-source compliance tools
Back to Blog
August 5, 2026, by Émile Ré

PostHog feature flags behind a cookie banner (without breaking GDPR)

How to evaluate PostHog feature flags only after analytics consent and identify() — and why cookieless_mode: on_reject is the right Track 2 default when you need flags.

You already have PostHog behind a regulation-aware cookie banner — the Track 2 setup. Now you want a small product feature gated by a PostHog feature flag: hidden when the flag is off, and also treated as off while PostHog is still cookieless or the visitor is unidentified. Only after analytics consent and identify() should the flag come from PostHog.

This companion guide covers the init choice that makes that possible, why the consent-preference cookie PostHog writes is usually fine under GDPR’s “strictly necessary” carve-out, and a minimum wiring pattern. For cookieless-only analytics (Track 1) and the full consent bootstrap, stay on the setup guide.

TL;DR

  • If you need feature flags after consent, start PostHog in reject → cookieless mode (cookieless_mode: "on_reject"), and opt out by default whenever analytics is not already allowed. Do not start in hard cookieless ("always") when analytics is denied — then a later accept in the same visit cannot attach a stable user id, so person-level flags never unlock.
  • While the visitor has rejected (or not yet chosen) analytics, PostHog must not keep tracking cookies or a lasting visitor id. Full capture and identity only start after they accept.
  • PostHog may still store a small opt-in/out preference (__ph_opt_in_out_<token>). Treat that like Probo’s probo_consent cookie: necessary privacy storage, list it as essential — not an analytics tracker.
  • In your app, keep the gated feature hidden until analytics consent is granted and you have told PostHog who the user is. Only then ask PostHog whether the flag is on. If consent is missing, the user is anonymous, or the flag answer is still loading, treat it as off.
  • When they accept: turn capturing on, then identify them. When they revoke: clear PostHog’s local state, then turn capturing off again (clear first — otherwise a previous accept can leave capture running).

Why not cookieless_mode: "always" when analytics is denied?

A common Track 2 pattern was:

cookieless_mode: analyticsAllowed ? "on_reject" : "always",

That looks safer: if the snapshot says analytics is off at init, PostHog never touches browser storage for the whole session. The catch shows up the moment the visitor accepts mid-session:

  1. With "always", identify() is blocked — a stable distinct ID is treated as personal data in that mode.
  2. opt_in_capturing() does not leave cookieless mode for that page load. You stay cookieless until a full reload with consent already granted.
  3. Feature flags that depend on an identified person never flip on in that flow.

If your product needs consent-aware flags (or session replay, surveys, person profiles) after accept, "always" at denied boot is the wrong trade-off.

Always use on_reject for Track 2

Initialize like this (still only after probo-ready, still driven by the consent snapshot for opt_out_capturing_by_default):

const analyticsAllowed = consent.getAll()["analytics"] === true;
posthog.init("<YOUR_POSTHOG_KEY>", {
api_host: "https://us.i.posthog.com", // or your reverse proxy
defaults: "2026-01-30",
cookieless_mode: "on_reject",
opt_out_capturing_by_default: !analyticsAllowed,
person_profiles: "identified_only",
respect_dnt: true,
});

What posthog-js actually does (verified in the SDK, not just the docs): when cookieless_mode is "on_reject" and the visitor is opted out — including pending + opt_out_capturing_by_default — persistence for identity and session is disabled. No tracking distinct_id cookie until they opt in. When they call opt_in_capturing(), the SDK leaves cookieless mode and normal persistence is allowed.

Reserve cookieless_mode: "always" for Track 1 (aggregate pageviews only, no identify, no flags). That is still the right choice when you do not need person-level features at all — see the setup guide.

opt_out_capturing() writes a preference under a key like __ph_opt_in_out_<project_token> (cookie or localStorage, depending on config). That is not the analytics identity cookie. It only records whether PostHog should capture.

Same framing you already use for Probo’s probo_consent cookie: the storage exists to remember and enforce a privacy choice. Under the usual GDPR / ePrivacy reading, that kind of preference storage is treated as strictly necessary, so it does not need the same prior consent as analytics cookies — as long as you:

  • keep the purpose narrow (consent state only),
  • list it in your cookie policy / inventory as essential,
  • do not reuse it for profiling or advertising.

The practical point for integrators: switching Track 2 to always "on_reject" does not mean “PostHog drops tracking cookies on rejectors.” It means rejectors get cookieless analytics (if you enable Cookieless server hash mode) plus a small consent-preference entry.

Minimum pattern, aligned with examples/cookie-banner-react/src/lib/posthog.ts:

import posthog from "posthog-js";
import { getConsent } from "@probo/cookie-banner/consent";
const ANALYTICS = "analytics";
const FLAG_KEY = "example-beta-panel";
const DISTINCT_ID = "cookie-banner-example-demo"; // or your logged-in user id
let initialized = false;
let identified = false;
export function configurePosthogFromBanner() {
if (initialized) return;
initialized = true;
const consent = getConsent();
const analyticsAllowed = consent.getAll()[ANALYTICS] === true;
posthog.init("<YOUR_POSTHOG_KEY>", {
api_host: "https://us.i.posthog.com",
defaults: "2026-01-30",
cookieless_mode: "on_reject",
opt_out_capturing_by_default: !analyticsAllowed,
person_profiles: "identified_only",
respect_dnt: true,
});
posthog.onFeatureFlags(() => {
// re-render your UI from isFeatureFlagEnabled()
});
sync(consent.getAll());
consent.subscribe(sync);
}
function sync(data: Record<string, boolean>) {
if (data[ANALYTICS]) {
posthog.opt_in_capturing();
posthog.identify(DISTINCT_ID);
identified = true;
} else {
// reset() clears stored consent — call it before opt_out
posthog.reset();
posthog.opt_out_capturing();
identified = false;
}
}
/** Default-deny: cookieless, pending, or unidentified ⇒ feature hidden. */
export function isFeatureFlagEnabled(key = FLAG_KEY): boolean {
if (
!initialized ||
posthog.get_explicit_consent_status() !== "granted" ||
!identified
) {
return false;
}
return posthog.isFeatureEnabled(key) ?? false;
}

Hang configurePosthogFromBanner off probo-ready exactly as in the setup guide. Never call identify() before analytics is allowed — that would write personal data without a legal basis.

In the UI, gate the feature on isFeatureFlagEnabled() (or a store that refreshes from onFeatureFlags). Do not show the beta UI when the helper returns false.

Create and target the flag in PostHog

  1. Create a boolean feature flag whose key matches your code (e.g. example-beta-panel).
  2. Either roll out to 100% of users, or add a release condition: Distinct ID equals the string you pass to identify() (e.g. cookie-banner-example-demo) at 100%.
  3. Enable Cookieless server hash mode under Project Settings → Web Analytics if rejected visitors should still count as unique users (same requirement as Track 1 / Track 2 in the setup guide).
  4. Exercise the flow: load without analytics → gated UI hidden; accept analytics → identify runs → UI appears when the flag is on; turn the flag off in PostHog → UI hides after the next flag refresh / reload.

Targeting a distinct ID is only needed when you want the feature limited to that identity. For local demos, 100% rollout is simpler.

Working example

The React example ships the full loop — status panel, consent-gated flag, and a “Beta panel” that only renders when the flag is enabled:

Point it at your banner and PostHog project, accept analytics on the Themed Banner tab, and watch the flag rows flip.

Putting it together

Need feature flags (or identify / replay) after consent?
├─ No → Track 1: cookieless_mode: "always"
└─ Yes → Track 2: cookieless_mode: "on_reject" always
+ opt_in + identify on grant
+ reset + opt_out on revoke
+ app-level default-deny around isFeatureEnabled

For the banner bootstrap, regulation mapping, and custom-event gating, use How to set up PostHog: GDPR, CCPA, and global privacy laws. For Consent Manager details, see the Consent Manager API docs.


Probo is the open-source compliance platform that also ships a free, dependency-free cookie banner with built-in support for GDPR, UK GDPR, FADP, CCPA, CPRA, LGPD, PIPEDA, POPIA, PDPA, PIPL, PIPA, APPI, DPDP, LFPDPPP, and PDPL. If you’d like a walkthrough, book a call.


Written by Émile Ré
Émile Ré is a founding engineer at Probo, building the open-source compliance platform from the ground up. He writes about the technical side of making compliance simple for startups.
Portrait Émile Ré
Sign up for our newsletter to get actionable insights about compliance, right to your inbox.
Logo probo

Managed frameworks

Not seeing the one you are looking for?
Reach out, we likely do it as well.

SOC 2
ISO 42001
FERPA
CCPA
SOC 2 Type 2
ISO 27701
SOC 2 Type 1
GDPR
SOC 3
HIPAA
Get compliant