GTM 12 min read

Magecart in Google Tag Manager: How to Detect and Block Credit Card Skimmers in Your Container

Attackers are weaponising Google Tag Manager containers to inject credit card skimmers directly into checkout pages — and because the malicious code loads through a trusted Google domain, most securit

A
Ashwani Bhasin
·

A client called me last March in a panic. Their Shopify Plus store was flagged by a customer’s bank for harvesting card details. The customer had screenshots of a POST request firing to a domain that looked almost identical to their CDN. We pulled their GTM container, and there it was: a Custom HTML tag named “GA4 - Enhanced Conversions Backup”, created six weeks earlier by a user who’d left the agency three months before that. The tag fired on Page Path contains /checkout and exfiltrated form field values to cdn-shopifу.net (note the Cyrillic у). Total loss before detection: roughly 1,800 cards.

This is Magecart in 2026. The skimmers no longer need to compromise your server or inject scripts into your theme. They just need one stale GTM user account, and the malicious payload loads from googletagmanager.com, signed by Google, trusted by every CSP you’ve ever written.

If you run a checkout with GTM on it, your container is a payment-data surface. Here’s how attackers are getting in, how to audit what you’ve already got, and how to lock it down to meet PCI DSS 4.0 requirement 11.6.1.

How attackers get into your GTM container

There are three realistic entry vectors, and almost every public Magecart-via-GTM case I’ve reviewed traces back to one of them.

1. Compromised admin accounts. The most boring and the most common. Someone on your team reuses a password, gets phished, or has their session cookie stolen by infostealer malware (Redline and Lumma both specifically harvest Google session tokens). If that account has Publish permission on your container, the attacker can push a new version inside two minutes.

2. Agency supply-chain access. This is how the case I opened with happened. Agencies routinely get added as users with Publish or Approve rights, then never get removed. I’ve audited containers with 40+ users where the marketing director couldn’t identify half of them. Every one of those accounts is a potential entry point, and you have zero visibility into how the agency stores credentials.

3. Leaked service account keys. If you use the GTM API for deployments (Terraform, custom CI/CD, the gtm-cli tool), the JSON key file for that service account is gold. I’ve seen these committed to public GitHub repos, left in Slack DMs, and baked into Docker images. A service account with publish scope is functionally an admin.

The tag patterns they inject

Once inside, the attacker has a small set of patterns they reuse. Knowing them makes auditing tractable.

PatternWhat it looks likeWhy it works
Direct exfiltrationCustom HTML tag with inline JS that reads input[type="text"], input[name*="card"], input[name*="cvv"] and POSTs to attacker domainFast, no external request before exfil. Triggers on checkout page view.
Lazy-loaded skimmerCustom HTML loads a second script from a benign-looking CDN (cdn-jsdelivr.net, cloudflare-static.com, typosquats)Bypasses keyword scans on the container itself; payload can be swapped without touching GTM
Event-listener hijackListens for form submit on checkout, clones FormData, sends to attacker before native submitSurvives single-page checkout flows where the URL doesn’t change
iframe overlayInjects an invisible iframe pointing to a fake payment form rendered over the real oneWorks against Shopify’s hosted checkout where direct DOM access to card fields is blocked
GTM-as-loaderUses Custom Image tag with src pointing to attacker domain, carries data in query stringLooks like a pixel tag in audit views. Easy to miss.

The Custom Image trick is the one most analysts miss. It looks identical to a Facebook or TikTok pixel. The tell is usually the trigger: legitimate pixels fire on page view or conversion events, skimmer image tags fire on form-field blur or checkout step changes.

Auditing your existing container

Do this now, before you do anything else. Block out 90 minutes. You need Publish or at minimum Read access to your container.

Step 1: User access audit

In GTM, go to Admin, then User Management at both the Account and Container level. Export the list. For each user:

  • When did they last sign in? If you can’t tell, that’s the problem — request Google Workspace audit logs from your IT team for accounts.google.com activity tied to that email.
  • Do they still work at your company or your agency?
  • Do they need Publish, or would Approve / Edit / Read be enough?
  • Is the account a personal Gmail? Personal Gmails should never have Publish on a production container handling payments. Migrate to Workspace accounts.

Remove anyone who fails these checks. The marketing intern from 2023 does not need Publish on your checkout container.

Step 2: Tag audit

In the container, sort tags by “Last edited” descending. Look at everything edited in the last 90 days. For each Custom HTML tag, ask:

  1. Do I recognise the purpose?
  2. Does the code do what the name suggests?
  3. Does it reference any external domain I don’t recognise?
  4. Does it touch form fields, document.cookie, localStorage, or fetch/XMLHttpRequest?

Pay special attention to tags with generic names: “Analytics Helper”, “Tracking Backup”, “Pixel Loader”, “Conversion Sync”. Attackers pick names that blend in.

Here’s a quick script you can run against a GTM container export (JSON) to flag suspicious patterns:

import json
import re

