Third-Party Tag & Analytics Integrity

Permalink to "Third-Party Tag & Analytics Integrity"

Third-party tags are the part of Asset Hashing & Dynamic Script Injection where the standard advice stops working. Everywhere else, the recipe is simple: build the asset, hash the asset, ship the hash. A tag manager container, a product analytics loader, a consent banner or a session-replay agent breaks that recipe at the first step, because you never build the asset and the vendor rebuilds it whenever they like — often several times a week, sometimes several times a day, always at the same URL.

The failure is not subtle. Paste a sha384- value copied from today’s copy of a vendor loader into your HTML and the tag works until the vendor’s next deploy, at which point the browser refuses the resource and your analytics, your consent gate or your fraud signal disappears without a single alert firing. Teams that hit this usually conclude that integrity checking is incompatible with vendor tags and drop the integrity attribute site-wide. That conclusion is wrong in an expensive way: it treats an unpinnable payload as an unmonitorable one.

The risk this leaves behind is the reason Magecart-style attacks keep succeeding. A vendor tag runs with exactly the privileges of your own code. It reads the DOM, it reads and writes cookies scoped to your registrable domain, it can attach listeners to a payment form and it can post whatever it collects to any host your policy allows. A compromise inside the vendor’s build pipeline is therefore a compromise of your checkout page, and the historical incidents follow that shape precisely: the 2018 Ticketmaster UK breach reached the checkout through a chat vendor’s script, not through Ticketmaster’s own code. Nothing about the attack required a flaw in the first-party application.

This page covers what actually holds: classifying tags by mutability, self-hosting a pinned copy behind a review-and-bump process, sandboxing tags that do not need first-party access, constraining the rest with CSP and Trusted Types, running a change-detection monitor over the vendor bundle, and pushing the residue into the contract. It closes with the PCI DSS v4.0.1 obligations that apply to payment pages and an explicit statement of what none of this stops.

Prerequisites

Permalink to "Prerequisites"

Conceptual Foundation: A Stable URL Is Not a Stable Payload

Permalink to "Conceptual Foundation: A Stable URL Is Not a Stable Payload"

The Subresource Integrity specification defines integrity as a set of cryptographic digests attached to a fetch. The browser retrieves the response body, computes the digest with the strongest algorithm present in the attribute, and compares. On mismatch the resource is treated as a network error: the script never parses, the element fires an error event, and the page continues without it. There is no partial trust and no negotiation. That binary behaviour is exactly what makes SRI valuable for a versioned library artifact and exactly what makes it unusable for a payload the vendor intends to change.

Vendor tags are built around continuous delivery on a stable entry point. The URL is the contract; the bytes are an implementation detail the vendor reserves the right to change. Three distinct mechanisms drive that churn:

Configuration compiled into the bundle. Tag managers and consent platforms compile the account’s current configuration into the served JavaScript. Every publish by a marketer — a new trigger, a changed consent category, an added conversion event — produces a new file at the same URL. Nobody in engineering is in the loop, which is the entire point of the product.

Continuous vendor releases. Analytics and session-replay agents ship fixes and feature flags on the vendor’s own cadence, often behind percentage rollouts. Two of your users can receive different bytes from the same URL on the same day.

Per-request variation. Some vendors vary the response by geography, by account tier, by User-Agent, or embed a build identifier or timestamp in the body. In that case no single hash is even correct at a single point in time.

Only the last of these is fundamentally hopeless. The first two produce a payload that is stable between vendor deploys, which is enough to pin — provided the copy you pin is a copy you serve. That is the pivot the rest of this page turns on: you cannot pin someone else’s mutable URL, but you can pin a snapshot of it that you control, and you can watch the URL you are no longer loading from.

One stable URL, a payload that changes A first-party checkout page requests a vendor loader at a URL that never changes; the vendor CDN returns a bundle that is rebuilt weekly, and that bundle then injects a session-replay tag, an experiment tag and an ad conversion pixel at runtime, none of which can carry an integrity attribute. One stable URL, a payload that changes First-party page checkout.shop.example requests Vendor loader URL stable for years responds Vendor bundle rebuilt on publish new digest each time each tag injected at runtime Session-replay tag reads the live DOM A/B experiment tag rewrites the page Ad conversion pixel sends order values Only the first hop can carry an integrity attribute, and only if you pin the bytes.

Step 1 — Inventory Every Tag and Classify Its Mutability

Permalink to "Step 1 — Inventory Every Tag and Classify Its Mutability"

You cannot pin, sandbox or monitor what you have not written down, and the list in the tag manager UI is never the real list, because containers load containers. Build the inventory from what the browser actually fetched, on a page that matters, after the page has settled.

// DevTools console, on a real page, ~10s after load.
// copy() is a DevTools utility; it puts the JSON on your clipboard.
copy(
  performance.getEntriesByType('resource')
    .filter(e => ['script', 'img', 'beacon', 'fetch', 'xmlhttprequest'].includes(e.initiatorType))
    .filter(e => new URL(e.name).origin !== location.origin)
    .map(e => ({
      url: e.name.split('?')[0],
      origin: new URL(e.name).origin,
      type: e.initiatorType,
      bytes: e.transferSize
    }))
);

Verification signal: the array you paste out should contain origins that appear in no ticket, no contract and no architecture diagram. If it does not, either the page is unusually clean or the resource timing buffer filled up — it holds 250 entries by default, so call performance.setResourceTimingBufferSize(1000) before the page loads when you audit a heavy page. Cross-origin entries also report transferSize as 0 unless the vendor sends Timing-Allow-Origin, which is itself a useful signal about how much telemetry that vendor is willing to give back.

With the raw list in hand, classify each entry by whether an integrity attribute is even physically applicable, and choose the control accordingly. The honest answer for most rows is no, and the value of the table is that it forces a named control into the gap instead of a shrug.

Tag type Can SRI apply? Practical control Residual risk
Tag manager container (GTM, Tealium, Adobe Launch) No — rebuilt on every publish by non-engineers Publish approvals plus a self-hosted or server-side container An approved publisher can still add a hostile tag
Product analytics loader Not at the vendor CDN; yes on a self-hosted mirror Mirror, pin, bump on review Feature and fix drift while the pin is stale
Consent management platform Rarely — account config ships inside the bundle Self-host the library, load the config as data The config channel becomes the mutable surface
Session replay agent No Pin plus monitor; enforce vendor-side masking rules Full DOM read access is the product
A/B testing and personalization No; the anti-flicker snippet is usually inline Self-host the loader, hash the inline snippet in CSP Experiment payloads still arrive at runtime
Ad or conversion pixel (<img>) No — images cannot carry integrity img-src and connect-src narrowing, or move into a sandboxed frame Data exfiltration through URL parameters
Chat, support and survey widgets Not at the CDN Sandboxed iframe on a separate origin Bridge misuse if the message contract is loose
Hosted payment fields Not applicable; the vendor owns the frame Origin boundary plus frame-src narrowing The surrounding page can still be compromised
Pinned library at a versioned URL Yes integrity with crossorigin="anonymous" Vendor repoints the version; the hash blocks it

Step 2 — Self-Host a Pinned Copy with a Review-and-Bump Process

Permalink to "Step 2 — Self-Host a Pinned Copy with a Review-and-Bump Process"

Self-hosting converts an unpinnable third-party URL into an ordinary first-party build artifact. The vendor’s bytes become an input to your release process rather than a live dependency of your page, and everything the rest of the site already does — hashing, cache busting, deploy verification — starts applying to them.

The mechanism is a scheduled mirror job that fetches the upstream file, hashes it, and refuses to update the checked-in copy silently.

#!/usr/bin/env bash
# scripts/mirror-vendor-tag.sh — fetch, hash and stage a pinned vendor bundle.
set -euo pipefail

SRC="https://cdn.vendor.example/analytics/v3/loader.js"
DEST="static/vendor/analytics-loader.js"
PIN="static/vendor/analytics-loader.sha384"

curl -fsSL --proto '=https' --tlsv1.2 -o "${DEST}.new" "$SRC"

NEW="sha384-$(openssl dgst -sha384 -binary "${DEST}.new" | openssl base64 -A)"
OLD="$(head -n1 "$PIN" 2>/dev/null || printf 'none')"

if [ "$NEW" = "$OLD" ]; then
  rm -f "${DEST}.new"
  printf 'unchanged: %s\n' "$NEW"
  exit 0
fi

mv "${DEST}.new" "$DEST"
printf '%s\n' "$NEW" > "$PIN"
printf 'CHANGED\n  was: %s\n  now: %s\n' "$OLD" "$NEW"
exit 3

Verification signal: exit code 0 on an unchanged upstream, exit code 3 with both digests printed when the vendor has shipped. Wire exit 3 to open a pull request rather than to commit, so the diff of a minified bundle is at least glanced at by a human. Reviewing minified vendor code line by line is theatre; reviewing it with a diff filter for fetch(, XMLHttpRequest, document.cookie, addEventListener('submit' and new hostnames is not.

The page then loads a first-party file with a digest that is stable until you decide otherwise:

<script
  src="/vendor/analytics-loader.js?v=2026-08-05"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
  defer></script>

The crossorigin="anonymous" attribute is mandatory whenever integrity is present on a cross-origin fetch and harmless on a same-origin one, so keep it on every tag without exception rather than maintaining a rule about when to omit it. Serve the mirrored file from the same origin as the page or from an asset host you control; if that host sits behind a proxy that rewrites JavaScript, read SRI with Cloudflare and Fastly Edge Transforms first, because an edge minifier will break a pinned hash exactly as reliably as an attacker would.

Two limits are worth stating plainly. Self-hosting only pins the first hop: a loader that fetches its real payload from the vendor at runtime is still fetching mutable code, and the pin proves only that the loader itself is the one you reviewed. And a stale pin is a real cost — a consent library that is four months behind may be missing a regulatory change. Set a maximum pin age, treat an expired pin as a defect with an owner, and do not let “we pinned it” become “we froze it and forgot”.

Step 3 — Isolate the Tag in a Sandboxed Iframe

Permalink to "Step 3 — Isolate the Tag in a Sandboxed Iframe"

Pinning answers which bytes run. Sandboxing answers what those bytes may touch, and for a tag that has no legitimate need to read your DOM — most ad pixels, most conversion tracking, many survey and chat widgets — it is the stronger control of the two.

Host a minimal runner document on a separate registrable domain, not a subdomain of your site, so that cookies scoped to your domain are out of reach even if the frame escapes its sandbox. The runner loads the vendor tag normally; the tag believes it is on an ordinary page.

<!-- https://tags-shop.example/tag-runner.html — separate registrable domain -->
<meta charset="utf-8">
<title>Tag runner</title>
<script src="https://cdn.vendor.example/analytics/v3/loader.js" crossorigin="anonymous" async></script>
<script type="module" src="/bridge.js"></script>

The first-party page embeds it with a deliberately minimal sandbox token set:

<iframe
  src="https://tags-shop.example/tag-runner.html"
  sandbox="allow-scripts"
  referrerpolicy="no-referrer"
  title="Analytics runner"
  width="0" height="0" style="display:none;border:0"></iframe>

sandbox="allow-scripts" without allow-same-origin gives the document an opaque origin: no access to your DOM, no cookies, no localStorage, no same-origin network credentials. Never add allow-same-origin alongside allow-scripts for a document you do not control — that combination lets the framed page remove its own sandbox attribute from the embedding markup in some navigation flows, and it restores the cookie access you were trying to remove.

Events cross the boundary through an explicit, typed message contract rather than through shared globals:

// First-party page: forward only an allow-list of named events.
const frame = document.querySelector('iframe[title="Analytics runner"]');
const ALLOWED = new Set(['page_view', 'add_to_cart', 'checkout_step']);

export function track(name, props = {}) {
  if (!ALLOWED.has(name)) return;
  // The frame has an opaque origin, so targetOrigin must be '*'.
  // The frame is the side that validates event.origin.
  frame.contentWindow.postMessage({ type: 'tag', name, props }, '*');
}
// bridge.js, inside the sandboxed frame.
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://shop.example') return;
  const msg = event.data;
  if (!msg || msg.type !== 'tag' || typeof msg.name !== 'string') return;
  window.vendorSdk?.track(msg.name, msg.props ?? {});
});

