CSIPE

Published

- 36 min read

Banks Are Leaking Tax IDs Through Tracking Pixels. The Fix Is Runtime Control.


The Digital Fortress: Your Everyday Guide to a Safer Digital Life

Stay Safe Online Without Making It Your Second Job

The Digital Fortress (Second Edition)

A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.

Buy the book now
The Anonymity Playbook: Digital Survival for Whistleblowers, Journalists, Activists, and Everyone Else

For People Who Cannot Afford to Get Privacy Wrong

The Anonymity Playbook (Second Edition)

A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.

Buy the book now
Secure Software Development: Practical patterns for building secure software

Write, Ship, and Maintain Code Without Shipping Vulnerabilities

Secure Software Development

A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.

Buy the book now
The Secure Harness: Shipping Production Code with AI Coding Agents

Use AI Coding Agents Without Losing Control of Your Codebase

The Secure Harness

A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.

Buy the book now
The AI Native Engineer: Build, Evaluate, and Ship AI Systems That Work in Production

Stop Shipping Demos. Start Shipping Systems.

The AI Native Engineer

Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.

Buy the book now

What the research found

Jscrambler published findings in July 2026 showing that European and US financial institutions were transmitting customer data to advertising and analytics platforms through tracking pixels. Recipients included Google, Meta, TikTok, and Salesforce.

The specifics vary by institution and are worse than the summary suggests. Spanish mortgage application flows leaked hashed email addresses and phone numbers. Portuguese banking sites sent names, tax identification numbers, and loan details without encryption. Some of this occurred without consent having been obtained, and some occurred in direct contradiction of the preferences a user had actually set in the cookie banner.

The regulatory surface named in the research covers GDPR, the ePrivacy Directive, DORA, and PSD2. For a European bank that is four distinct regimes, three of which carry substantial penalties, engaged by a script somebody added to improve conversion measurement.

The same failure, already priced in healthcare

European banking is the newest sector caught doing this. US healthcare has been paying for it since 2022, which makes it the better guide to what the bill eventually looks like.

The pattern there began with hospital websites running the Meta Pixel on public pages and, in several cases, inside authenticated patient portals. It turned into class action litigation across dozens of providers. An analysis of tracking pixel cases filed between 2023 and 2025 counted more than 100 million dollars in settlements and penalties across nineteen matters. Individual figures give a sense of the exposure for a single mid-sized organisation. MarinHealth settled for 3 million dollars over pixel use on its website. Inova Health Care Services settled for 3.1 million dollars covering both its public site and its MyChart patient portal. Reid Health, Skagit Regional Health, and Jefferson Healthcare reached settlements on comparable facts.

The allegation that recurs across those complaints is procedural rather than technical. No Business Associate Agreement had been signed with the tracking vendor, so there was no lawful route for the disclosure whatever the payload happened to contain. Engineering teams like to argue about whether the leaked data was really identifying. Most of those complaints never needed to reach that question.

Two things transfer directly to the banking case. The mechanism is identical, an autocapturing tag sitting on a page whose fields happen to be regulated, with only the field names differing between sectors: appointment reason and provider name in one, tax identification number and loan amount in the other. The enforcement route differs by geography rather than by severity. In the US the cost arrives through plaintiffs’ firms filing class actions under state wiretapping and privacy statutes, which moves faster than a regulator and settles for cash. In the EU it arrives through a supervisory authority, more slowly, with a much higher ceiling.

The useful conclusion for anyone estimating whether this work is worth funding: a seven-figure settlement is the ordinary outcome for one mid-sized organisation that left an autocapture tag on the wrong page for a couple of years. That is the floor, before any regulator gets involved.

How a marketing tag ends up reading a tax ID

Nobody configures a pixel to collect tax identification numbers. The leak is a consequence of how these scripts are designed to work.

Analytics and advertising tags are built to capture user interaction automatically, because requiring manual instrumentation of every event would make them useless to the marketing teams who buy them. So they attach broad listeners: form submissions, input changes, clicks, page navigation. Many capture form field values by default, or capture them when a heuristic decides a field looks interesting. Several capture the full URL including query parameters, and application state ends up in URLs constantly.

Put a tag configured that way on a mortgage application, and it does exactly what it does everywhere else. The difference is that the form fields now contain a national identification number and an income figure.

The hashing detail deserves attention because it is frequently offered as mitigation. Hashed email addresses are not anonymous. The input space of email addresses is small enough to brute force, and the entire purpose of hashed-email matching in advertising is to let two parties identify the same person, which means it works. A hashed identifier that reliably identifies an individual is personal data under GDPR, and treating it as anonymised is a common and expensive error.

Following one keystroke out of the browser

Autocapture is one route among several, and teams that fix only that route usually keep leaking. Tracing a single value through every path it can take makes the remaining routes obvious.

User types tax ID into input

Parent page DOM

Autocapture: input, change, submit listeners

Session replay: DOM snapshot and mutations

Form submit

Value lands in query string or path segment

Referer header on the next third-party request

page_location and document.title in the analytics event

fetch, XHR, or navigator.sendBeacon

Ad platform ingestion endpoint

Identity graph, audience match, lookalike modelling

The listener route is the one everyone checks. A handler on input, change, or submit reads event.target.value and packages it into the next batch. Vendors ship blocklists and masking attributes to suppress that, and those work right up until the field gets a new id in a redesign.

The URL survives every masking control, because masking operates on DOM values and a URL is not a DOM value. Once an identifier sits in a query string or a path segment it reaches the analytics platform as page_location, it reaches every third-party host as the Referer header on the next request, and it reaches the ad platform a third time if the user clicks a campaign-tagged link. One application reference number in a path is often enough to join a person across datasets that were supposed to stay separate.

Session replay carries the largest payload of the three. Replay tools serialise the DOM and stream mutations, so every rendered value goes to the vendor by design, including values rendered into confirmation screens long after the input itself was cleared. Text masking is configurable in all of them, and the shipped default masks less than a regulated page needs.

The validation error render deserves its own warning. A server-rendered error page usually echoes the submitted value back into the markup so the user does not have to retype it, and that render happens after the analytics tag has loaded, frequently on a URL carrying the submission. Teams test the happy path and miss this completely.

Then there is the first-party proxy. Several vendors offer a CNAME arrangement where their collector answers on metrics.example-bank.com, which makes their requests first-party for cookie lifetime and ad-blocker purposes. Where the data ends up does not change. What does change is that a connect-src allowlist written by hostname now permits the exfiltration, because the hostname belongs to you.

The most damaging finding is data flowing in violation of stated cookie preferences, because it means the consent mechanism was decorative.

The typical architecture explains why. A consent platform renders a banner, records the user’s choice, and writes it to a cookie or local storage. Tags are then supposed to check that value before firing. The enforcement depends on every tag, including ones added later by people who never read the integration guide, correctly consulting that value.

That breaks in predictable ways. A tag hardcoded into the page template loads before the consent platform initialises and fires immediately. A tag added through a tag manager by a marketing user bypasses the engineering review where consent gating would have been checked. A vendor updates their script and changes its initialisation behaviour. A tag loads a second script from another domain, and that one was never in scope.

The common thread is that consent is enforced by convention across many independently maintained scripts, and conventions fail silently.

Runtime enforcement instead

The control that holds is one the page enforces itself, at the point where data would leave the browser, regardless of what any individual script intends.

Content Security Policy is the first layer and the cheapest. A connect-src directive that names permitted destinations means a script attempting to send data elsewhere fails at the browser level. Start in report-only mode to discover what your pages actually contact, which is invariably more hosts than anyone expects.

   Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://cdn.example-bank.com;
  connect-src 'self' https://api.example-bank.com;
  img-src 'self' data:;
  report-uri /csp-violations

Collect reports for a fortnight, review the list of blocked destinations with the marketing team, and then enforce. The review conversation is the valuable part: it is usually the first time anyone has enumerated which third parties receive data from the checkout flow.

CSP constrains destinations and does nothing about what a permitted script reads from the page. For that, the sensitive pages need to be structurally separated from the tags.

The strongest version isolates data entry. Fields collecting regulated data live in an iframe served from a different origin, with no third-party scripts loaded in it. The same-origin policy then prevents any script on the parent page from reading those values, and the guarantee is enforced by the browser rather than by tag configuration. This is the architecture payment providers already use for card fields, and it applies equally to identification numbers and income data.

Where an iframe is impractical, the fallback is a strict allowlist of scripts on regulated pages, with a policy that no tag manager container loads there at all. Marketing loses conversion tracking on those specific steps. That is the correct trade, and it is worth stating explicitly to whoever will object.

A Content Security Policy you can actually ship

The report-only header above is a starting shape rather than a finished policy. Two details decide whether it survives contact with a real application: how inline script gets permitted, and where the reports go.

Host allowlists on their own are weak. If https://cdn.vendor.example is permitted and that host happens to serve a JSONP endpoint, an open redirect, or an archived copy of an old framework, an attacker with any injection point can route through it and stay inside your policy. Nonces combined with strict-dynamic sidestep the problem by trusting scripts you stamped rather than hosts you named, and by letting an approved script load its own dependencies without you enumerating every CDN it might reach for.

   // middleware/csp.js
import { randomBytes } from 'node:crypto'

const REPORT_GROUP = 'csp-endpoint'
const SENSITIVE = /^\/(mortgage|onboarding|payments|verify)\//

export function csp(req, res, next) {
	const nonce = randomBytes(16).toString('base64')
	res.locals.cspNonce = nonce

	const sensitive = SENSITIVE.test(req.path)

	const policy = [
		"default-src 'none'",
		`script-src 'nonce-${nonce}' 'strict-dynamic' https:`,
		"style-src 'self'",
		"img-src 'self' data:",
		"font-src 'self'",
		// Regulated routes talk to your own API and nothing else.
		sensitive ? "connect-src 'self'" : "connect-src 'self' https://analytics.example-bank.com",
		"frame-src 'self' https://fields.example-bank-secure.com",
		"form-action 'self'",
		"frame-ancestors 'none'",
		"base-uri 'none'",
		"object-src 'none'",
		'report-uri /csp/report',
		`report-to ${REPORT_GROUP}`
	].join('; ')

	res.setHeader('Reporting-Endpoints', `${REPORT_GROUP}="/csp/report"`)
	res.setHeader('Content-Security-Policy', policy)

	// Tighter policy under test, evaluated independently by the browser.
	res.setHeader(
		'Content-Security-Policy-Report-Only',
		`${policy}; require-trusted-types-for 'script'`
	)

	next()
}

