back to blog

Bypassing Ad-Blockers for PostHog with a Cloudflare Worker

The report

I finally wired PostHog into Biormin — pageviews, autocapture, session replays, exception tracking, the full package. Deployed to production, opened the site in Brave to check that events were flowing, and got this:

GET https://eu.i.posthog.com/e/            net::ERR_BLOCKED_BY_CLIENT
GET https://eu-assets.i.posthog.com/static/posthog-recorder.js
                                            net::ERR_BLOCKED_BY_CLIENT

Brave Shields, uBlock Origin, and any browser using EasyPrivacy will block anything touching posthog.com. That’s the price of using a shared analytics domain — and depending on your audience it can wipe out 15–40% of your session data before it leaves the user’s machine.

The standard fix is well documented: set up a reverse proxy from your own domain to PostHog. I did that. It fixed ~80% of the problem. This post is about the other 20% — the part where ad-blockers also filter by filename, regardless of what host serves it.

Step 1 — The reverse proxy

PostHog’s reverse-proxy guide recommends a Cloudflare Worker that forwards requests from your own domain to the PostHog API and asset hosts. First-party domains aren’t on filter lists, so requests get through.

Here’s the Worker, deployed on the route biormin.com/ingest/*:

const API_HOST = 'eu.i.posthog.com'
const ASSET_HOST = 'eu-assets.i.posthog.com'

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)
    const pathname = url.pathname.replace(/^\/ingest/, '')

    const isAsset = pathname.startsWith('/static/')
    const targetHost = isAsset ? ASSET_HOST : API_HOST

    const targetUrl = new URL(request.url)
    targetUrl.hostname = targetHost
    targetUrl.pathname = pathname || '/'

    const headers = new Headers(request.headers)
    headers.set('Host', targetHost)

    const upstream = await fetch(targetUrl.toString(), {
      method: request.method,
      headers,
      body: request.method === 'GET' ? undefined : request.body,
      redirect: 'manual',
    })

    return new Response(upstream.body, {
      status: upstream.status,
      headers: upstream.headers,
    })
  },
}

On the client side, posthog-js gets pointed at the proxy:

posthog.init(key, {
  api_host: 'https://biormin.com/ingest',
  ui_host: 'https://eu.posthog.com',
})

Rebuild, redeploy, reload the site with Brave Shields on. Network tab looks great — until it doesn’t.

The bug that isn’t obvious

The /e/, /decide/, /array/config requests all return 200. Events are flowing. But two errors persist:

GET https://biormin.com/ingest/static/posthog-recorder.js
                                            net::ERR_BLOCKED_BY_CLIENT
GET https://biormin.com/ingest/static/dead-clicks-autocapture.js
                                            net::ERR_BLOCKED_BY_CLIENT

The domain is biormin.com. The path is /ingest/static/*. Neither should hit any filter list.

But the filenames do. Open EasyPrivacy’s raw list and grep for posthog-recorder — it’s there. Filter authors know reverse proxies exist. They also know the filenames PostHog serves are stable across every deployment.

So the reverse proxy solves the domain layer. It doesn’t touch the filename layer. And session recording — the whole reason you paid for the observability upgrade — still fails.

Where the filename actually comes from

Before I could rename it, I needed to understand where the SDK gets that name. I opened node_modules/.pnpm/posthog-js@1.396.6/node_modules/posthog-js/dist/module.js and searched for posthog-recorder. Nothing.

The default in the SDK is different:

var e = (i.scriptConfig?.script) || "lazy-recorder";
var s = "/static/" + e + ".js?v=" + t.version;

The SDK reads a scriptConfig.script field from a remote config response. If it’s missing, the default is lazy-recorder — not posthog-recorder. Which means posthog-recorder.js in the wire request had to come from a config response somewhere.

There are two endpoints that carry that config:

  • /array/<token>/config returns JSON: {"sessionRecording":{"scriptConfig":{"script":"posthog-recorder"}, ...}}
  • /array/<token>/config.js returns the same config wrapped as window._POSTHOG_REMOTE_CONFIG[<token>] = {...}

Both are served by the same host as the recorder script itself. Which means the Worker can rewrite them in flight.

Step 2 — Rewriting the script name

The idea: intercept the config response, replace "posthog-recorder" with a short name that isn’t on any filter list, then reverse-map the short name to the real one when the SDK asks for the script.

I picked r for the recorder. Two-character filenames aren’t in filter lists, and they never will be — false-positive rate is too high.

const SCRIPT_ALIASES: Record<string, string> = {
  'r.js': 'posthog-recorder.js',
  'dc.js': 'dead-clicks-autocapture.js',
  'ex.js': 'exception-autocapture.js',
  'sv.js': 'surveys.js',
  'tb.js': 'toolbar.js',
}

On the way out — the response to /decide/ and /array/config gets rewritten so the SDK sees the short name:

const contentType = upstream.headers.get('content-type') ?? ''
const shouldRewrite =
  (pathname.startsWith('/decide') ||
    pathname.startsWith('/array/') ||
    pathname.startsWith('/flags')) &&
  (contentType.includes('json') || contentType.includes('javascript'))

if (shouldRewrite) {
  const body = await upstream.text()
  const rewritten = body
    .replaceAll('"posthog-recorder"', '"r"')
    .replaceAll('"dead-clicks-autocapture"', '"dc"')
  return new Response(rewritten, { /* ... */ })
}

On the way in — the SDK requests /ingest/static/r.js, and the Worker translates the short name back to the real one before forwarding upstream:

if (pathname.startsWith('/static/')) {
  const filename = pathname.slice('/static/'.length).split('?')[0]
  if (filename in SCRIPT_ALIASES) {
    targetPath = `/static/${SCRIPT_ALIASES[filename]}`
  }
}

Redeploy the Worker. Reload the page. posthog-recorder.js is gone from the console. r.js returns 200. Session replay starts recording.

Almost.

The two mistakes that cost me an hour

First: I only rewrote responses with content-type: application/json. The SDK actually prefers /array/<token>/config.js on the second page load — which returns application/javascript. My rewrite silently missed it, so the SDK kept reading the old value and asking for the blocked name. Once I widened the content-type check to include javascript, both endpoints started returning "script":"r".

Second, and worse: PostHog serves the config with cache-control: public, max-age=14400. That’s four hours. Even after I fixed the rewrite and hard-reloaded the browser — even after clicking Clear site data in DevTools — Chrome kept serving the stale config from its disk cache, and the SDK kept fetching the blocked filename.

The fix is a two-liner in the Worker’s rewrite path:

responseHeaders.set('cache-control', 'no-store, no-cache, must-revalidate')
responseHeaders.delete('etag')
responseHeaders.delete('last-modified')

For iterating on this, the reliable move is a fresh incognito window with no extensions. Regular hard-reload lies to you.

What still doesn’t work — and why I don’t care

dead-clicks-autocapture.js is a hardcoded string literal in posthog-js v1.396.6. Not read from scriptConfig, not templated — a literal "dead-clicks-autocapture" embedded in the compiled SDK:

// From posthog-js/dist/module.js, grep "dead-clicks-autocapture"
Ie.__PosthogExtensions__.loadExternalDependency(
  this._instance,
  "dead-clicks-autocapture",
  ...
)

Rewriting the config response does nothing for this — the SDK never asked the config for the name. It just fetches by the hardcoded string.

The workaround I could think of — a Vite plugin that patches the SDK source at build time — is more risk than the payoff justifies. And the payoff is minimal, because my remote config has captureDeadClicks: false. The feature is off. No event fires even if the script did load. The only symptom is one ERR_BLOCKED_BY_CLIENT in the console for users with EasyPrivacy.

I filed an issue upstream asking for the same scriptConfig.script treatment for dead-clicks. Until then, the cost of the residual console error is: cosmetic.

Reproducing this — the full setup

Everything below is what actually runs in production for Biormin. Nothing hidden. Adapt the hostnames and the zone name to yours and you’re done.

1. Create the Worker

You’ll need Cloudflare with your domain on it (nameservers pointing to Cloudflare), and wrangler installed.

Create a folder for the Worker:

mkdir -p apps/posthog-proxy/src
cd apps/posthog-proxy

Add a package.json:

{
  "name": "posthog-proxy",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "deploy": "wrangler deploy",
    "tail": "wrangler tail"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250101.0",
    "wrangler": "^3.100.0"
  }
}

A tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "strict": true,
    "types": ["@cloudflare/workers-types"],
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

And a wrangler.toml — this is where you tell Cloudflare which route triggers the Worker. Replace biormin.com with your zone:

name = "posthog-proxy"
main = "src/index.ts"
compatibility_date = "2025-01-01"

routes = [
  { pattern = "biormin.com/ingest/*", zone_name = "biormin.com" },
]

Install the deps:

pnpm install

2. The Worker source

The full source of src/index.ts — copy this verbatim, only the two upstream hostnames at the top need to match your PostHog region (us.i.posthog.com / us-assets.i.posthog.com for the US cloud):

const API_HOST = 'eu.i.posthog.com'
const ASSET_HOST = 'eu-assets.i.posthog.com'

// Short names that don't appear in any filter list, mapped to the real
// PostHog script filenames. When the SDK asks for `r.js`, the Worker
// fetches `posthog-recorder.js` upstream and returns it as `r.js`.
const SCRIPT_ALIASES: Record<string, string> = {
  'r.js': 'posthog-recorder.js',
  'dc.js': 'dead-clicks-autocapture.js',
  'ex.js': 'exception-autocapture.js',
  'sv.js': 'surveys.js',
  'tb.js': 'toolbar.js',
  'we.js': 'web-experiments.js',
  'tr.js': 'tracing-headers.js',
}

const ALIAS_ENTRIES = Object.entries(SCRIPT_ALIASES)

// Rewrites a config response body so the SDK sees the short names instead
// of the blocked ones. Runs on `/decide/`, `/array/<token>/config`,
// `/array/<token>/config.js`, and `/flags/`.
function rewriteConfigBody(body: string): string {
  let out = body
  for (const [alias, real] of ALIAS_ENTRIES) {
    const realName = real.replace(/\.js$/, '')
    const aliasName = alias.replace(/\.js$/, '')
    out = out.replaceAll(`"${realName}"`, `"${aliasName}"`)
  }
  return out
}

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)
    // Strip the `/ingest` prefix — that's just how the route was mounted.
    const pathname = url.pathname.replace(/^\/ingest/, '')

    const isAsset = pathname.startsWith('/static/')

    // Translate `/static/r.js` back to `/static/posthog-recorder.js` before
    // forwarding upstream. Preserve the query string (posthog appends
    // `?v=<sdk-version>` for cache-busting).
    let targetPath = pathname || '/'
    if (isAsset) {
      const filename = pathname.slice('/static/'.length).split('?')[0]
      if (filename in SCRIPT_ALIASES) {
        targetPath = `/static/${SCRIPT_ALIASES[filename]}`
      }
    }

    const targetHost = isAsset ? ASSET_HOST : API_HOST
    const targetUrl = new URL(request.url)
    targetUrl.hostname = targetHost
    targetUrl.pathname = targetPath
    targetUrl.port = ''
    targetUrl.protocol = 'https:'

    const headers = new Headers(request.headers)
    headers.set('Host', targetHost)
    // Don't leak Cloudflare's edge headers to PostHog.
    headers.delete('cf-connecting-ip')
    headers.delete('cf-ipcountry')
    headers.delete('cf-ray')
    headers.delete('cf-visitor')

    const upstream = await fetch(targetUrl.toString(), {
      method: request.method,
      headers,
      body:
        request.method === 'GET' || request.method === 'HEAD'
          ? undefined
          : request.body,
      redirect: 'manual',
    })

    const responseHeaders = new Headers(upstream.headers)
    // Workers auto-decompress on read; strip the encoding so the browser
    // doesn't try to decompress plaintext.
    responseHeaders.delete('content-encoding')
    responseHeaders.delete('content-length')

    const contentType = upstream.headers.get('content-type') ?? ''
    const shouldRewrite =
      !isAsset &&
      (pathname.startsWith('/decide') ||
        pathname.startsWith('/array/') ||
        pathname.startsWith('/flags')) &&
      // Check both — `/array/<token>/config.js` is JavaScript, the rest JSON.
      (contentType.includes('json') || contentType.includes('javascript'))

    if (shouldRewrite) {
      const body = await upstream.text()
      // Kill caching on rewritten responses. PostHog defaults to
      // `max-age=14400`; browsers happily serve stale bodies through
      // hard reloads and site-data clears if you don't strip this.
      responseHeaders.set('cache-control', 'no-store, no-cache, must-revalidate')
      responseHeaders.delete('etag')
      responseHeaders.delete('last-modified')
      return new Response(rewriteConfigBody(body), {
        status: upstream.status,
        statusText: upstream.statusText,
        headers: responseHeaders,
      })
    }

    return new Response(upstream.body, {
      status: upstream.status,
      statusText: upstream.statusText,
      headers: responseHeaders,
    })
  },
}