Verification signal: in the frame’s DevTools context, document.cookie returns an empty string and localStorage throws a SecurityError; from the parent, frame.contentWindow.document throws. If any of those succeed, allow-same-origin has crept back in. Note the asymmetry in the origin checks — messages sent from an opaque-origin frame arrive at the parent with event.origin === "null", so a parent listener must compare event.source against the frame’s contentWindow instead of trusting the origin string.

The cost is real and should be priced before you commit: a sandboxed tag loses first-party cookies, so vendor-side user stitching degrades, and any product whose value comes from reading the page — session replay, on-page personalization, form analytics — cannot be sandboxed at all without becoming useless. Those tags stay on the page and belong to the next two steps.

Step 4 — Constrain the Tag with CSP and Trusted Types

Permalink to "Step 4 — Constrain the Tag with CSP and Trusted Types"

For every tag that must stay on the page, Content Security Policy answers a different question from SRI: not “are these the bytes I approved” but “may code from this origin run at all, and where may it send what it collects”. The two controls are complementary and neither substitutes for the other.

# nginx: one header, three separate jobs — origins, destinations, DOM sinks.
add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' 'nonce-$request_id' https://cdn.vendor.example;
  connect-src 'self' https://beacon.vendor.example;
  img-src 'self' data: https://beacon.vendor.example;
  frame-src https://tags-shop.example;
  require-trusted-types-for 'script';
  trusted-types shop-tags dompurify;
  base-uri 'none';
  object-src 'none';
  report-uri /csp-report" always;

The connect-src line is the one that matters most for skimming. A tag that has been subverted still has to send the card data somewhere, and a connect-src restricted to the vendor’s own beacon host makes fetch() to an attacker-controlled collector fail. It is not airtight — navigator.sendBeacon() and image loads are governed by connect-src and img-src respectively, and any allowed host is a potential relay — but it converts silent exfiltration into a violation report.

Resist the temptation to reach for 'strict-dynamic' as a shortcut here. It makes browsers ignore the host allow-list entirely and trust anything a trusted script injects, which is precisely the behaviour of a tag loader; you gain convenience and lose the origin control you were buying. If you need nonces for your own inline bootstrapping, generate them properly per response as described in Generating Per-Request CSP Nonces — nginx’s $request_id is convenient but is a request identifier, not a documented cryptographic random source.

require-trusted-types-for 'script' closes the DOM-injection half of the problem, forcing every assignment to innerHTML, script.src and the other injection sinks through a named policy you wrote. That is a strong control against a tag that has been given a hostile payload, and it is also the single most disruptive header you can add to a page full of vendor code, because most tags assign strings to sinks constantly. Roll it out with Content-Security-Policy-Report-Only first, read the violations, and write the policy against real data — Trusted Types & DOM XSS Prevention covers the policy authoring, and Enforcing require-trusted-types-for script covers the enforcement switch. Browser support is the honest caveat: Chromium has shipped Trusted Types for years, while Firefox and Safari support has been landing more recently, so treat it as a defence that covers a large share of your traffic rather than all of it, and check current support before you rely on it alone.

One further note on the older require-sri-for directive, which would have let a policy demand that all scripts carry integrity metadata: it was specified but never reached stable cross-browser support, and it is not something to design a control around today. Combining require-sri-for with CSP covers the current state and the enforcement patterns that stand in for it.

Step 5 — Run a Change-Detection Monitor Over the Vendor Bundle

Permalink to "Step 5 — Run a Change-Detection Monitor Over the Vendor Bundle"

Pinning stops a changed bundle from reaching your users. It does not tell you that the bundle changed, which is the signal you actually want — both because a pin that never moves rots, and because an unannounced change is the earliest observable trace of a vendor compromise. A monitor is a scheduled job that fetches each vendor URL, hashes it, compares against a stored baseline and raises a ticket on any difference.