form-action 'self' is the directive most policies omit and the one that matters most on a page collecting regulated fields. Without it, an injected script can rewrite the form’s action attribute and the browser will POST the entire form body, in cleartext, to a host of the attacker’s choosing. No connect-src rule prevents that, because a form navigation is not a fetch and is governed by a different directive entirely.

base-uri 'none' closes the matching trick, where an injected <base> element silently rewrites every relative script URL on the page. object-src 'none' retires a legacy plugin surface that nobody audits any more.

Serving two headers at once is the technique that makes tightening survivable. The enforced header carries the policy you are confident in, the report-only header carries the next step, and browsers evaluate both independently on the same page load. You get enforcement and telemetry together, and the tighter policy graduates once its report volume settles at zero. Trusted Types belongs in that second header for a long time, because it needs application changes rather than configuration.

Differentiate by route. A campaign landing page and a mortgage application do not need identical policies, and writing one policy that satisfies both means the marketing page’s requirements set the floor for the regulated one. One caution on nonces: they must be unique per response, which conflicts with full-page CDN caching. Either bypass the cache on regulated routes or fall back to hashes there.

Collecting violation reports and making them useful

A policy without a working report pipeline is a policy nobody will trust enough to enforce, so the collector is worth building properly rather than pointing at whatever endpoint the vendor documentation suggests.

Two formats arrive. The legacy report-uri directive posts a single object with content-type: application/csp-report, while the Reporting API posts a batched array as application/reports+json. Accept both, answer immediately, and process afterwards.

   // routes/csp-report.js
import express from 'express'
import { createHash } from 'node:crypto'

const BODY_TYPES = ['application/csp-report', 'application/reports+json', 'application/json']

const IGNORED_SCHEMES = new Set([
	'chrome-extension:',
	'moz-extension:',
	'safari-web-extension:',
	'safari-extension:'
])

export const cspReports = express.Router()

cspReports.post('/csp/report', express.json({ type: BODY_TYPES, limit: '64kb' }), (req, res) => {
	res.status(204).end() // never make the browser wait on us

	const batch = Array.isArray(req.body) ? req.body : [req.body]

	for (const entry of batch) {
		if (entry.type && entry.type !== 'csp-violation') continue
		const r = entry.body ?? entry['csp-report'] ?? entry

		const blocked = r.blockedURL ?? r['blocked-uri'] ?? ''
		const directive =
			r.effectiveDirective ?? r['effective-directive'] ?? r['violated-directive'] ?? ''
		const documentUrl = r.documentURL ?? r['document-uri'] ?? ''

		const origin = toOrigin(blocked)
		if (!origin) continue
		if (IGNORED_SCHEMES.has(new URL(origin).protocol)) continue

		const route = routeTemplate(new URL(documentUrl).pathname)
		const fingerprint = createHash('sha1').update(`${route}|${directive}|${origin}`).digest('hex')

		record({
			fingerprint,
			route,
			directive,
			origin,
			sensitive: SENSITIVE.test(route),
			seenAt: Date.now()
		})
	}
})

function toOrigin(value) {
	if (!value || value === 'inline' || value === 'eval' || value === 'data') return null
	try {
		return new URL(value).origin
	} catch {
		return null
	}
}

Three decisions in that handler carry most of the value. Reducing blockedURL to its origin keeps cardinality survivable, because full URLs with query strings produce a unique report per page view and the signal you care about, a destination you have never seen before, drowns immediately. Reducing the document path to a route template does the same job on the other axis. Dropping extension schemes removes what is usually the majority of raw volume on a consumer site, since browser extensions, client-side antivirus, and ISP injection all trigger violations you cannot fix.

Alerting should split on sensitivity rather than on directive. A previously unseen destination origin on a regulated route pages someone, because that is the exact shape of the incident described in the research. The same event on a marketing route opens a ticket for the next working day.

Reports are lossy, and treating them as a complete record will mislead you. Browser support differs in the details, some violations are reported with the blocked URI stripped for privacy reasons, and a report only ever tells you that a destination was attempted and refused. Nothing in the pipeline tells you what a permitted script read from the page before deciding not to send it anywhere yet.

Subresource Integrity and where it stops helping

Integrity pinning comes up in every discussion of third-party scripts, and it is genuinely useful in a narrower band than people expect. The mechanism is a hash in the tag, checked by the browser before execution.

   <script
	src="https://cdn.example-vendor.com/tag/v4.2.1/tag.js"
	integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6R9GqQ8Kuxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
	crossorigin="anonymous"
	referrerpolicy="no-referrer"
></script>

Four limits decide where this helps.

Most tag vendors ship a mutable URL. The script lives at /tag.js with no version in the path and the contents change whenever they release, which means a pinned hash breaks the tag on their next deploy. A vendor that does not publish versioned immutable URLs cannot be pinned in practice, and asking them for one during procurement is more productive than asking your own team to work around it later.

Integrity covers the fetched bytes and stops there. A container script that passes its hash check then injects further scripts at runtime, none of which carry integrity attributes, which is a precise description of how every tag manager works. Pinning the container proves something about the loader and nothing at all about the tags it loads.

