• Home
  • Developer GTM
  • How to Set Up Signup Attribution in PostHog Behind a Cookie Banner
How to Set Up Signup Attribution in PostHog Behind a Cookie Banner
By Tessa Kriesel profile image Tessa Kriesel
7 min read

How to Set Up Signup Attribution in PostHog Behind a Cookie Banner

Your cookie banner is hiding where your signups come from. PostHog never sees the page they landed on, so nearly all of them show up as direct. Here’s the fix, step by step, with all the code.

When someone signs up for your product, you want to know what brought them in. PostHog can tell you. It takes some extra setup when a cookie banner sits in front of your website and signup happens in a separate app. Without it, nearly every signup shows up as direct.

This tutorial follows one visitor from their first click to their account and fixes each place where their source gets lost.

The path one visitor takes

  1. Someone clicks a link to your website in a Reddit post.
  2. They land on your site. A cookie banner asks if analytics is okay.
  3. They read a couple of pages, then accept.
  4. They click Sign up. That takes them off your website and into your app, which often lives at a different address.
  5. They create an account.

You want the account at the end of that path to say "Reddit." The source drops off at two points. Both happen because analytics waits for the visitor's consent.

  • At the banner. On a site with a cookie banner, PostHog is usually set to stay off until the visitor accepts. The landing from Reddit happens before that, so it isn't recorded.
  • When they move to the app. The app is often a separate site, and the visitor hasn't accepted cookies there yet. So it starts them as a new visitor with no history.

Steps 1 and 2 fix those two points. Step 3 gets the source into your CRM. Step 4 checks that it all works.

What you need

  • PostHog's JavaScript library on the website and the app, using the same project
  • A cookie banner that controls whether analytics runs
  • The website and the app on the same parent domain, with the app on a subdomain of it

Step 1: Remember how the visitor arrived

The visitor lands from Reddit before they've accepted cookies, so PostHog isn't running yet. By the time they accept, the landing is over and there's no record of where it came from.

Start PostHog on the website so it captures and stores nothing until the visitor accepts.

const accepted = visitorHasAccepted()

posthog.init(POSTHOG_KEY, {
  api_host: POSTHOG_HOST,
  opt_out_capturing_by_default: !accepted,                  // capture nothing until the visitor accepts
  persistence: accepted ? 'localStorage+cookie' : 'memory', // store nothing until the visitor accepts
  person_profiles: 'always',                                // keep a profile for visitors before they sign up
  capture_pageview: false,                                  // you send the pageview yourself, below
})

visitorHasAccepted() stands for however your cookie banner remembers the visitor's choice.

person_profiles: 'always' matters here. The first source is stored on the visitor's profile, so the visitor needs a profile before they have an account.

Then save the landing yourself. When the first page loads, put the page's address and the referrer into the browser tab's session storage. The address carries any campaign tags (the utm_ parameters) and ad click IDs. Nothing leaves the browser.

const ENTRY_KEY = 'entry_context'

function stashEntryContext() {
  try {
    if (sessionStorage.getItem(ENTRY_KEY)) return // only the first page of the visit
    sessionStorage.setItem(ENTRY_KEY, JSON.stringify({
      url: location.href,
      referrer: document.referrer,
    }))
  } catch {}
}

stashEntryContext() // run this on every page load

When the visitor accepts cookies, turn PostHog on and send one pageview that carries what you saved.

// The campaign tags and ad click IDs PostHog records as first touch, as of posthog-js 1.434.
const CAMPAIGN_PARAMS = [
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
  'gad_source', 'mc_cid', 'gclid', 'gclsrc', 'dclid', 'gbraid', 'wbraid',
  'fbclid', 'msclkid', 'twclid', 'li_fat_id', 'igshid', 'ttclid', 'rdt_cid',
  'epik', 'qclid', 'sccid', 'irclid', '_kx',
]

// Returns the saved landing once per visit. After that it returns null.
function takeEntryContext() {
  try {
    const saved = sessionStorage.getItem(ENTRY_KEY)
    if (!saved || sessionStorage.getItem(ENTRY_KEY + '_sent')) return null
    sessionStorage.setItem(ENTRY_KEY + '_sent', '1')
    return JSON.parse(saved)
  } catch {
    return null
  }
}

function hostOf(url) {
  try {
    return new URL(url).host
  } catch {
    return '$direct'
  }
}

function sendPageview() {
  const entry = takeEntryContext()
  if (!entry) {
    posthog.capture('$pageview')
    return
  }

  // The landing page, in the fields PostHog uses.
  const url = new URL(entry.url)
  const landing = {
    $current_url: entry.url,
    $host: url.host,
    $pathname: url.pathname,
    $referrer: entry.referrer || '$direct',
    $referring_domain: entry.referrer ? hostOf(entry.referrer) : '$direct',
  }
  for (const param of CAMPAIGN_PARAMS) {
    landing[param] = url.searchParams.get(param) || null
  }

  // PostHog stores first touch as the same fields with an $initial_ prefix.
  const firstTouch = { ...landing }
  for (const [key, value] of Object.entries(landing)) {
    firstTouch['$initial_' + key.replace('$', '')] = value
  }

  // The pageview itself reports the landing's referrer and campaign tags.
  const props = { $referrer: landing.$referrer, $referring_domain: landing.$referring_domain }
  for (const param of CAMPAIGN_PARAMS) {
    if (landing[param]) props[param] = landing[param]
  }

  posthog.capture('$pageview', props, { $set_once: firstTouch })
}

Then connect it to your cookie banner. Turn PostHog on and send the pageview when the visitor accepts, and turn it back off if they withdraw.

// When the visitor clicks Accept on your banner:
function onAccept() {
  posthog.set_config({ persistence: 'localStorage+cookie' })
  posthog.opt_in_capturing({ captureEventName: false })
  sendPageview()
}

// When the visitor rejects or withdraws consent:
function onReject() {
  posthog.opt_out_capturing()
  posthog.set_config({ persistence: 'memory' })
}

// On every page load, for visitors who accepted earlier:
if (accepted) sendPageview()

The saved landing goes in $set_once, as the third argument to capture. $set_once is PostHog's way of saying "set this only if it isn't set already." It's what keeps Reddit as the visitor's first source, whatever they click later. PostHog fills in first-touch values from the current page on its own, and values passed this way take their place.

Every campaign field gets a value, including null for tags the landing didn't have. That way a later page can't fill them in.

captureEventName: false skips the separate opt-in event, so this pageview is the first event PostHog receives for the visitor.

Step 2: Make the website and the app agree on who the visitor is

PostHog gives every visitor an anonymous ID. Because the app starts fresh, the visitor gets one ID on the website and a different one in the app. That looks like two people, and the one who signs up has no Reddit visit behind them.

The fix is to make the app use the ID the website already handed out.

On the website. There's nothing to add. Once the visitor accepts cookies, PostHog stores their ID in a cookie on your parent domain, where the app can read it. That setting is cross_subdomain_cookie, and it's on by default.

In the app. Start PostHog in memory only, so it doesn't read or write cookies until the visitor accepts. That means it won't pick up the website's ID on its own.

So read the cookie yourself and pass the ID in when PostHog starts, using the bootstrap option.

// Find the ID the website stored in the shared cookie.
function sharedDistinctId(posthogKey) {
  const prefix = `ph_${posthogKey}_posthog=`
  const cookie = document.cookie.split('; ').find((c) => c.startsWith(prefix))
  if (!cookie) return null
  try {
    return JSON.parse(decodeURIComponent(cookie.slice(prefix.length))).distinct_id || null
  } catch {
    return null
  }
}

const accepted = visitorHasAccepted()

// Once the visitor has accepted, PostHog reads the cookie itself.
const sharedId = accepted ? null : sharedDistinctId(POSTHOG_KEY)

