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 | >m_auth=…>m_preview=env-3>m_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.
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.
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 + '>m_auth=AUTH_TOKEN>m_preview=env-3>m_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.
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.jssnapshot 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-srcis what bounds the second hop. -
The
noscriptiframe cannot be protected the same way. The second half of the stock snippet embedshttps://www.googletagmanager.com/ns.html?id=GTM-XXXXXXXin an<iframe>. There is nointegrityattribute for iframes — the attribute applies toscriptandlinkelements — so the only controls available there areframe-srcin your CSP and thesandboxattribute. -
Omitting
crossorigin="anonymous"blocks a correct hash. Whenever you do addintegrity, 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_previewparameters and expects the loader fromgoogletagmanager.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=dataLayerselects 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.
Related
Permalink to "Related"- Self-Hosting Third-Party Scripts — the general mirror-and-hash pattern behind the snapshot workflow, including cache headers and update cadence
- Sandboxing Analytics Scripts with Iframes — containing a tag you cannot hash by giving it its own origin and a restricted sandbox
- Detecting Changes in Third-Party Scripts — turning the diff step into a scheduled monitor that alerts on unannounced vendor changes