SUSPICIOUS = [
    r'document\.querySelectorAll\(["\']input',
    r'input\[name\*?=["\'](card|cvv|cvc|exp|cc)',
    r'new\s+XMLHttpRequest',
    r'fetch\s*\(\s*["\']https?://(?!www\.googletagmanager|www\.google-analytics)',
    r'navigator\.sendBeacon',
    r'atob\s*\(',  # base64 decode is a huge red flag
    r'String\.fromCharCode',  # obfuscation tell
    r'\\x[0-9a-f]{2}',  # hex-encoded strings
    r'document\.forms',
    r'addEventListener\s*\(\s*["\']submit',
]

with open('container.json') as f:
    container = json.load(f)

for tag in container['containerVersion'].get('tag', []):
    if tag.get('type') != 'html':
        continue
    html = next((p['value'] for p in tag.get('parameter', []) 
                 if p['key'] == 'html'), '')
    hits = [p for p in SUSPICIOUS if re.search(p, html, re.IGNORECASE)]
    if hits:
        print(f"\n[!] Tag: {tag['name']} (ID {tag['tagId']})")
        print(f"    Last edited by: {tag.get('fingerprint', 'unknown')}")
        for pattern in hits:
            print(f"    Matches: {pattern}")

Export your container via the GTM API (accounts.containers.versions.get with containerVersionId=0 for the live version) and run it. Anything that lights up gets a human review. False positives are fine; you only need to investigate, not panic.

Step 3: Trigger audit

Filter triggers for anything referencing checkout URLs, thank-you pages, or form events. On Shopify, that’s /checkout, /thank_you, Page URL matches RegEx .*checkout.*. On custom builds, look for triggers on payment-step events.

For each checkout-scoped trigger, list every tag that fires on it. Cross-check that list against your documented marketing stack. If there’s a tag you can’t account for, that’s your suspect.

Step 4: Version history review

Go to Versions in the left nav. For the last 20 versions, check who published, when, and what changed. GTM shows you a diff. Look for:

  • Publishes outside business hours from accounts that usually publish during work
  • Publishes from new IPs (you’ll need to correlate with Google Workspace logs for this)
  • Versions named generically (“Updated”, “v2”, blank)
  • Multiple rapid publishes by the same user (skimmers often get tuned over several quick iterations)

Hardening: what actually works

Most hardening guides give you a checklist of 30 items. Half of them don’t matter. Here’s the short list that does.

Enforce 2FA at the Workspace level, not the GTM level

GTM honours whatever your Google account requires. Set Workspace policy to require 2FA, and require security keys (not SMS, not even TOTP) for anyone with Publish on a payment container. Yubikeys cost £45. Cheaper than a PCI breach.

Use the Approve workflow, even if you’re small

GTM has a permission level called “Approve” that lets users edit but requires a second user to publish. Turn this on for your production container. Yes, it adds friction. That friction is the point — a single compromised account can no longer push to production alone.

Workflow that works in practice: editors get Edit, senior analysts get Approve, exactly two people get Publish. Those two publish accounts are dedicated, with hardware keys, used only for the publish action.

Restrict Custom HTML to environments where it’s necessary

This is the recommendation most guides skip because it’s inconvenient. GTM lets you disable Custom HTML at the container level via “Restrict Custom HTML Tags from being deployed by users with edit permission” (Admin, Container Settings). If you don’t have a current business reason for new Custom HTML tags on your checkout container, turn this on. New tags will require an admin to permit them.

Better still: maintain a separate GTM container for checkout pages with Custom HTML disabled entirely, and a different container for the rest of the site. The checkout container only contains GA4, your verified conversion pixels, and nothing else. This is the single highest-leverage change you can make.

Kill stale users on a schedule

Calendar event, quarterly, non-negotiable. Pull the user list, confirm every account against your current org chart and active agency contracts. Anyone unaccounted for: remove first, ask questions second. You can always re-add.

Service account hygiene

If you use the GTM API:

  • One service account per pipeline, not a shared one
  • Store keys in a secrets manager (GCP Secret Manager, Vault, AWS Secrets Manager), never in repos or CI environment variables in plain text
  • Rotate every 90 days
  • Grant container-level permission, not account-level
  • Audit iam.serviceAccountKeys.list regularly to spot unauthorised key creation

Change detection: alert on every publish

GTM doesn’t natively Slack you when someone publishes. You have to build that. Here’s a working n8n workflow that polls the GTM API and alerts on new versions. It costs nothing if you self-host n8n.