Behaviour is out of scope. A script that matches its hash perfectly still reads every input on the page and still sends what it finds to its own collector. Integrity answers “is this the file we approved” rather than “is this file safe to run next to a tax identification number”.

Dynamically inserted scripts bypass the mechanism unless you set .integrity yourself before appending the element, and there is no broadly supported CSP directive that forces integrity across a document, since the old require-sri-for proposal was withdrawn.

Where SRI earns its place is on scripts you control and version yourself, and as the documented answer to an assessor asking how you assure script integrity. Pair it with immutable, content-addressed URLs and the integrity question becomes a build-time artefact rather than an ongoing argument.

Isolating the fields themselves

The iframe approach sketched earlier deserves a real implementation, because the details are what determine whether the boundary actually holds.

Isolated origin: fields.example-bank-secure.com

Parent origin: www.example-bank.com

renders iframe

postMessage: valid, empty, height

HTTPS POST with the real value

opaque token

postMessage: token only

same-origin policy refuses the read

Tag manager and analytics

Parent DOM and form shell

Hidden input: opaque token

Regulated input field

Client-side validation

Tokenisation request

Bank API

The parent listens for a small, fixed message vocabulary and verifies both the origin and the sending window.

   // parent page: www.example-bank.com
const FIELD_ORIGIN = 'https://fields.example-bank-secure.com'
const frame = document.getElementById('tax-id-frame')

window.addEventListener('message', (event) => {
	if (event.origin !== FIELD_ORIGIN) return
	if (event.source !== frame.contentWindow) return

	const msg = event.data
	if (typeof msg !== 'object' || msg === null) return

	switch (msg.type) {
		case 'field:state':
			// { valid, empty } only. No field value ever crosses this boundary.
			setContinueEnabled(msg.valid === true && msg.empty === false)
			break
		case 'field:token':
			// Opaque reference the backend can redeem. Safe to place in the form.
			document.getElementById('tax-id-token').value = String(msg.token)
			break
		case 'field:resize':
			frame.style.height = `${Math.min(Number(msg.height) || 0, 400)}px`
			break
	}
})

The child holds the real value, talks only to your API, and returns a reference.

   // fields.example-bank-secure.com
// No tag manager. No analytics. CSP: default-src 'none'; connect-src 'self'.
const PARENT_ORIGIN = 'https://www.example-bank.com'
const input = document.getElementById('tax-id')
const post = (payload) => window.parent.postMessage(payload, PARENT_ORIGIN)

input.addEventListener('input', () => {
	post({
		type: 'field:state',
		valid: isValidTaxId(input.value),
		empty: input.value === ''
	})
})

document.getElementById('field-form').addEventListener('submit', async (e) => {
	e.preventDefault()
	const res = await fetch('/tokenise', {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ taxId: input.value }),
		credentials: 'include'
	})
	const { token } = await res.json()
	input.value = ''
	post({ type: 'field:token', token })
})

Five implementation details break this pattern in the wild. Calling postMessage with a '*' target origin publishes the payload to whatever page embeds you. Skipping the origin check on receive lets any frame on the page forge state messages. Echoing the value back to the parent for display purposes reopens the entire hole in the name of user experience. Serving the isolated origin as a subdomain of the parent shares cookie scope with it, so a separate registrable domain is stronger, and host-only cookies are the minimum. Finally, the child needs its own frame-ancestors directive naming your parent, otherwise someone else can embed your field collector inside their page.

The remaining leak paths are the ones the boundary was never going to close: values you render back into the parent on a confirmation screen, and identifiers you put in the parent’s URL. Both need separate fixes.

What each control actually stops

Teams tend to adopt one of these controls and consider the problem addressed, which is why the same finding keeps recurring in different form. Each control covers a different failure, and the gaps between them are where the leaks live.

ControlWhat it stopsWhat it leaves openCost
connect-src allowlistData leaving to a host you never approvedOver-collection by an approved vendor; first-party CNAME collectorsLow
script-src nonce plus strict-dynamicUnapproved script executing at allAnything an approved script decides to load and doMedium
form-action 'self'A rewritten form action posting the whole body offsiteReads by scripts already running on the pageLow
Subresource IntegrityA pinned file changing underneath youRuntime-injected children; the behaviour of the correct fileLow where the vendor supports versioned URLs
Cross-origin iframe for fieldsAny parent-page script reading the valueValues rendered back into the parent; identifiers in the URLMedium to high
Tag manager consent gatingWell-behaved tags firing before consentHardcoded tags, nested tags, misconfigured tagsLow
Vendor masking attributesThe specific fields somebody remembered to markNew fields, renamed selectors, URLs, replay snapshotsLow
Server-side forwardingVendor code running inside your page at allOver-collection you build yourself; lawful basisHigh
Synthetic canary testsNothing directly, they only detectEverything, until a human reads the failureLow

The bottom row is the one worth arguing about internally. Detection stops nothing on its own, and it is still the control with the best return, because every other control on the list degrades quietly over eighteen months and the canary is what tells you it happened.

A reasonable minimum for a regulated flow is three of these together: nonce-based script-src with form-action locked down, a cross-origin iframe for the fields themselves, and a canary test in CI. The rest are refinements.

Four regimes, four different obligations

Engineers tend to treat “compliance” as a single undifferentiated constraint, which makes it hard to reason about severity. The four frameworks named in the research require different things, and knowing which one a given leak engages tells you how urgent it is.