// scripts/watch-vendor-tags.mjs — Node 20+ (global fetch, node:crypto).
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';

const TARGETS = [
  { id: 'analytics-loader', url: 'https://cdn.vendor.example/analytics/v3/loader.js' },
  { id: 'consent-cmp',      url: 'https://cdn.cmp.example/cmp.min.js' }
];

const baseline = JSON.parse(await fs.readFile('./tag-baseline.json', 'utf8'));
const report = [];

for (const target of TARGETS) {
  const res = await fetch(target.url, {
    redirect: 'follow',
    headers: {
      'cache-control': 'no-cache',
      'accept-language': 'en-GB,en;q=0.9',
      'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
                    '(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
    }
  });

  if (!res.ok) {
    report.push({ id: target.id, status: 'fetch_failed', code: res.status });
    continue;
  }

  const body = Buffer.from(await res.arrayBuffer());
  const digest = 'sha384-' + createHash('sha384').update(body).digest('base64');

  report.push({
    id: target.id,
    digest,
    bytes: body.length,
    etag: res.headers.get('etag'),
    lastModified: res.headers.get('last-modified'),
    changed: baseline[target.id] !== undefined && baseline[target.id] !== digest
  });
}

await fs.writeFile('./tag-report.json', JSON.stringify(report, null, 2));
const changed = report.filter(r => r.changed);
console.log(`${report.length} tags checked, ${changed.length} changed`);
if (changed.length > 0) process.exit(3);

Verification signal: a steady stream of 2 tags checked, 0 changed between vendor releases, and exit code 3 with the offending digests within an hour of a release. If the job reports a change on every single run, the bundle carries per-request variation — a build stamp, a request identifier, a geo-targeted payload — and hashing the whole body is the wrong test for that target. Fall back to hashing a normalised form (strip the volatile line), to watching content-length and etag, or to diffing the abstract shape of the file rather than its bytes.

Change-detection cycle for a vendor bundle A sequence diagram with four participants: an hourly monitor job requests the bundle from the vendor CDN, receives the body, compares its SHA-384 digest with a signed baseline store, learns that the digest differs, opens a review ticket with the on-call security rota, and finally receives approval to bump the pinned copy. Monitor job hourly cron Vendor CDN tag bundle URL Hash baseline signed store Security review on-call rota 1 · GET bundle 2 · 200 and body 3 · compare SHA-384 4 · differs from baseline 5 · open ticket, attach diff 6 · approve, bump the pin

Schedule it where your other automation lives, and make the escalation path part of the job rather than a convention:

# .github/workflows/vendor-tag-watch.yml
name: Vendor tag change detection
on:
  schedule:
    - cron: '17 * * * *'
  workflow_dispatch:

jobs:
  watch:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: Hash every vendor tag
        id: watch
        run: node scripts/watch-vendor-tags.mjs
        continue-on-error: true
      - name: Open a review ticket on change
        if: steps.watch.outcome == 'failure'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh issue create \
            --title "Vendor tag bundle changed at $(date -u +%FT%TZ)" \
            --label "supply-chain,needs-review" \
            --body-file tag-report.json

Run the monitor from more than one region and with more than one User-Agent if the vendor is known to vary its response, and store the baseline somewhere an attacker who reaches your CI cannot quietly rewrite. A signed baseline file in a separate repository with its own access control is the cheap version of that; an append-only object store with versioning is the better one.

Step 6 — Push the Remainder into the Contract

Permalink to "Step 6 — Push the Remainder into the Contract"

Some of the residual risk is not solvable with headers. A vendor who rewrites their loader without notice, ships a subprocessor change silently, or takes nine days to confirm an incident is a risk you can only manage through the commercial relationship, and the time to ask is during procurement rather than during an incident.

Four clauses do most of the work. A change-notification window — a commitment to publish release notes and a minimum notice period before behavioural changes to the loader — makes your monitor’s alerts interpretable instead of ambiguous. A subprocessor and CDN disclosure obligation tells you which additional origins can appear in your inventory without warning. A breach-notification SLA measured in hours matters far more than one measured in days when the payload is running on a checkout page. And a right to obtain a versioned, pinnable artifact — a copy of the bundle at a stable, immutable URL — is the clause that converts the whole problem into the ordinary case; more vendors will agree to it than you expect, because enterprise customers keep asking. Feed the answers into your vendor register; Scoring Third-Party Script Risk covers turning them into a comparable score.

How the Layers Compose

Permalink to "How the Layers Compose"

No single control on this page is sufficient, and each one’s weakness is the next one’s purpose. Read the following as a stack: the left column is the control, the middle column is the class of attack it removes, and the right column is what remains and therefore justifies the row below it.

Layered controls for a mutable vendor tag A five-row table-style diagram pairing each control layer — a self-hosted pinned copy, a sandboxed iframe, CSP with Trusted Types, scheduled hash change detection, and contractual controls — with the attack class it removes and the residual risk that remains after it. Control layer What it stops Residual risk Self-hosted copy, pinned by hash a silent byte swap at the vendor CDN a stale tag until you bump the pin Sandboxed iframe, separate origin reads of your DOM and your cookies abuse of a loose message bridge CSP allow-list plus Trusted Types new origins and unsafe DOM sinks hostile code from an allowed origin Scheduled hash change detection changes shipped without any notice payloads targeted by region or user Contract, notice period, audit right surprise changes at the policy level nothing at all during an incident Each residual risk in the right column is the reason the row below it exists.

Configuration Reference

Permalink to "Configuration Reference"
Setting Valid values Recommended for vendor tags Why
integrity algorithm sha256, sha384, sha512 sha384 Best margin-to-length trade-off; the browser uses the strongest algorithm present
crossorigin anonymous, use-credentials anonymous Required whenever integrity is on a cross-origin fetch; use-credentials sends cookies to the vendor
<iframe sandbox> allow-scripts, allow-same-origin, allow-forms, allow-popups, allow-modals, and others allow-scripts alone Any additional token gives the vendor back a capability you removed
referrerpolicy no-referrer, origin, strict-origin-when-cross-origin, others no-referrer on sandboxed frames Stops full checkout URLs, which often carry order identifiers, from reaching the vendor
script-src origin list, 'nonce-…', 'sha384-…', 'strict-dynamic' explicit origins plus a nonce 'strict-dynamic' discards the origin list, which is the control you want here
connect-src origin list the vendor beacon host only The narrowest practical constraint on exfiltration
require-trusted-types-for 'script' 'script', after a report-only phase Blocks string-to-sink assignment across every tag on the page
trusted-types policy names, 'none', 'allow-duplicates' named policies, no 'allow-duplicates' Duplicate policy names let a tag redefine a policy you wrote
Monitor interval any cron expression hourly on revenue pages PCI DSS v4.0.1 sets the floor at seven days; hourly bounds the exposure window
Maximum pin age days 30 for consent and security-relevant tags A frozen pin becomes its own compliance and correctness risk

PCI DSS v4.0.1 on Payment Pages

Permalink to "PCI DSS v4.0.1 on Payment Pages"

If any page in scope accepts cardholder data, two requirements apply directly and both became mandatory on 31 March 2025.

Requirement 6.4.3 covers management of payment-page scripts and asks for three things: a method to confirm that each script is authorized, a method to assure the integrity of each script, and an inventory of all scripts with a written business or technical justification for each. Notice what the wording does and does not say. It does not mandate SRI. A self-hosted pinned copy with a documented review-and-bump process is an integrity assurance method; a monitored digest with a documented alert path is another; a signed vendor manifest is a third. What is not acceptable is an unpinned, unmonitored script with no owner, which is the default state of a tag manager container.

