Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message One add-to-cart, through the data layer Step through what the bakery's checkout code pushes, what the container does with it and what reaches GA4.
One add-to-cart, through the data layer, step 6 of 6. Messages so far: Bakery's code to Tag Manager container: event add_to_cart; Tag Manager container to GA4 property: add_to_cart + 3 parameters.Bakery's codedataLayer declaredTag Manager containerGA4 tag reads 3 valuesGA4 propertyevent add_to_cartadd_to_cart + 3 parameterssame push, any tag

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 checkout's dataLayer pushes with the event plan Python · datalayer/datalayer_check.py
"""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

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

  1. Question 1 of 2 The product page's code sets window.dataLayer = [] below the container snippet. What is wrong?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It wipes what is already queued; facts are added with dataLayer.push() instead

    Declare dataLayer once, above the container snippet, and only ever push to it. An assignment later in the page throws away the facts and events queued before it.

  2. Question 2 of 2 The checkout pushes purchase with transactionId, and the GA4 tag's variable reads transaction_id. What reaches GA4 when Asha pays?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A purchase with no transaction id, because the variable finds nothing under the name it reads

    A tag can only send what the pushes contain, under the names they use. That is why the bakery compares its pushes with the event plan before building the tags.

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:

Seven loads under four policies: allowed, blocked, and the directive that decided Python · csp/csp_check.py
"""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

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

  1. Question 1 of 2 Why must a CSP nonce be made fresh for every response?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A value that never changes could be read once and put on an injected script; a fresh, unguessable one cannot be known in advance

    A nonce works only while an attacker cannot know it. Made fresh and random for each response, it cannot be copied onto an injected script in advance.

  2. Question 2 of 2 Under policy D, the bakery's developer reformats the container snippet and one space is added. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The snippet's hash no longer matches, the browser refuses it and no tags load at all

    A hash trusts one exact text. Any change to the snippet needs a new hash in the policy, which is why a nonce is the better choice wherever the server can make one.

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:

Interactive · stage by stage How the browser decides one load Follow two of the bakery's loads, the GA4 request and an old pasted script, through the questions the browser asks under policy B.
How the browser decides one load, step 6 of 6. Showing A load is requested (inline footer script), Pick its directive (script-src-elem found), Fall back if absent, Match one source (no nonce, no hash), Allowed.no match, blockedA load is requestedinline footer scriptPick its directivescript-src-elem foundFall back if absentscript-src, default-srcMatch one sourceno nonce, no hashAllowedblocked

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Content Security Policy (CSP) Generator Build a policy from Google's GA4 and Tag Manager presets, with a nonce or a hash, and see what each directive allows before you send the header.

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

  1. Question 1 of 2 Tags fire in preview, but no GA4 events arrive, and the browser reports a CSP violation for region1.google-analytics.com on the request that sends them. Which directive needs the origin?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: connect-src

    Read the violation: it names the directive and the origin. Add https://*.google-analytics.com to connect-src, as Google's guide lists it, and nothing more.

  2. Question 2 of 2 Does the source https://*.google-analytics.com allow a request to https://google-analytics.com itself?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No; a leading *. matches subdomains, such as region1.google-analytics.com, but not the bare domain

    https://*.google-analytics.com covers region1.google-analytics.com and every other subdomain, which is what the GA4 tag uses; the bare domain would need its own entry.

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'

  1. Question 1 of 2 What does a Custom JavaScript variable return under a CSP whose script-src has no 'unsafe-eval'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: undefined, so any tag that uses it sends nothing in its place

    Custom JavaScript variables need 'unsafe-eval'. Google recommends Custom Templates, which build tags and variables without it.

  2. Question 2 of 2 Policy C allows all seven of the checkout's loads. Why is it worse than policy A, which blocks two?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It also allows any script from any HTTPS address and any inline script an attacker injects

    The point of a policy is what it refuses. Policy A refuses the two loads nobody should need: eval, replaced by a Custom Template, and a script nobody chose.

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:

Timeline · 6 events in order One change to the container, from draft to rollback Walk through the bakery's change of its purchase tag, phase by phase, and what to check before the next phase.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. Question 1 of 2 While you test a change in Tag Manager's preview, what do the site's regular visitors get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The published container, with no debug view

    Preview runs your draft in your own browser only, which is what makes it safe to test with a real order.

  2. Question 2 of 2 An hour after a container version goes live, purchases stop arriving in GA4. What is the quickest safe fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Publish the previous version from Versions, then find the fault in a workspace and preview the fix

    Roll back first, fix second. The version list is the change log that tells you what the broken version changed.

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:

  1. trusts scripts by the nonce it is given, in script-src-elem (or script-src), next to https://www.googletagmanager.com;
  2. lets the GA4 requests through: img-src with https://www.googletagmanager.com and https://*.google-analytics.com, and connect-src with those two and https://*.google.com, as Google's guide lists them for GA4 without ads features;
  3. contains neither 'unsafe-inline' nor 'unsafe-eval', anywhere;
  4. sets object-src 'none' and base-uri 'none' (or 'self'), and keeps default-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.

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.

Security Headers Checker Once your own site sends a policy, check the Content-Security-Policy header it really returns, next to its other security headers.

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.

  1. Question 1 of 10 What is the data layer?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: An object through which the site passes facts and events to the tags of Tag Manager and gtag.js

    The data layer is how the site states facts, such as an add-to-cart and its items, so that the container decides which tags use them without changes to the site's code.

  2. Question 2 of 10 A developer plans to have the purchase tag read the order total from the page with a CSS selector. What is the sturdier design?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: Have the checkout push value and currency into the data layer with the purchase event

    Facts belong in the data layer, stated by the code that knows them. Reading the page's layout ties measurement to the design.

  3. Question 3 of 10 The server's nonce for this response is kV3x9Qe2. What does script-src-elem list to trust the snippet?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: 'nonce-kV3x9Qe2', with the single quotes

    The policy lists 'nonce-kV3x9Qe2' and the snippet carries nonce="kV3x9Qe2"; the browser runs scripts whose attribute matches.

  4. Question 4 of 10 A shop serves its pages as files, the same to every visitor. How should its policy trust the container snippet?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: With the sha256 hash of the snippet, updated whenever the snippet changes

    Nonce where the server makes each response, hash where the same text is served to all. Both keep 'unsafe-inline' out.

  5. Question 5 of 10 Which directive governs the request in which the GA4 tag sends events from a script?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: connect-src

    Each kind of load has its directive: scripts in script-src-elem, images in img-src, requests from scripts in connect-src. GA4 needs its origins in all three.

  6. Question 6 of 10 A new policy is ready. How do you see what it would block on real traffic without breaking anything?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Send it as Content-Security-Policy-Report-Only, with report-to, and read the reports before enforcing it

    Report first, enforce second. A week of reports shows every origin the tags really use.

  7. Question 7 of 10 What does Google recommend instead of Custom JavaScript variables on a site with a CSP?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Custom Templates, which build tags and variables that do not need 'unsafe-eval'

    Google's guide says Custom Templates can build tags and variables that do not need 'unsafe-eval', which is why a strict policy can keep eval out.

  8. Question 8 of 10 In the program's output, which load allowed under policy C shows best why the policy is unsafe?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: The old script pasted in the footer, which nobody remembers adding

    Judge a policy by what it lets through that nobody chose. Under policy C that is the footer script, and anything like it.

  9. Question 9 of 10 What is a Tag Manager container version?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: A snapshot of the container's configuration at one moment, with a name and a description

    A version is the container as it was. Its name and description are the change log, and publishing an older one undoes a bad change.

  10. Question 10 of 10 Who should be able to publish the bakery's container?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: A few people, with every change previewed and looked at like a change to the site's code

    Publishing to a container is publishing code to the site. Keep the circle small, preview everything and write what changed in the version's name.

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

  1. Question 1 of 2 Interview brief: a week after a new CSP goes live on a shop's product and checkout pages, GA4 sessions are down 60 % while orders are unchanged. What do you check first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The violation reports, or the browser console on those pages, for which directive refused which Google origin

    Start with what the browser refused: the violation names the directive and the origin, and the fix is that one origin in that one directive.

  2. Question 2 of 2 Interview brief: a marketer needs a Custom JavaScript variable, and the site's CSP forbids 'unsafe-eval'. What do you propose first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Rebuild the variable as a Custom Template, or have the site push the value into the data layer

    Ask where the value comes from. Usually the site already knows it and can push it, and anything else a template can compute.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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-src and connect-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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.