SEO & Digital Marketing Module 11 – Analytics and measurement
Tags, Tag Manager and a strict CSP
Send events through a data layer and Tag Manager, run GA4 under a strict Content Security Policy with a nonce, and test every change before it goes live.
What you will learn
- Send events through a data layer that a Tag Manager container reads, and check the pushes against an event plan
- Write a Content Security Policy that runs Tag Manager and GA4 with a nonce or a hash, without 'unsafe-inline' or 'unsafe-eval'
- Diagnose a blocked tag from the directive that blocked it, and fix the policy or the tag
- Test a container change in preview, publish it as a named version and roll it back when it breaks tracking
Before you start
What you’ll learn · Before you start
What you will learn
- Send events through a data layer that a Tag Manager container reads, and check the pushes against an event plan
- Write a Content Security Policy that runs Tag Manager and GA4 with a nonce or a hash, without 'unsafe-inline' or 'unsafe-eval'
- Diagnose a blocked tag from the directive that blocked it, and fix the policy or the tag
- Test a container change in preview, publish it as a named version and roll it back when it breaks tracking
Before you start
On this page
The bakery’s new Content Security Policy went live on a Monday morning, and by Wednesday GA4 had recorded no sessions at all since then, while 41 orders had arrived as usual. Nothing on the page looked broken: one directive lacked the origin GA4 sends its events to, and every request carrying them had been refused by the visitors’ own browsers.
Tags are the code that sends your measurements, and a policy is what keeps the wrong code from running. By the end of
this page you can send events through a data layer that Tag Manager reads, write a policy that lets Tag Manager and
GA4 run with a nonce instead of 'unsafe-inline', read which directive blocked a load and fix it, and change a
container safely: preview, publish as a version, roll back.
The page states facts in a data layer; Tag Manager decides which tags use them
A tag is code that sends data somewhere, such as the GA4 tag that sends the bakery’s events. It can sit in the page as the Google tag, or run inside a Tag Manager container, which Google describes as a collection of tags, triggers, variables and their settings installed on one website or app (Introduction to Tag Manager). With a container, a trigger decides when a tag fires and variables decide what it sends, so marketing can change tags without changing the site’s code.
The site and the container meet in the data layer: An object on a web page through which the site's code passes facts and events, with dataLayer.push(), to the tags of Google Tag Manager and gtag.js, so that tags read data instead of the page's layout., the object that Tag Manager and
gtag.js use “to pass information to tags” (The data layer).
The site’s code adds facts to it with dataLayer.push(), with an event key when something happens. Google’s rules
are few: declare it above the container snippet, never overwrite window.dataLayer because that wipes what is queued,
and spell dataLayer exactly, because the name is case-sensitive. Step through one add-to-cart:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The data layer exists first. The page declares window.dataLayer above the container snippet, so nothing pushed before Tag Manager loads is lost. The code adds to it with push and never assigns a new array to it, which would wipe what is queued.
- Clear, then push. Asha adds the 1 kg cake to her cart. As Google's ecommerce guide does, the code first pushes ecommerce null to clear the previous ecommerce object, then pushes the event add_to_cart with currency, value and items inside ecommerce.
- A trigger matches. In the container, a trigger waits for the custom event add_to_cart. The push's event key matches it exactly, since dataLayer and event names are case-sensitive.
- Variables read the push. The GA4 event tag reads currency, value and items through data layer variables. The site's code states the facts; the container decides which tags use them.
- The tag sends the event. The GA4 event tag fires and sends add_to_cart to the property, with the same parameter names as the event plan. A name the plan does not know, such as transactionId, would arrive under that wrong name.
- Nothing else changed. The bakery's own code sent nothing to Google; it only stated what happened. Change the tag, the trigger or the destination in the container, and that code stays as it is.
A tag can only send what the pushes contain, under the names they use. Before anyone builds the purchase tag, the
bakery compares its checkout’s pushes, written in the shape of Google’s
ecommerce guide, with the
event plan of the previous lesson. The program holds the pushes in PUSHES, in order. Run it, then press Edit, rename
transactionId to transaction_id in PUSHES and run it again:
"""Compare the dataLayer pushes of the bakery's checkout with its event plan, before any tag is set up to read them.
PUSHES holds what the checkout's code pushes, in order, in the shape Google's ecommerce guide uses for Tag Manager: an
event key, and the event's parameters inside ecommerce, cleared with {"ecommerce": null} before each push. PLAN lists
the parameters each event must carry (GA4's event reference). The bakery is fictional.
"""
import difflib
ITEMS = [{"item_id": "eggless-1kg", "price": 1200, "quantity": 1}]
PUSHES = [
{"page_type": "product", "site_section": "cakes"},
{"ecommerce": None},
{"event": "view_item", "ecommerce": {"currency": "INR", "value": 1200, "items": ITEMS}},
{"ecommerce": None},
{"event": "add_to_cart", "ecommerce": {"currency": "INR", "value": 1200, "items": ITEMS}},
{"ecommerce": None},
{"event": "begin_checkout", "ecommerce": {"currency": "INR", "value": 1200, "items": ITEMS}},
{"ecommerce": None},
{"event": "purchase",
"ecommerce": {"transactionId": "PB-10482", "currency": "INR", "value": 1200, "item_list": ITEMS}},
]
PLAN = {
"view_item": ["currency", "value", "items"],
"add_to_cart": ["currency", "value", "items"],
"begin_checkout": ["currency", "value", "items"],
"purchase": ["transaction_id", "currency", "value", "items"],
}
mismatches = 0
for number, push in enumerate(PUSHES, start=1):
if "event" not in push:
what = "clears the previous ecommerce object" if push == {"ecommerce": None} else f"sets {', '.join(push)}"
print(f"push {number}: no event, {what}")
continue
name, params = push["event"], push.get("ecommerce") or {}
if name not in PLAN:
print(f"push {number}: {name} is not in the event plan")
mismatches += 1
continue
missing = [p for p in PLAN[name] if p not in params]
unknown = [p for p in params if p not in PLAN[name]]
print(f"push {number}: {name}" + (" matches the plan" if not missing and not unknown else " does not match the plan"))
for p in unknown:
guess = difflib.get_close_matches(p, missing, n=1, cutoff=0.5)
hint = f", the plan's {guess[0]}?" if guess else ""
print(f" {p} is not a parameter of {name}{hint}")
mismatches += 1
for p in missing:
if not difflib.get_close_matches(p, unknown, n=1, cutoff=0.5):
print(f" {p} is missing")
mismatches += 1
print(f"\n{len(PUSHES)} pushes, {mismatches} parameter names to fix before the purchase tag can read them") Output
push 1: no event, sets page_type, site_section
push 2: no event, clears the previous ecommerce object
push 3: view_item matches the plan
push 4: no event, clears the previous ecommerce object
push 5: add_to_cart matches the plan
push 6: no event, clears the previous ecommerce object
push 7: begin_checkout matches the plan
push 8: no event, clears the previous ecommerce object
push 9: purchase does not match the plan
transactionId is not a parameter of purchase, the plan's transaction_id?
item_list is not a parameter of purchase, the plan's items?
9 pushes, 2 parameter names to fix before the purchase tag can read them
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 datalayer_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Three of the four events match. The purchase push names two parameters its own way, transactionId and item_list,
and a tag that reads transaction_id and items would send an order with no id and no products. Two names, found
before launch instead of after a month of empty reports.
Events can also come from a server. An order confirmed on WhatsApp and entered in the bakery’s back office never loads a page, so it can reach GA4 through the Measurement Protocol, which Google built to add to the collection by the tag and Tag Manager, “not to replace it” (Measurement Protocol). Its API secret is “private to your organization” (reference): it stays on the server, never in a page, and the events it sends carry no personal data either.
Checkpoint
Check: The data layer and the container
A strict CSP names the scripts it trusts, by nonce or by hash
Every tag runs inside your page. OWASP warns that third-party code gets “the exact same privileges that were granted to the user” and adds that “a data layer is not a sandbox” (Third Party JavaScript Management Cheat Sheet). So the question is not whether a script may run, but which scripts, and the answer is a Content Security Policy: A response header that tells the browser which scripts, images, connections and other loads a page may use, and from where. Anything it does not allow is refused, and the refusal can be reported.: a response header that tells the browser where each kind of load may come from (CSP Level 3).
Google’s guide for Tag Manager recommends a nonce: In a Content Security Policy, a random value the server makes fresh for every response and writes into both the policy and the scripts it trusts, so that the browser runs only scripts carrying the matching value. in script-src-elem: a random value
the server makes for each response, which nobody can guess. The container snippet carries the same value in its
nonce attribute and reads it, and Tag Manager then passes the nonce on to every script it adds to the page
(CSP guide). A page served as the same file to
everyone cannot carry a fresh nonce, so it lists a hash of its inline snippet instead. 'unsafe-inline' works too, but
Google calls it “discouraged”: it lets every inline script run, the injected ones included.
The program applies the matching rules of the CSP specification to all seven loads of the bakery’s checkout, listed
in LOADS, under four policies, and says for each load which directive decided and why. Run it, then press Edit,
change one character of the snippet’s content in LOADS and run it again:
"""Which loads of the bakery's page would four Content Security Policies allow, and which directive decides?
The program applies the matching rules of CSP Level 3 (W3C) to the loads in LOADS: the directive for each kind of
load and its fallbacks, nonces, sha256 hashes, 'self', hosts with a leading *. and schemes, 'unsafe-inline' and
'unsafe-eval'. The GA4 origins come from Google's guide "Use a Content Security Policy with Google Tag Manager". A
browser is the final judge; the program shows the reasoning. The bakery and its container ids are fictional.
"""
import base64
import hashlib
from urllib.parse import urlsplit
PAGE = "https://punebakes.example"
LOADS = [ # what the checkout page asks the browser for
{"kind": "inline", "what": "Tag Manager container snippet", "nonce": "kV3x9Qe2",
"content": "(stand-in for the container snippet of GTM-PB1234)"},
{"kind": "script", "what": "gtm.js, added by the snippet", "nonce": "kV3x9Qe2",
"url": "https://www.googletagmanager.com/gtm.js?id=GTM-PB1234"},
{"kind": "script", "what": "the GA4 tag, added by Tag Manager", "nonce": "kV3x9Qe2",
"url": "https://www.googletagmanager.com/gtag/js?id=G-PB5678"},
{"kind": "connect", "what": "GA4 sends the bakery's events",
"url": "https://region1.google-analytics.com/g/collect"},
{"kind": "img", "what": "an image request to Google Analytics",
"url": "https://region1.google-analytics.com/g/collect"},
{"kind": "eval", "what": "a Custom JavaScript variable"},
{"kind": "inline", "what": "an old script pasted in the footer",
"content": "(an old tracking snippet nobody remembers adding)"},
]
GA4 = "img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; "
POLICIES = {
"A, a nonce as Google's guide describes":
"default-src 'self'; script-src-elem 'nonce-kV3x9Qe2' https://www.googletagmanager.com; " + GA4 +
"connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; "
"object-src 'none'; base-uri 'none'",
"B, the same with connect-src cut short":
"default-src 'self'; script-src-elem 'nonce-kV3x9Qe2' https://www.googletagmanager.com; " + GA4 +
"connect-src 'self' https://www.googletagmanager.com; object-src 'none'; base-uri 'none'",
"C, the policy that allows everything":
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; img-src *; connect-src *",
"D, a hash for pages that cannot carry a fresh nonce":
"default-src 'self'; script-src-elem 'sha256-epyN0S8OPM1oiKjWyAWDmD+lCxarovT/LABBTEsuy+s=' "
"https://www.googletagmanager.com; " + GA4 + "connect-src 'self' https://*.google-analytics.com "
"https://www.googletagmanager.com; object-src 'none'; base-uri 'none'",
}
DIRECTIVE = {"script": "script-src-elem", "inline": "script-src-elem", "eval": "script-src",
"img": "img-src", "connect": "connect-src"}
FALLBACK = {"script-src-elem": ["script-src-elem", "script-src", "default-src"],
"script-src": ["script-src", "default-src"], "img-src": ["img-src", "default-src"],
"connect-src": ["connect-src", "default-src"]}
def parse(policy):
return {p.split()[0]: p.split()[1:] for p in policy.split(";") if p.strip()}
def matches(source, url):
scheme, host = urlsplit(url).scheme, urlsplit(url).hostname
if source == "*":
return scheme in ("http", "https")
if source.endswith(":"):
return scheme == source[:-1]
if source == "'self'":
return f"{scheme}://{host}" == PAGE
want = urlsplit(source if "//" in source else "https://" + source)
pattern = source.split("//")[-1].split("/")[0]
if want.scheme and want.scheme != scheme:
return False
if pattern.startswith("*."):
return host.endswith(pattern[1:])
return host == pattern
def decide(policy, load):
"""(allowed, the directive that decided, why)."""
wanted = DIRECTIVE[load["kind"]]
found = next((d for d in FALLBACK[wanted] if d in policy), None)
if found is None:
return True, wanted, "no directive restricts it"
values, where = policy[found], found if found == wanted else f"{found}, standing in for {wanted}"
if load["kind"] == "eval":
return ("'unsafe-eval'" in values), where, "'unsafe-eval' allows eval" if "'unsafe-eval'" in values else "eval needs 'unsafe-eval'"
if load.get("nonce") and f"'nonce-{load['nonce']}'" in values:
return True, where, "its nonce matches"
if load["kind"] == "inline":
digest = base64.b64encode(hashlib.sha256(load["content"].encode()).digest()).decode()
if f"'sha256-{digest}'" in values:
return True, where, "its sha256 hash matches"
trusted = any(v.startswith(("'nonce-", "'sha256-")) for v in values)
if "'unsafe-inline'" in values and not trusted:
return True, where, "'unsafe-inline' allows every inline script"
return False, where, "an inline script needs the nonce or its hash"
source = next((v for v in values if matches(v, load["url"])), None)
if source:
return True, where, f"{urlsplit(load['url']).hostname} matches {source}"
return False, where, f"nothing in it matches {urlsplit(load['url']).hostname}"
for name, text in POLICIES.items():
policy = parse(text)
print(f"Policy {name}")
allowed = 0
for load in LOADS:
ok, where, why = decide(policy, load)
allowed += ok
print(f" {'allowed' if ok else 'BLOCKED'} {load['what']}\n {where}: {why}")
print(f" {allowed} allowed, {len(LOADS) - allowed} blocked\n") Output
Policy A, a nonce as Google's guide describes
allowed Tag Manager container snippet
script-src-elem: its nonce matches
allowed gtm.js, added by the snippet
script-src-elem: its nonce matches
allowed the GA4 tag, added by Tag Manager
script-src-elem: its nonce matches
allowed GA4 sends the bakery's events
connect-src: region1.google-analytics.com matches https://*.google-analytics.com
allowed an image request to Google Analytics
img-src: region1.google-analytics.com matches https://*.google-analytics.com
BLOCKED a Custom JavaScript variable
default-src, standing in for script-src: eval needs 'unsafe-eval'
BLOCKED an old script pasted in the footer
script-src-elem: an inline script needs the nonce or its hash
5 allowed, 2 blocked
Policy B, the same with connect-src cut short
allowed Tag Manager container snippet
script-src-elem: its nonce matches
allowed gtm.js, added by the snippet
script-src-elem: its nonce matches
allowed the GA4 tag, added by Tag Manager
script-src-elem: its nonce matches
BLOCKED GA4 sends the bakery's events
connect-src: nothing in it matches region1.google-analytics.com
allowed an image request to Google Analytics
img-src: region1.google-analytics.com matches https://*.google-analytics.com
BLOCKED a Custom JavaScript variable
default-src, standing in for script-src: eval needs 'unsafe-eval'
BLOCKED an old script pasted in the footer
script-src-elem: an inline script needs the nonce or its hash
4 allowed, 3 blocked
Policy C, the policy that allows everything
allowed Tag Manager container snippet
script-src, standing in for script-src-elem: 'unsafe-inline' allows every inline script
allowed gtm.js, added by the snippet
script-src, standing in for script-src-elem: www.googletagmanager.com matches https:
allowed the GA4 tag, added by Tag Manager
script-src, standing in for script-src-elem: www.googletagmanager.com matches https:
allowed GA4 sends the bakery's events
connect-src: region1.google-analytics.com matches *
allowed an image request to Google Analytics
img-src: region1.google-analytics.com matches *
allowed a Custom JavaScript variable
script-src: 'unsafe-eval' allows eval
allowed an old script pasted in the footer
script-src, standing in for script-src-elem: 'unsafe-inline' allows every inline script
7 allowed, 0 blocked
Policy D, a hash for pages that cannot carry a fresh nonce
allowed Tag Manager container snippet
script-src-elem: its sha256 hash matches
allowed gtm.js, added by the snippet
script-src-elem: www.googletagmanager.com matches https://www.googletagmanager.com
allowed the GA4 tag, added by Tag Manager
script-src-elem: www.googletagmanager.com matches https://www.googletagmanager.com
allowed GA4 sends the bakery's events
connect-src: region1.google-analytics.com matches https://*.google-analytics.com
allowed an image request to Google Analytics
img-src: region1.google-analytics.com matches https://*.google-analytics.com
BLOCKED a Custom JavaScript variable
default-src, standing in for script-src: eval needs 'unsafe-eval'
BLOCKED an old script pasted in the footer
script-src-elem: an inline script needs the nonce or its hash
5 allowed, 2 blocked
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 csp_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Policy A, the nonce, allows the five loads the tags need and blocks two: a Custom JavaScript variable, which the next sections deal with, and an old script pasted into the footer, which nobody remembers adding and which an attacker’s injected script would look exactly like. Policy D does the same with a hash, and one changed character in the snippet breaks that hash, which is why OWASP calls hashing “a risky approach” for code that changes (CSP cheat sheet).
Key idea
A strict policy trusts scripts by a nonce made fresh for each response, or by the hash of an inline script that never changes, so the scripts nobody chose are refused.
Checkpoint
Check: A strict CSP with a nonce or a hash
GA4 needs three directives, and a missing origin fails in silence
The bakery’s empty reports are policy B in the program’s output. The tags ran, the page worked, and the one request
that carries the events, to region1.google-analytics.com, met a connect-src that listed only Tag Manager. Follow
that request, and the pasted footer script after it, through the questions the browser asks:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- GA4 sends the events. The GA4 tag asks to send the bakery's events to region1.google-analytics.com. A request sent from a script is a connection, so the browser looks for connect-src.
- The directive is there. Policy B has a connect-src, so default-src is never consulted for this load. A directive that is present is the whole answer, however much default-src would have allowed.
- No source matches. connect-src lists only 'self' and www.googletagmanager.com. Neither is region1.google-analytics.com, so the browser blocks the request and can send a violation report to the address in report-to.
- Nothing looks broken. The tag ran and the page works, but the events never arrive. Adding https://*.google-analytics.com, as Google's guide lists it, lets every Google Analytics subdomain through, though not the bare domain.
- An old pasted script. Next, the browser meets an inline script in the footer that nobody remembers adding. Inline scripts are governed by script-src-elem, present here, and the script carries neither the nonce nor a hash the policy lists.
- Blocked, as it should be. The browser refuses to run it. This is the protection a strict policy buys you, since an injected script looks exactly like this one.
Google’s guide gives the origins for GA4 without its advertising features:
| Directive | Origins for GA4 through Tag Manager |
|---|---|
script-src-elem |
https://www.googletagmanager.com (and the nonce) |
img-src |
https://www.googletagmanager.com https://*.google-analytics.com |
connect-src |
https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com |
With Google Ads features the lists grow: https://*.g.doubleclick.net, each Google country domain written out, and a
frame-src for Tag Manager. A leading *. matches every subdomain but not the bare domain, so
https://*.google-analytics.com covers region1.google-analytics.com. Preview mode needs its own origins too, among
them https://tagmanager.google.com (CSP guide).
Silent failures are found by asking the browser to report. A report-to directive sends a violation report for every
refused load, and the Content-Security-Policy-Report-Only header delivers a policy that “doesn’t get enforced” while
still reporting, which OWASP describes as “a precursor to utilizing CSP in blocking mode”
(CSP cheat sheet). Run a new
policy in report-only mode for a week of real traffic, read the reports, then enforce it.
Fixing a blocked tag with a wildcard
When a tag is blocked, connect-src * or script-src https: makes the error go away and lets every origin in. The
fix is the one origin the report names, added to the one directive that refused it.
Checkpoint
Check: The directives GA4 needs, and silent failures
Custom JavaScript variables need ‘unsafe-eval’; Custom Templates do not
The program’s second blocked load is a Custom JavaScript variable. Google’s guide is direct: such variables “will
evaluate to undefined in the presence of a CSP” unless 'unsafe-eval' is in script-src, and it adds “use
‘unsafe-eval’ only when absolutely necessary”. The way out is a Custom Template, “the recommended alternative to Custom
JavaScript variables”, which can build tags and variables that “don’t require the use of ‘unsafe-eval’”
(CSP guide).
The program shows what the shortcut costs. Policy C, script-src 'self' 'unsafe-inline' 'unsafe-eval' https:, allows
all seven loads, the Custom JavaScript variable and the pasted footer script among them. It also allows any script
from any HTTPS address and any inline script an attacker manages to inject, so it protects nothing while looking like a
policy:
| Policy | Loads allowed | What it costs |
|---|---|---|
| A, nonce | 5 of 7 | The Custom JavaScript variable becomes a Custom Template |
B, nonce, short connect-src |
4 of 7 | Every GA4 event is lost, silently |
C, 'unsafe-inline' and 'unsafe-eval' |
7 of 7 | Any injected script runs too |
| D, hash | 5 of 7 | The hash must change whenever the snippet does |
Checkpoint
Check: Custom Templates instead of 'unsafe-eval'
Every change is previewed, published as a version and easy to undo
A container change goes live the moment it is published, for every visitor at once. Tag Manager gives three tools against that: preview, where Tag Assistant shows “which tags fired and in which order” on your own browser and the debug view “is not visible to your regular website visitors” (Preview and debug containers); versions, “a snapshot of a container configuration at a particular time”, with a name and a description; and publishing an earlier version when a new one breaks something (Publishing, versions, and approvals). Google’s CSP guide adds that Tag Assistant lists CSP problems under “Page issues”. Walk through one change to the bakery’s purchase tag:
- Workspace
The change waits in a draft
The bakery renames transactionId to transaction_id in its purchase tag's variables. In the workspace nothing is live yet; visitors still get the published container.
- Preview
Test it in Tag Assistant not live
Preview opens the site with Tag Assistant connected. It shows which tags fired, in what order and with what data, and lists blocked resources under Page issues when a CSP refuses something. Regular visitors see none of it.
- Fix
Fix what preview showed
The test order shows the GA4 request blocked by connect-src. The fix is a line in the site's CSP, made by whoever owns the server, then a second preview run with a test order.
- Publish
Publish and create a version live
Publish and Create Version records a snapshot of the container. A name and description such as "purchase tag reads transaction_id" are the change log; the publish history adds who published it and when.
- Watch
Watch the first real orders
Compare the next day's purchases in GA4 with the orders the bakery recorded. A gap that opens right after a version went live points at that version.
- Roll back
Publish the earlier version if it broke
If the new version breaks tracking, open Versions and publish the previous one. Every publish kept a snapshot, so going back is one more publish, and the broken version stays in the list to be fixed.
The timeline has 6 events, from Workspace to Roll back.
The version names are the bakery’s change log: what changed, in words a colleague understands, and the publish history adds who and when. OWASP gives the same advice from the security side: “Restrict tag-manager publishing access and review changes as application code”. On a fixed schedule, such as every quarter, list every tag in the container, ask what each sends and who still needs it, and remove the rest: a tag nobody owns is still running in every page.
HAR File Analyzer & Sanitizer Save a HAR file of your checkout from the browser's developer tools and see every request the tags make, with its host, so you know which origins your policy must list.Checkpoint
Check: Preview, versions and rollback
Practise: a strict policy for the checkout
Write the bakery’s header in code: a nonce made fresh for every response, the GA4 origins in the right directives, nothing unsafe. The sample tests split the header the way a browser does and call your function with two nonces:
Exercise · Medium · Python
Write a strict CSP for Tag Manager and GA4
The bakery's checkout page loads GA4 through Tag Manager, with consent mode, and one tag built from a Custom Template instead of a Custom JavaScript variable. Its server makes a fresh random nonce for every response and passes it to build_policy(nonce) in policy.py, which returns the value of the Content-Security-Policy header.
The policy a contractor left in the file "works" because it allows almost everything. Rewrite build_policy so that the policy:
- trusts scripts by the nonce it is given, in
script-src-elem(orscript-src), next tohttps://www.googletagmanager.com; - lets the GA4 requests through:
img-srcwithhttps://www.googletagmanager.comandhttps://*.google-analytics.com, andconnect-srcwith those two andhttps://*.google.com, as Google's guide lists them for GA4 without ads features; - contains neither
'unsafe-inline'nor'unsafe-eval', anywhere; - sets
object-src 'none'andbase-uri 'none'(or'self'), and keepsdefault-src 'self'for everything else.
The sample tests parse the header the way a browser splits it, check each rule, and call build_policy with two different nonces to make sure the nonce is not written into the code.
Starter code · policy.py
"""The Content-Security-Policy header of the bakery's checkout page. The server calls build_policy with a fresh nonce."""
def build_policy(nonce):
"""Return the header value for a page that loads GA4 through Tag Manager."""
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'nonce-kV3x9Qe2' https:; "
"img-src *"
) The sample tests · test_policy.py
from policy import build_policy
def directives(header):
"""Split a header the way a browser does: directives by ;, sources by spaces, names in lower case."""
out = {}
for part in header.split(";"):
words = part.split()
if words and words[0].lower() not in out:
out[words[0].lower()] = words[1:]
return out
def script_sources(policy):
return policy.get("script-src-elem") or policy.get("script-src") or []
def test_trusts_scripts_by_the_nonce_it_is_given():
"""puts 'nonce-<value>' and Tag Manager's origin in the script directive"""
sources = script_sources(directives(build_policy("Zq81mPx0")))
assert "'nonce-Zq81mPx0'" in sources
assert "https://www.googletagmanager.com" in sources
def test_nonce_is_not_fixed_in_the_code():
"""changes the nonce with every response"""
first = directives(build_policy("abc123"))
second = directives(build_policy("xyz789"))
assert "'nonce-abc123'" in script_sources(first) and "'nonce-xyz789'" in script_sources(second)
assert "'nonce-abc123'" not in script_sources(second)
def test_ga4_requests_get_through():
"""lists Google's GA4 origins in img-src and connect-src"""
policy = directives(build_policy("n0nce"))
for origin in ("https://www.googletagmanager.com", "https://*.google-analytics.com"):
assert origin in policy.get("img-src", []), f"img-src needs {origin}"
assert origin in policy.get("connect-src", []), f"connect-src needs {origin}"
assert "https://*.google.com" in policy.get("connect-src", [])
def test_no_unsafe_keywords():
"""allows neither inline scripts in general nor eval"""
header = build_policy("n0nce").lower()
assert "'unsafe-inline'" not in header
assert "'unsafe-eval'" not in header
def test_closes_the_other_doors():
"""sets object-src 'none', a strict base-uri and default-src 'self'"""
policy = directives(build_policy("n0nce"))
assert policy.get("object-src") == ["'none'"]
assert policy.get("base-uri") in (["'none'"], ["'self'"])
assert policy.get("default-src") == ["'self'"] A hint
Build the header from directives separated by ; , each a name followed by its sources, separated by spaces. The nonce goes in as 'nonce- + the value + ', with the single quotes. Start from policy A in the lesson's program, which already allows everything the GA4 tag needs, and use the argument instead of a fixed value.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise the five ideas of the page, weakest first:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why is a nonce safer than ‘unsafe-inline’ for running Tag Manager under a CSP?
'unsafe-inline' lets every inline script on the page run, so a script an attacker manages to inject runs as freely
as the Tag Manager snippet. A nonce is a random value the server makes fresh for every response and writes into two
places: the policy, as 'nonce-…' in script-src-elem, and the scripts it trusts. The browser runs only scripts that
carry the matching value, and an attacker cannot know it in advance. Tag Manager’s nonce-aware snippet reads the value and passes
it to the scripts it adds, so the tags keep working. When a policy lists a nonce or a hash, browsers ignore
'unsafe-inline' in the same directive, which is why the two never belong together. Google’s own guide calls
'unsafe-inline' discouraged.
Two more questions follow, about a policy that cut GA4’s traffic and about a marketer who needs a Custom JavaScript variable. Choose your first move for each, then open it:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A week after a new Content Security Policy goes live on a shop's product and checkout pages, GA4 sessions are down 60 % while orders are unchanged. How do you find the cause and fix it without weakening the policy?
-
Mid to senior
A marketer needs a Custom JavaScript variable in Tag Manager to read the coupon code at checkout, and the site's Content Security Policy forbids 'unsafe-eval'. What do you do?
Key takeaways
- The site states facts in the data layer with
dataLayer.push(), and the Tag Manager container’s triggers and variables decide which tags use them; check the pushes against the event plan before building tags. - A strict Content Security Policy trusts scripts by a nonce made fresh for each response, or by a hash for pages that cannot carry one, and refuses the scripts nobody chose.
- GA4 needs its origins in
script-src-elem,img-srcandconnect-src, and a missing origin fails silently, so run a new policy in report-only mode first and read the violation reports. - Custom JavaScript variables need
'unsafe-eval'; Custom Templates do not, and a policy with'unsafe-inline'and'unsafe-eval'protects nothing. - Preview every container change, publish it as a named version, and roll back by publishing the previous one when tracking breaks.
Terms in this lesson
- Content Security Policy
- A response header that tells the browser which scripts, images, connections and other loads a page may use, and from where. Anything it does not allow is refused, and the refusal can be reported.
- data layer
- An object on a web page through which the site's code passes facts and events, with dataLayer.push(), to the tags of Google Tag Manager and gtag.js, so that tags read data instead of the page's layout.
- nonce
- In a Content Security Policy, a random value the server makes fresh for every response and writes into both the policy and the scripts it trusts, so that the browser runs only scripts carrying the matching value.
References
- Introduction to Tag Manager (Tag Manager Help)
- The data layer (Google for Developers)
- Measure ecommerce (Google for Developers)
- Use a Content Security Policy with Google Tag Manager (Google for Developers)
- Content Security Policy Level 3 (W3C)
- Content Security Policy Cheat Sheet (OWASP)
- Third Party JavaScript Management Cheat Sheet (OWASP)
- Preview and debug containers (Tag Manager Help)
- Publishing, versions, and approvals (Tag Manager Help)
- Measurement Protocol (Google Analytics 4) (Google for Developers)
- Measurement Protocol reference (Google for Developers)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress