hello@newnorth.nl+31 (0) 85 401 31 62
/Journal

PII in your analytics: how email addresses end up in GA4 and how to keep them out

ReferenceAnalytics2023.01.30
Freek Kampen
Freek KampenCo-founder, New North Digital

Google bans personal data in Analytics and Ads. Where it leaks in anyway, how to find it in BigQuery, and what to do about it.

What PII is and why Google refuses it

PII stands for personally identifiable information: data that lets you identify a person. Email address, name, phone number, street address, a customer number with a name attached.

Google prohibits sending that data to Google Analytics and Google Ads. It sits in the terms of service, not in a guideline you can argue around, and Google may delete the data or terminate the account.

Then there is the GDPR. An email address inside your GA4 property is personal data shared with a processor for a purpose that appears nowhere in your records, and you cannot get it out again when someone asks.

Almost nobody does this on purpose. It leaks in.

Where it leaks in

Five routes we run into most often:

  • The email address in the URL. A form that submits over GET puts its fields in the query string: /thank-you?email=jane@example.com. GA4 records that URL as page_location and reports it as a landing page.
  • user_id filled with an email address. That field is meant for an internal identifier that means nothing outside your systems. A developer who wants a quick join reaches for the email, because both systems already have it.
  • Name or order details in page_title. Thank-you pages reading "Thanks Jane Smith, order 10482". That title travels to GA4 as page_title.
  • A form field passed as an event parameter. Someone builds a dataLayer push on form submit and includes every field "just in case". Phone number and email are in there.
  • The full page_location in a server-side setup. Server-side GTM receives the request with every parameter intact and forwards it to GA4 and BigQuery. The interface may never show those rows. The export holds them.

How to find it

Start inside GA4. Build a free-form exploration with Page location as the dimension and a filter for "contains @". Repeat it with "contains %40", since the at sign is often encoded. Add Page title as a second check.

If you have the BigQuery export, that is the more reliable place. It holds the raw parameters, including the rows the interface collapsed into (other).

SELECT
  event_date,
  event_name,
  (SELECT value.string_value FROM UNNEST(event_params)
   WHERE key = 'page_location') AS page_location,
  COUNT(*) AS hits
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20240101' AND '20240131'
  AND REGEXP_CONTAINS(
        (SELECT value.string_value FROM UNNEST(event_params)
         WHERE key = 'page_location'),
        r'(@|%40)')
GROUP BY 1, 2, 3
ORDER BY hits DESC

Run the same check against user_id, and scan your event parameter names for email, phone, name and zip. Save it as a scheduled query and let it run monthly: one release that reworks a form brings the problem straight back.

How to keep it out

Clean up before the tag fires, not after. GA4 has no button that undoes this.

In GTM. Write a custom JavaScript variable that rewrites the URL and use it as page_location in your GA4 configuration. Work from a list of parameters you allow rather than a list you strip: new parameters you have never heard of then never make it through.

function() {
  var url = new URL({{Page URL}});
  var allowed = ['gclid', 'utm_source', 'utm_medium', 'utm_campaign'];
  var clean = new URLSearchParams();
  allowed.forEach(function(p) {
    if (url.searchParams.has(p)) clean.set(p, url.searchParams.get(p));
  });
  url.search = clean.toString();
  return url.toString();
}

Server-side. With a server-side container, rewrite it in the client or in a transformation before the GA4 tag sends anything to Google. That also catches hits that never pass through your web container. On hosting, see Cloud Run or Stape.

If it is already in there. For GA4 you file a data deletion request under Admin, which only works within the window Google allows. In BigQuery you own the data, so you rewrite or mask the column yourself.

Hashed email addresses are a different thing

This is where the argument usually goes wrong. A SHA-256 hashed email address sent to Google Enhanced Conversions or the Meta Conversions API is not the same as PII in your analytics, and it is permitted under the terms of those products.

The difference is the channel and the purpose. Enhanced Conversions and the Conversions API exist to match a conversion to a user the platform already knows. The hash goes there, not into your GA4 property, and it never returns in a report.

Normalise before you hash: trim whitespace, lowercase everything. And have a lawful basis for the transfer, because under the GDPR a hashed email address is still personal data. Consent Mode and your consent management apply in full.

What is still off limits: putting that same hash into GA4 as an event parameter because "it is hashed anyway".

What to do with this

  • Run the page_location check today, filtering on @ and %40, in an exploration or in BigQuery.
  • Verify your forms submit over POST. A GET form writes personal data into the URL of every visitor who fills it in.
  • Fill user_id with an internal identifier or a hash, never with an email address.
  • Take names and order details out of the title of your thank-you page.
  • Build your URL cleanup from a list of allowed parameters, in GTM or server-side, and test it in preview before you publish.
  • Send hashed email addresses only to Enhanced Conversions or the Conversions API, and keep them out of your analytics.

Want to talk about this?

Let's talk data.

Tell us about your stack, your goals, the data you wish you had.

Takes 1 minute