Applying SRI to Google Tag Manager

Permalink to "Applying SRI to Google Tag Manager"

Part of Third-Party Tag & Analytics Integrity, this page starts from an uncomfortable fact — the stock Google Tag Manager loader cannot carry a working integrity value — and then works through the controls that genuinely do reduce the risk of a tag container executing code you never reviewed.

Quick Reference

Permalink to "Quick Reference"
Element Value Effect
Hosted loader URL https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX Per-container JavaScript, regenerated on every container publish
integrity on that URL not viable Digest goes stale at the next publish; the browser blocks the load
Self-hosted snapshot integrity="sha384-…" + crossorigin="anonymous" Integrity enforceable; console publishes no longer reach the page
Server container endpoint https://sgtm.example.com/gtm.js?id=GTM-XXXXXXX First-party origin you control; digest still not stable
Environment pinning &gtm_auth=…&gtm_preview=env-3&gtm_cookies_win=x Serves the version bound to that environment instead of Live
CSP script-src 'self' https://www.googletagmanager.com Limits which origins tags may pull further code from
require-trusted-types-for 'script' response header directive Blocks unsafe DOM sink writes; Custom HTML tags commonly violate it
noscript iframe (ns.html) no integrity support <iframe> has no integrity attribute at all

The mental model: gtm.js has no stable digest

Permalink to "The mental model: gtm.js has no stable digest"

Subresource Integrity is a promise about bytes. You record a digest of a file, the browser fetches that file, recomputes the digest, and refuses to execute anything that does not match. The promise only holds if the bytes are immutable for the lifetime of the reference — which is why SRI pairs so naturally with versioned CDN paths and content-hashed build output.

Google Tag Manager violates that precondition by design. gtm.js is not a library release; it is a compiled artifact of your container. It bundles the runtime, your tag definitions, your triggers, your variables and your consent configuration into one response, keyed off the id=GTM-XXXXXXX query parameter. When somebody in the marketing team publishes a new container version, Google regenerates that artifact, and the bytes at the same URL are different within seconds. Google also ships changes to the runtime itself on its own schedule, independent of anything you do. There is no published, version-locked URL for the hosted loader that would let you pin one specific build.

So a digest recorded on Monday describes a file that may not exist on Tuesday. That is not a hypothetical drift problem you can paper over with a nightly job — it is the normal, intended workflow of the product. Any static integrity value on the hosted loader is a scheduled outage: not a soft failure, but a hard block that takes every tag in the container down with it, including consent management and, in many deployments, the analytics that would have told you something broke.

Why the gtm.js digest moves The page loader requests gtm.js from googletagmanager.com; a container publish rewrites the bundle served at that same URL, so the digest recorded on day one no longer matches the bytes returned after the publish and the integrity check fails. container publish rewrites the bundle GTM loader snippet in your page www.googletagmanager.com /gtm.js?id=GTM-XXXXXXX bytes you hashed on day 1 digest sha384-K7vR2q... integrity check passes bytes after the next publish digest sha384-9dTm4x... every tag stops firing

What happens if you add integrity anyway

Permalink to "What happens if you add integrity anyway"

The stock snippet does not expose an attribute you can edit — it builds the element in JavaScript, so people usually reach for the injected-script form and set the property directly, as described in Adding Integrity to Runtime-Injected Scripts:

<!-- Do not ship this. It works exactly until the next container publish. -->
<script>
(function (w, d, s, l, i, h) {
  w[l] = w[l] || [];
  w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
  var f = d.getElementsByTagName(s)[0], j = d.createElement(s);
  j.async = true;
  j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + '&l=' + l;
  j.integrity = h;              // <- the value that will go stale
  j.crossOrigin = 'anonymous';  // <- required whenever integrity is set
  f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXXXXXX', 'sha384-…');
</script>

Two distinct failures are waiting here, and it is worth knowing which one you are looking at. If the response does not satisfy the CORS preconditions that integrity checking requires, Chromium reports:

Subresource Integrity: The resource 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX&l=dataLayer'
has an integrity attribute, but the resource requires the request to be CORS enabled to check the
integrity, and it is not. The resource has been blocked because the integrity cannot be enforced.

That one is a configuration mistake, and How CORS and crossorigin Affect SRI explains the request-mode rules behind it. The second failure is the structural one, and no configuration fixes it:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX&l=dataLayer' with computed SHA-384 integrity
'9dTm4x…'. The resource has been blocked.

Firefox words it differently — None of the “sha384” hashes in the integrity attribute match the content of the subresource — but the outcome is identical. The script is discarded, dataLayer fills with events nobody consumes, and consent, conversion and error-reporting tags all go dark at once. Debugging SRI Hash Mismatch Errors walks through confirming which of the two you have hit.

Canonical example: a self-hosted snapshot with a re-hash workflow

Permalink to "Canonical example: a self-hosted snapshot with a re-hash workflow"

If you want a real integrity attribute on tag-manager code, you have to own the bytes. Fetch the generated loader once, store it at an immutable path, hash it, and reference that path. The trade is explicit and permanent: publishing in the console no longer reaches production until your pipeline runs again.

#!/usr/bin/env bash
# snapshot-gtm.sh — fetch, hash and stage one immutable copy of the container loader
set -euo pipefail

CONTAINER="GTM-XXXXXXX"
STAMP="$(date -u +%Y%m%d%H%M)"
OUT="dist/vendor/gtm/${CONTAINER}.${STAMP}.js"

mkdir -p "$(dirname "$OUT")"
curl -fsSL "https://www.googletagmanager.com/gtm.js?id=${CONTAINER}&l=dataLayer" -o "$OUT"

DIGEST="sha384-$(openssl dgst -sha384 -binary "$OUT" | openssl base64 -A)"
echo "path:   /vendor/gtm/${CONTAINER}.${STAMP}.js"
echo "digest: ${DIGEST}"

The hashing step is ordinary SRI arithmetic; Generating SRI Hashes with OpenSSL and shasum covers the encoding rules and the equivalent shasum invocation. Feed the two printed values into the page template. This is the loader variant where integrity is finally meaningful:

<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
</script>
<script src="/vendor/gtm/GTM-XXXXXXX.202608051200.js"
        integrity="sha384-K7vR2q0oS1pQhE0m4Wq3zZC9dY7f5tHn8Jb2Xv1LrA6uMk4sTgPnQe3wYc0iRbXd"
        crossorigin="anonymous"
        async></script>

Serving the snapshot from your own origin does not exempt you from crossorigin="anonymous"; the attribute is what puts the fetch into a CORS mode the integrity check will accept, and omitting it is the single most common reason a correct hash still blocks. If a CDN sits in front of that path, the same applies — and the response must carry Access-Control-Allow-Origin for the document’s origin.

The workflow around the snapshot matters more than the tag itself. Treat it as a loop with a defined trigger, an approval, and a deploy.

Snapshot and re-hash pipeline A five stage pipeline fetches gtm.js from Google, diffs it against the last snapshot, computes a SHA-384 digest, uploads the file to your own origin and bumps the integrity value in the page template, repeating each time the container is republished. container republished 1 2 3 4 5 fetch gtm.js from Google diff against last snapshot compute sha-384 upload to your origin bump tag integrity value between snapshots the page serves the container config you last froze

Step 2 is the one people skip and regret. Diffing the freshly fetched loader against the stored copy is what turns this from a blind mirror into a review gate: a container change that nobody told you about shows up as a diff before it reaches a user.

Variants

Permalink to "Variants"

Server-side tagging: move the endpoint to a domain you own

Permalink to "Server-side tagging: move the endpoint to a domain you own"

A Google Tag Manager server container runs on infrastructure you operate — Cloud Run, App Engine or any host that can run the image — behind a hostname such as sgtm.example.com. With the web-container client enabled, that server serves the loader itself, so the browser only ever contacts a first-party endpoint:

<script>
(function (w, d, s, l, i) {
  w[l] = w[l] || [];
  w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
  var f = d.getElementsByTagName(s)[0], j = d.createElement(s);
  j.async = true;
  j.src = 'https://sgtm.example.com/gtm.js?id=' + i + '&l=' + l;
  f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXXXXXX');
</script>

Be clear about what this buys. It collapses your script-src allow-list to one origin you control, it lets you log and inspect every byte you hand out, and it moves measurement traffic behind first-party DNS. It does not stabilise the digest — the response is still built from the container definition and still changes on publish — so no integrity attribute belongs on that tag. The value is a control point, not a hash. Server-side tagging becomes an integrity story only when you add caching and freezing on top: pin the response your server returns, and you are back to the snapshot pattern with better ergonomics.

Pin the published version with a GTM environment

Permalink to "Pin the published version with a GTM environment"

Tag Manager’s Environments feature binds a named environment to a specific container version. The Live environment tracks whatever was published last; a custom environment serves the version you attached to it until somebody deliberately re-points it. The snippet for a custom environment carries three extra parameters:

<script>
(function (w, d, s, l, i) {
  w[l] = w[l] || [];
  w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
  var f = d.getElementsByTagName(s)[0], j = d.createElement(s);
  j.async = true;
  j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i +
          '&l=' + l + '&gtm_auth=AUTH_TOKEN&gtm_preview=env-3&gtm_cookies_win=x';
  f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXXXXXX');
</script>

Pair this with container permissions: give most users Edit but reserve Publish and Approve for a named few, and make re-pointing the production environment a reviewed change with a ticket behind it. This is process control, not cryptographic control — a compromised account with Publish rights defeats it — but it removes the everyday case where an unreviewed tag lands on a checkout page an hour before a release freeze.

Constrain what tags may do with CSP and Trusted Types

Permalink to "Constrain what tags may do with CSP and Trusted Types"

Because you cannot verify the loader’s bytes, verify its behaviour instead. A script-src allow-list decides which origins any tag may pull further code from, which is the difference between a rogue tag exfiltrating card fields to an arbitrary host and a blocked request with a violation report attached. Configuring Content Security Policy with SRI covers how the two directives interact; a workable starting header for a server-side deployment looks like this:

add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://sgtm.example.com 'nonce-r4nd0m'; connect-src 'self' https://sgtm.example.com; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'" always;

require-trusted-types-for 'script' closes the DOM-sink half of the problem: assignments to innerHTML, script.src and similar sinks must go through a registered policy rather than accepting raw strings. See Enforcing require-trusted-types-for script for the policy-authoring mechanics. Two honest caveats. Trusted Types is enforced in Chromium-based browsers and has been landing in other engines more recently, so treat it as coverage for a large share of your traffic rather than a universal guarantee. And Custom HTML tags — the escape hatch that lets anyone paste arbitrary markup into a container — are exactly what a strict policy blocks. Convert them to custom templates, whose sandboxed API grants capabilities individually, including an inject_script permission with an explicit URL allow-list, so a template can only load code from hosts you approved. Roll the whole thing out with the guidance in Rolling Out a Script Policy in Report-Only Mode before you enforce anything.

Choosing between the controls

Permalink to "Choosing between the controls"

None of these options is strictly better than the others; they trade different things. The matrix below is the summary worth keeping.

Tag Manager hardening options compared A matrix comparing the standard loader, server-side tagging, a self-hosted snapshot, a pinned environment and a CSP with Trusted Types, on whether a static integrity value is possible, whether container publishes still apply immediately, and where the primary control point sits. control static integrity possible publishes apply immediately primary control point standard GTM loader snippet no yes none server-side tagging container no yes your origin self-hosted gtm.js snapshot yes no the browser pinned container environment no no GTM console CSP script-src + Trusted Types no yes the browser

Most production deployments end up combining rows two, four and five: a server container for the endpoint, a pinned environment with an approval gate for the container definition, and a policy header that constrains whatever the tags try to do. The snapshot row is reserved for pages where the compliance requirement is explicit — payment and card-entry pages, typically — and where losing same-day tag publishing is an acceptable price.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Hashing the loader does not cover what the loader loads. Even a perfectly pinned gtm.js snapshot is only the first hop. Tags inside the container fetch their own vendor scripts at runtime, and those requests carry no integrity metadata at all. The digest proves that the dispatcher is unchanged, not that the code it dispatches is. script-src is what bounds the second hop.

  • The noscript iframe cannot be protected the same way. The second half of the stock snippet embeds https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX in an <iframe>. There is no integrity attribute for iframes — the attribute applies to script and link elements — so the only controls available there are frame-src in your CSP and the sandbox attribute.

  • Omitting crossorigin="anonymous" blocks a correct hash. Whenever you do add integrity, the request must be made in a CORS mode; without the attribute the browser cannot read the response for verification and refuses the resource outright, even for a same-origin-looking CDN path. This bites hardest on the self-hosted snapshot, where developers assume first-party means exempt.

  • Preview and debug mode expects Google’s origin. GTM’s preview flow appends gtm_preview parameters and expects the loader from googletagmanager.com. A frozen self-hosted copy will not honour those parameters, so keep the hosted loader on a staging hostname and reserve the snapshot for production.

  • Query strings do not version the response. ?id=GTM-XXXXXXX&l=dataLayer selects a container, it does not select a build. Nothing you can put in that URL produces the immutability that SRI assumes, which is precisely why the snapshot has to live on infrastructure you control.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the hosted loader carries no integrity metadata

Permalink to "1. Confirm the hosted loader carries no integrity metadata"

Load a page using the stock snippet and inspect the injected element:

node -e "console.log('run in the browser console instead')"

In the browser console:

document.querySelector('script[src*="googletagmanager.com/gtm.js"]').integrity

Expected output is the empty string '', confirming the element the snippet built has no integrity metadata to enforce.

2. Confirm the deployed snapshot matches the digest in your HTML

Permalink to "2. Confirm the deployed snapshot matches the digest in your HTML"
curl -fsSL https://www.example.com/vendor/gtm/GTM-XXXXXXX.202608051200.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

Prefix the printed value with sha384- and compare it against the integrity attribute in the served page. Any difference means the deploy and the template are out of step, and the browser will block the script.

3. Confirm the CORS preconditions are satisfied

Permalink to "3. Confirm the CORS preconditions are satisfied"
curl -sI -H "Origin: https://www.example.com" \
  https://www.example.com/vendor/gtm/GTM-XXXXXXX.202608051200.js | grep -i access-control

Expected:

access-control-allow-origin: https://www.example.com

An empty result means crossorigin="anonymous" will fail the fetch before the digest is even compared.

4. Confirm drift detection fires after a container publish

Permalink to "4. Confirm drift detection fires after a container publish"

Publish a trivial change in the Tag Manager console, then re-run the snapshot script and compare digests:

curl -fsSL "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX&l=dataLayer" \
  | openssl dgst -sha384 -binary | openssl base64 -A

The value must differ from the digest of your stored snapshot. If it does not change after a publish, your fetch is being served from a cache and the review gate is not actually reading fresh bytes — add -H "Cache-Control: no-cache" and re-check.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Can the standard Google Tag Manager snippet use an integrity attribute?

No. The snippet points at gtm.js, which Google generates from your container definition and regenerates every time the container is published. A digest you record today describes bytes that stop existing the moment a colleague publishes a tag change, and the browser then blocks the script. There is no version-locked URL for the hosted loader that would make a static hash durable.

Does server-side tagging make SRI work for gtm.js?

Not on its own. A server container moves the endpoint to a domain you own, which is a real improvement for CSP, cookie scope and observability. But the JavaScript it hands back is still generated from the container definition and still changes on publish, so a static integrity value on that URL breaks for the same reason. You need a frozen snapshot for that.

What breaks when I self-host a gtm.js snapshot?

Publishing inside the Tag Manager console stops reaching your site. Every container change now needs a re-fetch, a re-hash and a deploy, so marketing loses same-day tag rollout. Preview and debug mode may also misbehave, because those flows expect the loader to come from Google’s origin with the matching query parameters.

Which error does the browser show when the hashed file changes?

Chromium logs that it failed to find a valid digest in the integrity attribute for the resource, prints the digest it computed, and states that the resource has been blocked. Firefox reports that none of the sha384 hashes in the integrity attribute match the content of the subresource. In both cases the script never executes and every tag inside the container silently stops firing.

Does Google Tag Manager run under a CSP without unsafe-inline?

The loader itself can, if you allow its origin in script-src and give the inline bootstrap a nonce or a hash. Custom HTML tags are the hard part: they inject inline script at runtime and are blocked outright under a policy with no unsafe-inline and no nonce propagation. Converting those tags to sandboxed custom templates is usually the prerequisite for a strict policy.

Permalink to "Related"

Related Articles

Sandboxing Analytics Scripts with Iframes
Self-Hosting Third-Party Scripts
Third-Party Tag & Analytics Integrity Asset Hashing & Dynamic Script…