3. Deploy the Worker

Log in to Cloudflare and ship it:

npx wrangler login    # opens a browser for OAuth, one-time
npx wrangler deploy

Output should look like:

Uploaded posthog-proxy (5.20 sec)
Deployed posthog-proxy triggers (7.30 sec)
  biormin.com/ingest/* (zone name: biormin.com)

Verify the proxy is live before touching the frontend:

curl -i "https://yourdomain.com/ingest/decide/?v=3"

You should see HTTP/2 200 and a JSON body — not a 404 from your origin. If you see 404, the Worker isn’t registered on the route: check wrangler.toml and your zone name.

Verify the rewrite is working:

curl -s "https://yourdomain.com/ingest/array/<your-token>/config" \
  | grep -oE '"script":"[^"]*"'

You should see "script":"r", not "script":"posthog-recorder". If you still see the long form, either the Worker version is stale (redeploy) or your token in the URL is wrong.

4. Point posthog-js at the proxy

In your frontend, initialize posthog-js with api_host set to the proxy and ui_host set to the real PostHog UI (so links from the SDK to feature flags and dashboards go to the right place):

import posthog from 'posthog-js'

let initialized = false

export function initPostHog(): typeof posthog | null {
  if (typeof window === 'undefined') return null
  if (initialized) return posthog

  const key = import.meta.env.VITE_POSTHOG_KEY as string | undefined
  if (!key) return null

  posthog.init(key, {
    // Proxy — this is where all events, config, and static scripts go.
    api_host: 'https://yourdomain.com/ingest',
    // Real PostHog UI — used only for links from the SDK to the dashboard.
    ui_host: 'https://eu.posthog.com', // or https://us.posthog.com
    capture_pageview: false, // manual, so SPA route changes emit one each
    capture_pageleave: true,
    capture_exceptions: true,
    capture_dead_clicks: false, // the SDK still tries to load the script,
                                // but no event fires; see the earlier section
    autocapture: true,
    persistence: 'localStorage+cookie',
    person_profiles: 'identified_only',
    disable_session_recording: false,
  })

  initialized = true
  return posthog
}

Set the env vars in your production env file (.env.production for a Vite build, wherever your framework reads them):

VITE_POSTHOG_KEY=phc_your_project_key_here
VITE_POSTHOG_HOST=https://yourdomain.com/ingest
VITE_POSTHOG_UI_HOST=https://eu.posthog.com

Rebuild and redeploy the frontend. The VITE_POSTHOG_HOST value is baked into the bundle at build time, so a config change alone won’t take effect — you need a fresh build.

5. Verify end-to-end

Open the deployed site with an ad-blocker enabled — Brave Shields, uBlock Origin, whatever. Open DevTools, Network tab, filter for ingest. What you should see:

  • POST /ingest/array/<token>/config.js → 200
  • POST /ingest/decide/ → 200
  • GET /ingest/static/r.js → 200
  • POST /ingest/e/ → 200 (events)
  • POST /ingest/s/ → 200 (session recording chunks)

No net::ERR_BLOCKED_BY_CLIENT. No posthog-recorder.js in the requests. Just short filenames on your own domain.

Then head to your PostHog dashboard: Activity → Live should show events streaming in, and after a minute or two of navigating around, Replay → Recordings should have a session for you.

If it doesn’t work: the most common failure is the browser serving a stale config from disk cache. Try a fresh incognito window with no extensions before assuming the Worker is wrong.

Takeaways

Four things I want to remember:

  1. Reverse proxying by domain is only half the fight. Filter lists match on filenames too. A domain switch alone leaves session recording and any other named script (surveys, toolbar, exceptions) blocked.
  2. Read the SDK source before assuming the config is authoritative. The posthog-recorder.js name came from a remote config field — the dead-clicks-autocapture.js name is hardcoded. Same-looking symptom, entirely different root cause.
  3. Content-type checks are load-bearing in response rewrites. JSON and JavaScript endpoints can carry the same payload. If your rewrite only checks one, the other silently falls back to the un-rewritten default.
  4. cache-control will lie to you during debugging. If the response you’re rewriting has a long TTL, the browser will happily serve stale bodies through hard reloads and site-data clears. Strip it at the Worker while you iterate. Add it back once you’re confident.

If you set up PostHog and Live events are flowing but your Replay dashboard is suspiciously empty, this is where I’d start looking.