GDPR governs the processing of personal data and requires a lawful basis for it. Sending a customer’s tax identification number to an advertising platform requires a basis, and the plausible ones are consent or legitimate interest. Consent that the user declined is not consent, and legitimate interest is difficult to argue for advertising conveyance of financial identifiers. Penalties reach the higher of 20 million euros or 4% of global annual turnover, and supervisory authorities have been notably willing to act on tracking-related complaints.

The ePrivacy Directive covers the act of storing or reading information on a user’s device, which is what a tracking pixel does regardless of whether the data is personal. Its consent requirement is stricter and more specific than GDPR’s, applies before the storage happens rather than before the processing does, and has no legitimate-interest escape route. A tag that fires before the banner is answered violates ePrivacy even if the data it sends is anonymous.

DORA, the Digital Operational Resilience Act, applies to financial entities in the EU and concerns operational resilience including third-party ICT risk. An unreviewed third-party script executing on a customer-facing banking page is an ICT third-party dependency, and DORA expects those to be inventoried, risk-assessed, and contractually governed. Most tag manager deployments meet none of those expectations.

PSD2 governs payment services and carries strong customer authentication and data protection requirements around payment data. Where the leaking page is part of a payment or account access flow, the exposure extends into that regime as well.

The practical consequence for prioritisation: a leak on a marketing page is a GDPR and ePrivacy matter. The same leak on a mortgage application or payment flow at a regulated institution engages all four, and the resilience and payment regimes bring supervisory relationships that operate on a faster timescale than data protection complaints.

The regimes side by side

Printing the comparison makes the prioritisation conversation with legal much shorter, because the columns people disagree about turn out to be the trigger and the enforcer rather than the penalty.

RegimeWhat triggers itConsent standardExposureWho acts
GDPRProcessing personal data, including hashed identifiers that resolve to a personFreely given, specific, informed, unambiguous; or another lawful basisUp to 20 million euros or 4% of global turnoverSupervisory authority, plus individual claims
ePrivacy DirectiveStoring or reading anything on the device, personal or notPrior consent, with no legitimate-interest alternativeSet by national implementing law, often applied alongside GDPRNational data protection or telecoms regulator
DORAAn ICT third-party dependency at an EU financial entityNot a consent regime; requires inventory, risk assessment, contractual termsSupervisory action and remediation ordersFinancial supervisor
PSD2Payment initiation and account access flowsNot a consent regime; authentication and payment data rulesSupervisory action, licence conditionsFinancial supervisor

The enforcement climate around the first two rows has hardened. Cumulative GDPR fines recorded by the CMS enforcement tracker passed 6 billion euros across roughly 2,685 decisions by March 2026. France’s CNIL issued a 325 million euro fine against Google in September 2025 over advertising and tracking consent practices, which is a reminder that the largest numbers now attach to tracking mechanics rather than to breaches. Italy’s Garante adopted guidelines specifically on tracking pixels in electronic mail in April 2026, and the CNIL ran a consultation on the same subject the previous year. The EDPB launched the fifth edition of its Coordinated Enforcement Framework in March 2026, aimed at transparency and information obligations, with national authorities running parallel investigations. A programme of that shape is how an issue moves from complaint-driven to systematic.

PCI DSS 4.0 turned the script inventory into a control

Anyone processing card payments has a fifth regime to satisfy, and it is the most prescriptive of the set about exactly this problem. Two requirements introduced in PCI DSS v4.0 became mandatory on 31 March 2025 and address browser-side script risk directly.

Requirement 6.4.3 covers scripts loaded and executed in the consumer’s browser on a payment page, including scripts from your own environment and anything a third or fourth party pulls in. It asks for three things: a method confirming each script is authorised, a method assuring the integrity of each script, and an inventory recording written justification for why each one is necessary.

Requirement 11.6.1 asks for a change and tamper detection mechanism that alerts on unauthorised modification of the HTTP headers and the content of the payment page, evaluated at least once every seven days, or on a frequency justified by a targeted risk analysis.

RequirementWhat it asks forMechanism that satisfies itEvidence an assessor wants
6.4.3 authorisationConfirmation that every script on the page is approvedA manifest under version control, changed only by reviewed pull requestThe manifest plus its approval history and named approver
6.4.3 integrityAssurance the script is what it is meant to beSRI where the vendor allows pinning, recorded hashes and monitoring where it does notStored hashes and the alert history from the monitor
6.4.3 inventoryWritten justification for each script’s business needA purpose field per entry, recertified on a fixed cycleThe inventory with review dates and reviewer names
11.6.1 change detectionAlerting on unauthorised page and header modificationA scheduled headless capture diffed against the manifestJob logs, diff output, and evidence of response to a real alert

The manifest is the artefact that makes all four rows answerable, and keeping it in the repository beside the code turns approval into a reviewable event.

   # payment-page-scripts.yml
# Diffed in CI against the live page. Recertified quarterly.
- id: checkout-core
  url: https://cdn.example-bank.com/checkout/4.11.2/core.js
  provider: internal, Payments Platform team
  purpose: renders the card form, validates input, submits to the PSP
  authorised_by: payments-platform-lead
  authorised_on: 2026-04-02
  integrity: sha384-3Xk2p9QwZr7Ld0aVn5YcTt1JbHm8FsRe4Uq6WgAxOyNvCiKlPzMdBhSuEjGr
  reads_card_fields: true

