Published
- 36 min read
Banks Are Leaking Tax IDs Through Tracking Pixels. The Fix Is Runtime Control.
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.
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.
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.
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.
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.
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.
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.
Consent that the code never enforces
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.
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.
| Control | What it stops | What it leaves open | Cost |
|---|---|---|---|
connect-src allowlist | Data leaving to a host you never approved | Over-collection by an approved vendor; first-party CNAME collectors | Low |
script-src nonce plus strict-dynamic | Unapproved script executing at all | Anything an approved script decides to load and do | Medium |
form-action 'self' | A rewritten form action posting the whole body offsite | Reads by scripts already running on the page | Low |
| Subresource Integrity | A pinned file changing underneath you | Runtime-injected children; the behaviour of the correct file | Low where the vendor supports versioned URLs |
| Cross-origin iframe for fields | Any parent-page script reading the value | Values rendered back into the parent; identifiers in the URL | Medium to high |
| Tag manager consent gating | Well-behaved tags firing before consent | Hardcoded tags, nested tags, misconfigured tags | Low |
| Vendor masking attributes | The specific fields somebody remembered to mark | New fields, renamed selectors, URLs, replay snapshots | Low |
| Server-side forwarding | Vendor code running inside your page at all | Over-collection you build yourself; lawful basis | High |
| Synthetic canary tests | Nothing directly, they only detect | Everything, until a human reads the failure | Low |
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.
| Regime | What triggers it | Consent standard | Exposure | Who acts |
|---|---|---|---|---|
| GDPR | Processing personal data, including hashed identifiers that resolve to a person | Freely given, specific, informed, unambiguous; or another lawful basis | Up to 20 million euros or 4% of global turnover | Supervisory authority, plus individual claims |
| ePrivacy Directive | Storing or reading anything on the device, personal or not | Prior consent, with no legitimate-interest alternative | Set by national implementing law, often applied alongside GDPR | National data protection or telecoms regulator |
| DORA | An ICT third-party dependency at an EU financial entity | Not a consent regime; requires inventory, risk assessment, contractual terms | Supervisory action and remediation orders | Financial supervisor |
| PSD2 | Payment initiation and account access flows | Not a consent regime; authentication and payment data rules | Supervisory action, licence conditions | Financial 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.
| Requirement | What it asks for | Mechanism that satisfies it | Evidence an assessor wants |
|---|---|---|---|
| 6.4.3 authorisation | Confirmation that every script on the page is approved | A manifest under version control, changed only by reviewed pull request | The manifest plus its approval history and named approver |
| 6.4.3 integrity | Assurance the script is what it is meant to be | SRI where the vendor allows pinning, recorded hashes and monitoring where it does not | Stored hashes and the alert history from the monitor |
| 6.4.3 inventory | Written justification for each script’s business need | A purpose field per entry, recertified on a fixed cycle | The inventory with review dates and reviewer names |
| 11.6.1 change detection | Alerting on unauthorised page and header modification | A scheduled headless capture diffed against the manifest | Job 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 tag | Server-side forwarding | |
|---|---|---|
| What the vendor’s code can read | Everything in the DOM, the URL, and storage on that origin | Only the fields your code chose to send |
| Who controls the payload | The vendor, through configuration you cannot audit at runtime | You, in a reviewed code path with tests |
| Consent enforcement point | Every tag, by convention | One server-side check before the request is made |
| Effect of ad blockers and browser privacy defaults | Frequently blocked or truncated | Largely unaffected |
| Typical failure mode | Silent over-collection | Silent under-collection |
| CSP implications | Vendor host needed in script-src and connect-src | Neither directive needs the vendor |
| Cost to change | Minutes in a tag manager | Weeks 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
})
}
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.
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.