Tutorial: Finding Personal Data in GA4 Page Paths and Removing It

Contents
Personal data does not arrive in an analytics property through a decision. It arrives because a form submits over GET instead of POST, because a password reset link carries an address, or because a search box writes what was typed into the URL.
Nothing in the interface reports it. The page path is just a dimension, and a path containing an email address looks like any other path in a list of four thousand. Finding it takes one query and six patterns; getting rid of it takes two separate measures, because the leak and the stored data are different problems.

What Counts as Personal Data in a Page Path
The obvious cases are email addresses and phone numbers. The less obvious ones cause more trouble, because they are not recognised as personal at all.
An order number is personal data as soon as it can be resolved to a person in another system, and it usually can. A password reset token is not personal data but is a secret, and a secret in an analytics report is available to everyone with read access to the property. A search term is personal data whenever people type their own name or address into the search box, which on a shop with an order lookup happens daily.
What all of them have in common is that they arrive through the URL, and the URL reaches GA4 in full. Everything after the question mark becomes part of the page dimension, and the fragment after the hash is the only part that never leaves the browser.
Pulling the Page Paths Out of the Property
The Data API returns the same dimensions the reports use, without the row limits of the interface. One request over a long window brings back everything that was ever recorded.
curl -X POST \
"https://analyticsdata.googleapis.com/v1beta/properties/123456789:runReport" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dateRanges": [{ "startDate": "395daysAgo", "endDate": "today" }],
"dimensions": [{ "name": "pagePathPlusQueryString" }],
"metrics": [{ "name": "screenPageViews" }],
"limit": 100000,
"orderBys": [{ "metric": { "metricName": "screenPageViews" }, "desc": true }]
}'
Three details decide whether the result is complete. The dimension has to be the one that includes the query string – pagePath alone strips exactly the part where the data usually sits. The date range should reach back as far as the property retains data, because a leak that was fixed six months ago is still in the stored rows. And a property with many paths needs paging: the response carries a row count, and everything beyond the limit needs a second request with an offset.
A note on quota. The API counts tokens per property and per day, and a query over four hundred days with a hundred thousand rows is not free. Running it once a quarter is sensible; running it in a loop while developing exhausts the daily allowance in an afternoon.
The Patterns Worth Scanning For
Six patterns cover almost every finding, and they are worth applying to both the raw and the decoded form of the path, because an address arrives as %40 as often as it does as @.
import re, urllib.parse
MUSTER = {
"mail": re.compile(r"[\w.+-]+@[\w-]+\.[\w.]{2,}"),
"mail_kod": re.compile(r"[\w.+-]+%40[\w-]+"),
"telefon": re.compile(r"(?:\+|00)\d{7,15}|\b0\d{8,13}\b"),
"schluessel": re.compile(r"[?&](?:token|hash|auth|key|sig|pwd|password)=", re.I),
"namensfeld": re.compile(r"[?&](?:name|vorname|nachname|first|last|user)=", re.I),
"suche": re.compile(r"[?&](?:q|s|search|query)=", re.I),
}
def pruefe(pfad):
formen = {pfad, urllib.parse.unquote(pfad)}
return {name for name, m in MUSTER.items() if any(m.search(f) for f in formen)}
The last two need a second look before anything is reported. A parameter called name often carries a product name rather than a person, and a search parameter is only a problem if people put personal data into it – which is settled by reading twenty of the actual values, not by the parameter name.
The phone pattern produces the most false positives of all: order numbers, article numbers and dates of the form 20260920 all match a long run of digits. Narrowing it to the parameters it appears in, rather than to the whole path, removes most of that.
Redacting at the Stream Rather Than in the Tag
With the findings in hand, the leak is closed at the earliest point that is under control. GA4 offers that at the data stream itself, and it applies to everything the stream receives regardless of which tag sent it.
The setting sits in the administration under the web data stream, in the tag settings, as Redact data. It has two parts: an automatic removal of anything shaped like an email address, and a list of query parameter names whose values are replaced before the hit is stored.
token, hash, auth, key, sig, pwd, password,
email, mail, e-mail, user, username,
phone, tel, mobile,
name, firstname, lastname
This list belongs in the same place as the scan results, and it should be re-checked whenever the scan finds a new parameter. Redaction is exact: a parameter that is spelled differently on one page – e_mail rather than email – passes through untouched.
Two remarks on where this sits relative to the other options. Overwriting page_location in the tag manager works too and catches the value earlier, but only for the hits that particular tag sends; a second tag, or a hard-coded gtag call somewhere in the theme, bypasses it. Scrubbing in a server-side container is the strongest option and the only one that also protects the onward transfer to advertising platforms, and it is a separate piece of work with its own article. The stream setting is the one that takes ten minutes and covers everything.
The Data That Is Already Stored
Redaction applies to hits from now on. It changes nothing about the rows already in the property, and those are the ones that matter for a data protection question.
What removes them is a data deletion request, in the administration under the property. The relevant type deletes selected parameters across all events, and the page location is one of those parameters.
Three properties of that mechanism are worth knowing before it is triggered. It has a pending window of several days during which it can be withdrawn, which is deliberate and useful. Once running, it takes up to about two months to be applied throughout. And it is irreversible – the page dimension for that period is gone afterwards, including for the paths that were fine, if the request was scoped to the whole parameter.
The one thing the request does not touch is the BigQuery export. Data exported before the deletion sits in a dataset the property no longer controls, and it has to be removed there separately – which is an UPDATE per daily table like the one below, a DELETE or a rebuilt table, and it needs to happen for the same period.
FOR t IN (
SELECT table_name
FROM `projekt`.analytics_123456789.INFORMATION_SCHEMA.TABLES
WHERE table_name BETWEEN 'events_20260101' AND 'events_20260920')
DO
EXECUTE IMMEDIATE FORMAT(r"""
UPDATE `projekt.analytics_123456789.%s`
SET event_params = ARRAY(
SELECT AS STRUCT
p.key,
IF(p.key = 'page_location',
STRUCT(REGEXP_REPLACE(p.value.string_value,
r'([?&](?:email|token|phone)=)[^&]*', r'\1[entfernt]')
AS string_value, p.value.int_value,
p.value.float_value, p.value.double_value),
p.value) AS value
FROM UNNEST(event_params) AS p)
WHERE TRUE""", t.table_name);
END FOR;
What the Scan Cannot See
Three places hold the same kind of data and do not appear in this report.
The first is event parameters other than the page location. A form tracking setup that sends the entered value as a parameter puts it into a custom dimension, and the scan above never looks there. The same Data API query with the custom dimension in place of the page path finds it, and that query is worth running for every registered dimension once.
The second is the user ID field. A property configured to receive a user ID and given an email address instead is a textbook violation, and it is invisible in reports because the field is not shown in most of them. The check is the same as everywhere: look at the actual values, not at the field name.
The third is everything that has already left. If the hits also went to an advertising platform, then redacting in GA4 changes nothing about the copy that arrived there, and that platform has its own deletion path. The list of destinations is worth writing down before the first deletion request, because the request itself does not produce it.
Questions and answers
Can the same leak reach the property through the referrer?
Yes. When a link leads from a page with data in its URL to another page on the same site, the browser passes the full URL on as the referrer under the usual default, and GA4 stores it in the page_referrer parameter of the next page. The scan is therefore worth repeating with the pageReferrer dimension in place of the page path, and a deletion request that covers only the page location leaves this copy in place. Under the same default, third-party servers receive only the page’s origin through the referrer, as long as the page does not set a more generous referrer policy.
Is switching a form from GET to POST enough?
For the leak at that point, yes; for the rest, no. With POST the fields travel in the request body instead of the URL, so they reach neither GA4 nor the server’s access log nor the browser history.
What matters is the page that appears afterwards. If the server redirects to a URL without the data after the submission, that URL stays clean, and a reload does not send the form a second time. If the redirect appends the input to the URL again, for example as a confirmation on the success page, the leak is back, just one page later.
The switch does not touch the rows already stored; they remain until a deletion request removes them. For a search box, POST is usually not a solution, because result pages can then no longer be linked. GET stays in place there, and protection comes from redaction and from reading the actual values, as the article describes.
Does GA4 record search terms anywhere other than the page path?
Yes, as soon as site search is switched on in enhanced measurement. GA4 then reads the search parameter from the URL and, in addition to the page view, sends a view_search_results event whose search_term parameter carries the term a second time. The scan from the article does not see this copy, because it only queries the page dimension.
The same Data API query with the searchTerm dimension in place of pagePathPlusQueryString brings it back, and the same patterns should be applied to its values. A deletion request that covers only the page location leaves search_term in place. Whether the terms are personal data is, here too, settled by reading actual values rather than by the parameter name.
Is the data in BigQuery gone immediately after the clean-up?
Not immediately. BigQuery keeps the state before every change for time travel, seven days by default, during which it can still be queried with FOR SYSTEM_TIME AS OF, and then for another seven days as a fail-safe, from which only Google support can restore it. The time travel window can be shortened per dataset to the minimum of two days; snapshots, copies from scheduled queries and tables in other projects need their own clean-up.
Practical check: compare multiple Collect requests
The GA4 Parameter Analyser can now inspect several browser Collect requests together. One URL or URL-encoded query/body string goes on each line. If parameters belong to the common URL of a POST request, the separate shared-parameters field makes that context visible on every line.
The overview shows event names, measurement IDs and repeated parameter keys. The comparison lists only differences: an absent key is different from a key with an empty value. Different event types naturally carry different parameters, so a difference alone is not a tracking fault. Repeated keys remain in their original order; the tool makes no claim about which occurrence the receiver will use.
Values are decoded once and rendered as text, including any HTML-like content. The optional CSV download contains the decoded values and is not anonymized. This makes the analyser useful for examining an unexpected page URL or parameter, but it is not a complete personal-data detector. A clean-looking result does not prove that the implementation is free of personal data.
No pasted request is replayed or uploaded. The tool describes the input, not acceptance by Google. Actual collection can be checked using Google DebugView where applicable. Query decoding follows the URL form-encoding rules; packed item segments are only decoded heuristically. JSON Measurement Protocol payloads require the separate validator.