- id: psp-fields
  url: https://js.psp.example/v3/fields.js
  provider: PSP Ltd, contract PSP-2024-118, DPA signed 2024-11-05
  purpose: loads the hosted card field iframes
  authorised_by: payments-platform-lead
  authorised_on: 2026-04-02
  integrity: unpinned, vendor ships a mutable URL, covered by the 11.6.1 monitor
  reads_card_fields: false

The monitor that satisfies 11.6.1 is a scheduled headless browser run that records every script URL served to the payment page, hashes each response body, snapshots the security-relevant response headers, and diffs the result against the manifest. Run it daily rather than weekly, because the standard sets a ceiling on the interval and a daily cadence costs nothing extra.

   // tools/payment-page-monitor.mjs
import { chromium } from 'playwright'
import { createHash } from 'node:crypto'

const PAGE = 'https://www.example-bank.com/checkout/payment'
const HEADERS_WATCHED = [
	'content-security-policy',
	'strict-transport-security',
	'x-frame-options',
	'permissions-policy'
]

const browser = await chromium.launch()
const page = await browser.newPage()
const observed = new Map()

page.on('response', async (res) => {
	const type = res.request().resourceType()
	if (type !== 'script') return
	try {
		const body = await res.body()
		observed.set(res.url(), 'sha384-' + createHash('sha384').update(body).digest('base64'))
	} catch {
		observed.set(res.url(), 'unreadable')
	}
})

const main = await page.goto(PAGE, { waitUntil: 'networkidle' })
const headers = Object.fromEntries(HEADERS_WATCHED.map((h) => [h, main.headers()[h] ?? null]))

await browser.close()
reportDrift(compareToManifest(observed, headers))

One caveat worth checking rather than assuming: the applicability of these two requirements to the smallest self-assessment tier has been adjusted since v4.0 was published, so read the current SAQ text for your acceptance channel instead of relying on a summary. The engineering work is the same either way, and it is work you would want done on a mortgage form that never touches a card number.

Migrating without stalling the project

Teams stall on this because the obvious first move, removing all third-party tags from sensitive flows, produces an immediate fight with whoever depends on the resulting data. A sequence that avoids that fight works better.

Measure before you propose anything. Run the canary test described below across your sensitive flows and produce a list of exactly which fields reach which third parties. Concrete evidence changes the conversation entirely, because the marketing team is usually as surprised as engineering that the tax ID field is being captured. Nobody asked for that data and nobody wants the liability of holding it.

Separate the measurement need from the data leak. Marketing almost always needs conversion counts and funnel drop-off, and almost never needs field values. Server-side tagging satisfies the former without the latter: your backend sends a controlled, explicitly constructed event to the analytics platform, containing exactly the fields you chose, and the browser never talks to the third party at all. That is a genuine engineering project, and it is one that survives future regulatory tightening rather than needing revisiting.

Sequence the rollout by exposure. Payment and identity verification steps first, since those carry the most regimes and the most sensitive fields. Application forms next. Ordinary authenticated pages after that. Marketing pages can keep client-side tags indefinitely, which is worth saying out loud so the proposal does not read as an attack on measurement generally.

Set the policy while the work is fresh. A rule that no client-side tag loads on pages collecting regulated data, enforced by CSP rather than by documentation, prevents the slow re-accumulation that otherwise undoes the project within two years.

Server-side tagging, and the part it does not fix

Since server-side forwarding is the concession that makes the rest of the migration acceptable to marketing, it is worth being precise about what changes and what does not.

Client-side tagServer-side forwarding
What the vendor’s code can readEverything in the DOM, the URL, and storage on that originOnly the fields your code chose to send
Who controls the payloadThe vendor, through configuration you cannot audit at runtimeYou, in a reviewed code path with tests
Consent enforcement pointEvery tag, by conventionOne server-side check before the request is made
Effect of ad blockers and browser privacy defaultsFrequently blocked or truncatedLargely unaffected
Typical failure modeSilent over-collectionSilent under-collection
CSP implicationsVendor host needed in script-src and connect-srcNeither directive needs the vendor
Cost to changeMinutes in a tag managerWeeks of engineering plus ongoing schema ownership

The forwarding endpoint is small, and its value comes from the allowlist rather than the transport.

   // server/analytics/forward.js
const ALLOWED_FIELDS = new Set(['event_name', 'funnel_step', 'product', 'value', 'currency'])

export async function forwardEvent(session, rawEvent) {
	const consent = await consentStore.get(session.consentId)
	if (!consent?.purposes.includes('analytics')) return { skipped: 'no-consent' }

	const payload = {}
	for (const [key, value] of Object.entries(rawEvent)) {
		if (!ALLOWED_FIELDS.has(key)) continue // unknown fields are dropped, never passed through
		payload[key] = value
	}

	payload.client_id = session.pseudonymousId // rotated, not an account number
	payload.event_time = Math.floor(Date.now() / 1000)

	return vendorClient.send(payload, {
		timeout: 2000,
		retries: 1
	})
}
Vendor ingestion APIConsent storeYour application serverBrowser (regulated page)Vendor ingestion APIConsent storeYour application serverBrowser (regulated page)allowlist filter drops anything unrecognisedPOST /events {event_name, funnel_step}lookup consent for this sessionpurposes: [analytics] or []server-to-server event, explicit fields only200204, no vendor host ever contacted by the browser

Three things server-side tagging does not do, all of which get assumed. It does not create a lawful basis: forwarding a tax identification number from your server rather than the browser is the same disclosure to the same recipient. It does not remove ePrivacy exposure, because the identifier stitching sessions together is still written to the device, and a cookie set by your own server is still storage on the user’s device. It does not remove the vendor from your third-party inventory, which matters under DORA specifically.

The failure mode to watch for is teams that build the pipeline and then forward the entire request body to keep parity with what the client-side tag used to capture. That reconstructs the original leak with better uptime and worse visibility, since the payload is now assembled somewhere nobody instrumented. An explicit allowlist that drops unrecognised keys, as above, is what makes the pipeline safe, and the drop should be silent to the caller but counted in your metrics so schema drift shows up.

Verifying rather than assuming

Configuration drifts, so the check needs to run continuously rather than at review time.

A synthetic browser session that loads each sensitive flow, fills it with recognisable canary values, and records every outbound request gives you evidence rather than intent. Searching those requests for the canary values answers the question directly: did the tax ID field’s contents leave the page, and to whom.

   // Playwright: fill the flow with canaries and assert nothing carries them.
const CANARY = 'ZZQQ-CANARY-4417'
const leaks = []

page.on('request', (req) => {
	const url = req.url()
	const body = req.postData() || ''
	if (url.includes(CANARY) || body.includes(CANARY)) {
		leaks.push({ to: new URL(url).host, method: req.method() })
	}
})

await page.goto('https://bank.example/mortgage/apply')
await page.fill('#tax-id', CANARY)
await page.click('#continue')
await page.waitForLoadState('networkidle')

if (leaks.length) {
	throw new Error(`Canary left the page to: ${JSON.stringify(leaks)}`)
}

Run it in CI against staging, and on a schedule against production. It catches the case that matters most, which is a tag added by someone outside engineering after your review concluded.

Run the same check twice, once accepting all cookies and once rejecting them, and compare. Any request that fires in both runs is a request your consent mechanism does not gate, which is precisely the finding the researchers reported.

Turning the canary into a CI suite

The snippet above proves the idea. Turning it into something that survives a year of unrelated frontend changes takes a fixture, a per-field canary map, and three distinct assertions.

   // tests/fixtures/recorder.js
export function recordTraffic(page) {
	const requests = []
	page.on('request', (req) => {
		requests.push({
			host: new URL(req.url()).host,
			url: req.url(),
			body: req.postData() || '',
			type: req.resourceType()
		})
	})
	return {
		requests,
		hosts: () => new Set(requests.map((r) => r.host)),
		carrying: (needle) => requests.filter((r) => r.url.includes(needle) || r.body.includes(needle))
	}
}

Give every field its own canary. A single shared value tells you something leaked; distinct values tell you which input the vendor is reading, and that detail is what ends the argument about whether masking configuration is sufficient.

   // tests/regulated-flows.spec.js
import { test, expect } from '@playwright/test'
import { recordTraffic } from './fixtures/recorder.js'

const CANARIES = {
	'#tax-id': 'ZZQQ-TAXID-4417',
	'#annual-income': 'ZZQQ-INCOME-8823',
	'#email': '[email protected]'
}

const APPROVED_HOSTS = new Set([
	'www.example-bank.com',
	'api.example-bank.com',
	'fields.example-bank-secure.com'
])

async function runFlow(page, { acceptCookies }) {
	const traffic = recordTraffic(page)
	await page.goto('/mortgage/apply')
	await page.click(acceptCookies ? '#cookie-accept-all' : '#cookie-reject-all')
	for (const [selector, value] of Object.entries(CANARIES)) {
		await page.fill(selector, value)
	}
	await page.click('#continue')
	await page.waitForLoadState('networkidle')
	return traffic
}

test('no canary value leaves the page, consent granted', async ({ page }) => {
	const traffic = await runFlow(page, { acceptCookies: true })
	for (const [selector, canary] of Object.entries(CANARIES)) {
		const carriers = traffic.carrying(canary)
		expect(carriers, `${selector} reached ${carriers.map((c) => c.host)}`).toHaveLength(0)
	}
})

test('no unapproved host is contacted', async ({ page }) => {
	const traffic = await runFlow(page, { acceptCookies: true })
	const unexpected = [...traffic.hosts()].filter((h) => !APPROVED_HOSTS.has(h))
	expect(unexpected, 'new third-party hosts on a regulated route').toEqual([])
})

test('rejecting cookies changes the outbound host set', async ({ page }) => {
	const accepted = await runFlow(page, { acceptCookies: true })
	const rejected = await runFlow(page, { acceptCookies: false })
	const ungated = [...rejected.hosts()].filter((h) => accepted.hosts().has(h))
	const thirdParty = ungated.filter((h) => !APPROVED_HOSTS.has(h))
	expect(thirdParty, 'hosts contacted despite rejection').toEqual([])
})

The third test is the one that reproduces the published finding, and it is the test most likely to fail the first time you run it. It fails on tags that read consent state after firing, on tags that treat a missing consent cookie as permission, and on anything hardcoded into the template above the consent script.