Requirement 11.6.1 is the one teams miss, because it is not about scripts at all. It calls for a change- and tamper-detection mechanism that alerts personnel to unauthorized modification of the HTTP headers and the content of payment pages as received by the consumer browser, evaluated at least once every seven days or at a frequency justified by a targeted risk analysis under requirement 12.3.1. A server-side monitor that hashes the vendor bundle from your CI runner does not satisfy this on its own: your runner is not a consumer browser, and it does not see a payload that a vendor serves only to residential addresses in one country. Meeting 11.6.1 properly means collecting evidence from real sessions — a client-side reporting endpoint, a synthetic browser check from consumer-like network positions, or both.

The two requirements work best when the same digest is the artifact behind them. Your inventory row names the tag, its justification and its owner; the pin is the integrity method for 6.4.3; the monitor plus browser-side telemetry is the detection mechanism for 11.6.1; and the ticket the monitor opens is the audit evidence that the mechanism fires and gets handled. Mapping CVEs to PCI DSS 6.4.3 covers the inventory and justification side in detail.

Integration with Adjacent Tooling

Permalink to "Integration with Adjacent Tooling"

Release gates. The pinned copy and its digest belong in the same manifest as the rest of your build output, so that a deploy which silently changed a vendor file fails the same check any other drift would. Verifying Deployed Assets Against a Hash Manifest covers the post-deploy verification job, and the broader gating patterns live under CI/CD Integrity Gates.

Browser-side telemetry. The monitor watches the vendor from your infrastructure; violation reports watch the same vendor from your users’ machines, which is what 11.6.1 actually asks for. Collect securitypolicyviolation events and integrity failures together and alert on both — Alerting on SRI Failures from CSP Reports covers the pipeline. A capturing error listener registered before your tags load is the reliable way to catch a blocked script, since integrity failures surface as an error event on the element:

window.addEventListener('error', (e) => {
  const el = e.target;
  if (el instanceof HTMLScriptElement || el instanceof HTMLLinkElement) {
    navigator.sendBeacon('/telemetry/asset-error', JSON.stringify({
      url: el.src || el.href,
      integrity: el.integrity || null,
      page: location.pathname
    }));
  }
}, true); // capture phase — resource errors do not bubble

Fallback behaviour. When a pinned tag does fail to load, decide deliberately whether the page degrades or retries; Serving Local Fallback Bundles covers the safe shape of that logic, and the layered end state is described in Deploying Defense-in-Depth Script Controls.

Troubleshooting

Permalink to "Troubleshooting"

Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.vendor.example/analytics/v3/loader.js' with computed SHA-384 integrity 'K2f…'. The resource has been blocked.

The vendor deployed. This is the expected outcome of pinning a URL you do not control, not a bug in the hash. Do not paste the computed digest from the console into your HTML as a fix — that is trusting whatever bytes arrived, which defeats the check entirely. Move the file to your own origin and pin the copy you reviewed, or drop the attribute for that URL and rely on monitoring instead.

Access to script at 'https://cdn.vendor.example/tag.js' from origin 'https://shop.example' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Adding integrity to a cross-origin <script> requires crossorigin="anonymous", which turns the request into a CORS request, and many tag endpoints do not send the response header. The hash is never even compared. Either the vendor adds the header or the tag cannot carry integrity metadata from their origin — which is another argument for hosting the copy yourself.

Blocked script execution in 'https://tags-shop.example/tag-runner.html' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.

The sandbox attribute is present but empty or missing allow-scripts. An empty sandbox="" applies every restriction. Add allow-scripts and nothing else; if the tag still fails, read the next entry before reaching for allow-same-origin.

Failed to read the 'localStorage' property from 'Window': Access is denied for this document.

Expected inside a sandboxed frame with an opaque origin, and thrown by many vendor SDKs on init. Check whether the vendor supports a cookie-less or memory-only storage mode; several analytics SDKs have one. If storage is genuinely required, this tag cannot be sandboxed this way — give it a dedicated origin without the sandbox attribute instead, which still separates it from your first-party cookies.

This document requires 'TrustedScriptURL' assignment.

A tag is assigning a plain string to script.src or a similar sink while require-trusted-types-for 'script' is enforcing. Reproduce under Content-Security-Policy-Report-Only first, then either write a policy that validates the vendor’s URLs against an allow-list, or ask the vendor for a Trusted Types compatible build — most large vendors now have one.

The monitor reports a changed digest on every run, with no vendor release.

The bundle carries per-request variation: an embedded timestamp, a request identifier, a rolled-out flag or a geo-specific payload. Confirm it by fetching twice within a second and diffing. When it is confirmed, stop hashing the whole body for that target; normalise the volatile region out, watch content-length and etag for step changes instead, and record in the inventory that this tag’s integrity assurance is behavioural rather than byte-exact.

Security Boundary Note

Permalink to "Security Boundary Note"

Everything on this page constrains which code from a vendor runs, where it runs, and what it may reach. It does not address:

  • A malicious change that the vendor publishes deliberately and announces. A pin and a monitor detect the change and give a human the chance to say no. If the reviewer approves a diff they did not understand, every control here has functioned correctly and the outcome is still bad.
  • A payload served only to your users. Targeted delivery — by geography, by user agent, by cookie, or to a fraction of sessions — is invisible to a monitor that fetches from a CI runner. Only browser-side telemetry from real sessions closes that gap, which is precisely why PCI DSS 11.6.1 is worded as it is.
  • Anything the tag was always allowed to do. A session-replay agent reading a card field is not a compromise; it is the product working as configured. Field masking is a vendor configuration problem, and no header on your side enforces it.
  • The first-party code path. A pinned, sandboxed, monitored vendor tag does nothing about an attacker who has write access to your own build. Provenance and pinning of your own dependencies is a separate discipline, and Third-Party Risk Assessment is where the vendor-facing half of it is tracked.
  • Data already sent. Integrity controls are preventative and detective, never restorative. Once a beacon carrying form data has left the browser, the only remaining levers are incident response and notification.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why can I not simply add an integrity attribute to the tag manager snippet?

Because the container script is regenerated every time anyone publishes a change, and the URL stays the same while the bytes move. An integrity attribute pins bytes, not behaviour, so the first publish after you compute the hash blocks the tag entirely. The tag is not broken; the pairing of a mutable payload with a static hash is.

Does self-hosting a vendor tag break its functionality?

Sometimes. Loaders that fetch further code, configuration or experiment definitions at runtime keep working, because only the first hop moved to your origin. Tags that hard-code their own CDN path, sign requests against a Referer or Origin header, or ship a per-account build tend to fail. Test the self-hosted copy against the vendor’s own debug tooling before you cut over.

Is a sandboxed iframe enough to satisfy PCI DSS v4.0.1 requirement 6.4.3?

Not on its own. Moving a tag off the payment page removes it from the 6.4.3 scope only if the payment page genuinely no longer loads or executes it. If the iframe is embedded in the payment page, the script is still loaded in the consumer browser in that page context, so you still owe an inventory entry, a written justification and an integrity assurance method.

How often should the change-detection monitor run?

Hourly is a reasonable default for tags on revenue-carrying pages, because a skimmer that lives for six hours is materially cheaper than one that lives for seven days. PCI DSS v4.0.1 requirement 11.6.1 sets the floor at least once every seven days, or a different frequency justified by a targeted risk analysis under requirement 12.3.1.

Does a strict CSP with strict-dynamic replace SRI for third-party tags?

No, and it can weaken the origin control. The strict-dynamic keyword makes browsers ignore host allow-lists and trust any script injected by an already-trusted script, which is exactly what a tag loader does. CSP then answers who may load code, never which bytes those are. Keep both: CSP for provenance, a pinned hash or a monitor for content.

What should happen when the monitor flags a changed bundle overnight?

Nothing should break automatically. The pinned copy your page serves is unchanged, so the site keeps working while a human diffs the new bundle in the morning. Reserve paging on-call for two cases: the vendor bundle changed and the vendor published no release note, or the diff touches network calls, cookie access or form handling.

Permalink to "Related"

Articles in This Topic

Applying SRI to Google Tag Manager
Sandboxing Analytics Scripts with Iframes
Self-Hosting Third-Party Scripts
Back to Asset Hashing & Dynamic Script Injection