{
  "nodes": [
    {
      "name": "Every 5 min",
      "type": "n8n-nodes-base.cron",
      "parameters": {
        "triggerTimes": { "item": [{ "mode": "everyX", "value": 5, "unit": "minutes" }] }
      }
    },
    {
      "name": "List GTM Versions",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "=https://tagmanager.googleapis.com/tagmanager/v2/accounts/{{$env.GTM_ACCOUNT_ID}}/containers/{{$env.GTM_CONTAINER_ID}}/version_headers",
        "authentication": "oAuth2",
        "options": { "queryParameters": { "parameters": [{ "name": "includeDeleted", "value": "false" }] } }
      }
    },
    {
      "name": "Get Last Seen Version",
      "type": "n8n-nodes-base.redis",
      "parameters": { "operation": "get", "key": "gtm:lastVersionId" }
    },
    {
      "name": "Is New Version?",
      "type": "n8n-nodes-base.if",
      "parameters": {
        "conditions": { "string": [{ "value1": "={{$json[\"containerVersionId\"]}}", "operation": "notEqual", "value2": "={{$node[\"Get Last Seen Version\"].json[\"value\"]}}" }] }
      }
    },
    {
      "name": "Fetch Version Diff",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "=https://tagmanager.googleapis.com/tagmanager/v2/accounts/{{$env.GTM_ACCOUNT_ID}}/containers/{{$env.GTM_CONTAINER_ID}}/versions/{{$json[\"containerVersionId\"]}}"
      }
    },
    {
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#gtm-alerts",
        "text": "=:rotating_light: GTM publish detected\nContainer: {{$json[\"name\"]}}\nVersion: {{$json[\"containerVersionId\"]}}\nPublisher: {{$json[\"fingerprint\"]}}\nTags changed: {{$json[\"numOfTags\"]}}\nReview: https://tagmanager.google.com/#/versions/accounts/{{$env.GTM_ACCOUNT_ID}}/containers/{{$env.GTM_CONTAINER_ID}}/versions/{{$json[\"containerVersionId\"]}}"
      }
    }
  ]
}

This polls every five minutes, caches the last seen version ID in Redis, and Slacks #gtm-alerts on a change with a one-click link to the diff view. Five minutes is a reasonable detection window — attackers typically need longer than that to exfiltrate meaningful card volume, especially against a low-traffic checkout.

For a more thorough audit trail, enable Google Cloud audit logs for the Tag Manager API. The relevant log filter:

protoPayload.serviceName="tagmanager.googleapis.com"
protoPayload.methodName=~".*\.versions\.publish$"

Pipe these to BigQuery and you have a permanent record of every publish, with IP, user agent, and authenticated principal. If your security team uses a SIEM, route them there. This is also what you’ll hand to your QSA during PCI assessment.

If you want help wiring this kind of monitoring into your stack, that’s the sort of thing we build with our AI agents service — agents that watch GTM, Shopify webhooks, and SP-API events and route them through automated triage.

Checkout-specific defences

Even with a clean container, you should not assume GTM is the only injection vector. Defence in depth means the page itself rejects skimmers regardless of how they got there.

Content Security Policy

A CSP on your checkout page is non-negotiable for PCI DSS 4.0. The hard part: GTM legitimately needs to load arbitrary scripts, which fights against a strict CSP. The answer is to have a tight CSP on checkout pages and load only a minimal, audited tag set there.

Workable checkout CSP:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com 'nonce-{random}';
  connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net;
  img-src 'self' data: https://www.google-analytics.com https://www.googletagmanager.com;
  frame-src 'self' https://js.stripe.com;
  form-action 'self' https://checkout.shopify.com;
  base-uri 'self';
  report-uri https://your-csp-reporter.example.com/report

Note: no 'unsafe-inline', no 'unsafe-eval'. This will break any Custom HTML tag that uses inline event handlers or eval. Good. That’s the point.

Set up report-uri from day one. CSP violations are your early warning that something is trying to load that shouldn’t.

Subresource Integrity (with the GTM caveat)

SRI works for static scripts. It does not work for GTM’s container script, because the contents change every time you publish. You can SRI the third-party libraries GTM loads via Custom HTML if you control them, but the gtm.js file itself is uncacheable in SRI terms.

This is one of the gaps PCI 4.0 explicitly flags. The compensating control is logging and integrity monitoring of the container itself (the n8n workflow above), which the standard accepts as equivalent.

Shopify Checkout Extensibility

If you’re on Shopify Plus and still using checkout.liquid, migrate to Checkout Extensibility. The new model runs extensions in sandboxed iframes with a restricted API surface — there’s no DOM access to card fields, no inline script injection, and GTM’s Custom HTML tags simply do not execute on the new checkout pages. Web Pixels run in a worker context with no DOM access at all.

This is the single biggest reduction in skimmer surface area available to a Shopify merchant. If you need help with the migration, our Shopify service handles this regularly.

Server-side GTM

Server-side tagging changes the threat model entirely. The browser only talks to your first-party server-side endpoint. The server-side container fans out to analytics and ad platforms. A compromised web container can still inject Custom HTML on the page, so SS-GTM isn’t a complete defence on its own — but it dramatically reduces the data that flows through third-party domains, and the server-side container has a sm

#GTM#Security#PCI Compliance#Shopify

Share this article

Want This Implemented Correctly?

Let our team apply these concepts to your specific setup — with QA validation and 30 days of support.