Wire the suite into CI with different severities for different signals.

   # .github/workflows/tag-canary.yml
name: tag-canary
on:
  pull_request:
    paths: ['src/**', 'public/**', 'config/tags/**']
  schedule:
    - cron: '17 */6 * * *' # production, four times a day

jobs:
  canary:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npx playwright install --with-deps chromium
      - run: npx playwright test tests/regulated-flows.spec.js
        env:
          BASE_URL: ${{ github.event_name == 'schedule' && vars.PROD_URL || vars.STAGING_URL }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: canary-traffic
          path: test-results/

Gating rules that hold up in practice: a canary value in any outbound request blocks the merge with no override, a previously unseen host on a regulated route blocks the merge but can be cleared by adding the host to the approved list in the same pull request, and a new host on a marketing route only warns. The production schedule runs against a synthetic account with canary values that never appear in staging, so a failure there is unambiguous.

Write the runbook before the first alert, because the correct response at three in the morning is to roll the tag container back to its last approved version and investigate in daylight. Debugging a vendor’s minified bundle while customer data is in flight is the wrong order of operations.

Common mistakes that keep recurring

The same handful of errors show up in nearly every assessment of this problem, and most of them look like controls from a distance.

Treating hashing as anonymisation. Covered above for email, and the same applies to phone numbers, which have a smaller input space still. Advertising platforms offer automatic advanced matching that scrapes form fields for anything resembling contact details and hashes it client-side. The feature is often on by default, and its output is designed to be matchable, which is the definition of identifying.

Gating consent only in the tag manager. The container respects consent. The four tags hardcoded in the page template, added during separate projects over six years, do not know the container exists.

Allowlisting the tag manager host in CSP. Permitting *.googletagmanager.com in script-src grants execution rights to whatever any container operator publishes tomorrow. It is a policy that names a host and delegates the actual decision to a different team. The same reasoning applies to 'unsafe-inline' retained temporarily during a migration that never finished.

Locking down script-src while leaving connect-src open, or the reverse. The first controls what runs, the second controls where it talks. A permitted script with an open connect-src exfiltrates freely, and a locked connect-src with 'unsafe-inline' still lets injected code rewrite the form action.

Relying on the vendor’s sensitive data filter. That filter runs inside the vendor’s script, after the value has already been read into their memory, and its behaviour is defined by their release notes rather than by your policy.

Masking by CSS class alone. Attributes like a no-capture class work until a redesign renames the wrapper, and nothing in your build fails when that happens.

Putting identifiers in URLs. An application reference in a path segment leaks via Referer to every third-party host the page contacts, and no DOM-level masking touches it.

Testing only the happy path. The validation error render, the session timeout screen, and the confirmation page are where values get echoed back into markup, and none of them appear in a smoke test that submits valid data once.

Removing the tag but leaving the plumbing. Deleting the script while the CNAME for the vendor’s first-party collector still resolves, and the server-side container still holds credentials, leaves a working data path that no page inspection will reveal.

Calling a same-origin iframe isolation. An iframe served from your own origin offers no protection at all, since the parent can reach straight through contentDocument into the fields. The boundary comes from the origin difference, and only from that.

The organisational half

The technical controls above are a few days of work. The reason this keeps happening is that the people who add tags and the people accountable for data protection are in different departments with different objectives and no shared process.

Two changes address that. Tag manager access on regulated pages should require the same review as a code deployment, because it is one. And the inventory of third parties receiving data should be a maintained document with an owner, reconciled against the synthetic test output monthly, rather than a snapshot produced for an audit.

The approval path, written down

Making that concrete means naming who decides what, and routing the decision by the sensitivity of the page rather than by the seniority of whoever asked.

Marketing pages only

Authenticated or regulated route

No

Yes

Fails

Passes

Request: new or changed tag

Target route sensitivity

Marketing ops approves in the container

Engineering review as a code change

Contract in place: DPA, sub-processors listed

Rejected, routed to procurement

Privacy review: purpose, lawful basis, retention

Add to script manifest with justification and approver

Canary suite runs against staging

Publish, then recertify quarterly

Four rules make the diagram operate rather than decorate. Publication to a regulated route needs a second approver from outside the requesting team, which is the same standard applied to a production database migration. Every manifest entry carries an expiry date, so a tag nobody recertifies at the next quarterly review is removed by default rather than surviving on inertia. Container access is scoped so marketing users cannot publish to regulated routes at all, since a rule enforced by permissions costs nothing to maintain and a rule enforced by training decays. And the offboarding step gets an explicit owner, because vendors get replaced constantly and the old collector endpoint usually keeps resolving for years.

The reconciliation loop matters more than the approval gate. Once a month, diff three lists: the script manifest, the hosts observed by the canary suite in production, and the vendor list your procurement system believes is active. Any host in the observed set that is absent from the manifest is an unapproved change. Any vendor in procurement absent from both is either a dead contract worth cancelling or a data flow happening somewhere nobody is watching. Both findings are cheap to produce and awkward to explain if an assessor produces them first.

A tracking pixel is a piece of third-party JavaScript with permission to read your page and transmit what it finds. On a marketing site that is an acceptable trade for measurement. On a page collecting a tax identification number, it is an unreviewed data processor with direct access to special-category data, and the regulatory frameworks have opinions about that arrangement.