// Start PostHog with that ID, if there is one.
posthog.init(POSTHOG_KEY, {
  api_host: POSTHOG_HOST,
  opt_out_capturing_by_default: !accepted,
  persistence: accepted ? 'localStorage+cookie' : 'memory',
  ...(sharedId ? { bootstrap: { distinctID: sharedId, isIdentifiedID: false } } : {}),
})

The app and the website now use the same ID for the visitor.

The last part happens in the app. Opt the visitor in when they accept cookies there, and tell PostHog who they are once they're signed in.

// When the visitor accepts cookies in the app. Run it then, not on every page:
posthog.set_config({ persistence: 'localStorage+cookie' })
posthog.opt_in_capturing({ captureEventName: false })

// When the visitor rejects or withdraws consent:
posthog.opt_out_capturing()
posthog.set_config({ persistence: 'memory' })

// Once they're signed in:
posthog.identify(userId)

PostHog attaches what the anonymous visitor did, including the landing from Reddit, to that user. isIdentifiedID: false in the code above is what allows that. It tells PostHog the shared ID doesn't belong to a known user yet.

Step 3: Put the source on the account

At this point PostHog knows the user came from Reddit. I wanted that on the account in my CRM, next to everything else I know about them.

A script asks PostHog for the first source of whoever created each account. This is my query. My accounts are organizations, so the first part finds the person who created each one. If your accounts are single users, you can skip that part and read the same properties off the person who signed up.

WITH creators AS (
  SELECT $group_0 AS org_key, argMax(person_id, timestamp) AS creator
  FROM events
  WHERE event = 'organization_created'
  GROUP BY org_key
)
SELECT c.org_key,
       p.properties.$initial_utm_source,
       p.properties.$initial_utm_medium,
       p.properties.$initial_utm_campaign,
       p.properties.$initial_referring_domain
FROM creators c
LEFT JOIN persons p ON p.id = c.creator
LIMIT 100000

Keep the LIMIT. Without one, the query API returns the first 100 rows.

The script then writes four fields to the account:

  • Channel: where they came from, like Reddit
  • Type: paid ad, organic, referral, or direct, worked out from the utm_medium tag and the referrer
  • Confidence: high when a campaign tag or referrer backs it up, lower when it's a guess
  • Basis: the raw values from PostHog, so I can check any account by hand

Step 4: Check that it works

Events showing up in PostHog doesn't mean the pieces are connected. I use three checks.

Walk the path yourself. Open a link to your site with campaign tags on it. Click to a second page and accept cookies there. Then click Sign up and create an account. Your new account should show that campaign.

Look at pageviews per person. If the number sits near 1, that often means every page load is being counted as a new person and the ID isn't being kept.

Count how many signups have a source. A signup has a source when the person has a campaign tag, or a referrer from somewhere other than your own site. Swap in your signup event and your domain.

SELECT
  uniqExact(person_id) AS signups,
  uniqExactIf(person_id,
    coalesce(person.properties.$initial_utm_source, '') != ''
    OR (coalesce(person.properties.$initial_referring_domain, '$direct') NOT IN ('$direct', '')
        AND person.properties.$initial_referring_domain NOT LIKE '%yoursite.com')
  ) AS signups_with_a_source
FROM events
WHERE event = 'user_signed_up'
  AND timestamp >= now() - INTERVAL 90 DAY

PostHog also gives each person a channel type on its own, like Direct, Referral, or Organic Search. If you count signups by that and get a much higher number than this query, check how many are labeled Unknown. PostHog uses Unknown for people it never saw on your website. They have no source, so this query leaves them out.

Where this leaves you

For every visitor who accepts cookies, the account they create shows the page they landed on and what sent them there. Visitors who never accept stay without a source. That's by design.

Before you trust the numbers, run the first check in step 4 once on your live site.

By Tessa Kriesel profile image Tessa Kriesel
Updated on
Developer GTM