Skip to content

iFrame Tracking

iframes create a separate browsing context. GTM running on the parent page cannot access DOM elements or JavaScript inside a cross-origin iframe, and an iframe cannot directly push to the parent page’s dataLayer. The solution is a postMessage bridge: the iframe sends messages to the parent, and the parent forwards them to the dataLayer.

Same-origin iframes (e.g., iframe and parent are both on yoursite.com) can share JavaScript directly: window.parent.dataLayer.push({...}). This is the simple case.

Cross-origin iframes (e.g., iframe is on payments-provider.com, parent is on yoursite.com) cannot access each other’s JavaScript due to the browser’s same-origin policy. You need postMessage.

Inside the iframe (code you add to the iframe’s page)

Section titled “Inside the iframe (code you add to the iframe’s page)”
// Inside your iframe page — push this instead of dataLayer.push()
function trackToParent(eventData) {
window.parent.postMessage({
type: 'GTM_EVENT',
payload: eventData
}, '*'); // Replace '*' with the parent domain for security: 'https://yoursite.com'
}
// Use anywhere you would normally call dataLayer.push()
document.getElementById('contact-form').addEventListener('submit', function(e) {
e.preventDefault();
// ... form logic ...
trackToParent({
event: 'form_submission',
form_name: 'embedded_contact',
form_location: 'iframe'
});
});

On the parent page (Custom HTML tag in GTM)

Section titled “On the parent page (Custom HTML tag in GTM)”
dataLayer.push() iframe_event

Listen for postMessage events from iframes and forward them to the dataLayer.

(function() {
var ALLOWED_ORIGINS = [
'https://checkout.payments-provider.com',
'https://forms.yourembeddedform.com'
];
window.addEventListener('message', function(event) {
// Validate origin
if (ALLOWED_ORIGINS.indexOf(event.origin) === -1) return;
// Validate message structure
if (!event.data || event.data.type !== 'GTM_EVENT') return;
var payload = event.data.payload;
if (!payload || !payload.event) return;
// Forward to dataLayer
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(payload);
});
})();
  1. Add the postMessage listener to the parent page as a Custom HTML tag

    • Trigger: DOM Ready (All Pages where iframes appear)
  2. Add trackToParent() calls inside the iframe’s JavaScript, replacing any dataLayer pushes

  3. Create a Custom Event Trigger on the parent page:

    • Trigger type: Custom Event
    • Event name: form_submission (or whatever event name the iframe sends)
  4. Create a GA4 Event Tag on the parent page:

    • The tag reads from DLV - form_name etc., just like any other GA4 event
    • Trigger: the Custom Event trigger above
  5. Test in Preview Mode

    Open GTM Preview on the parent page. Interact with the iframe (e.g., submit the form). The event from the iframe should appear in the GTM Summary pane as if it were fired on the parent page.

GA4-Specific iframe Tracking Considerations

Section titled “GA4-Specific iframe Tracking Considerations”

GA4 has several iframe-specific behaviors worth understanding:

Client ID synchronization across iframe boundaries: GA4 automatically uses the same client_id for the parent and any same-origin iframes, so sessions are naturally joined. For cross-origin iframes, the client_id is different — this is by design for privacy. Events from a cross-origin iframe will appear as a different user in GA4 unless you explicitly synchronize the client_id via postMessage.

Measurement Protocol with cross-origin iframes: If you are building a backend service inside a cross-origin iframe and need to send Measurement Protocol hits to GA4 from that backend, you can:

  1. Send hits from the iframe backend directly to GA4 (requires API secret)
  2. Have the iframe forward events to the parent’s GA4 tag via postMessage (simpler if you control both domains)

UTM parameters in iframes: Cross-origin iframes do not inherit the parent page’s UTM parameters. If a user arrives at the parent page with ?utm_source=email&utm_medium=newsletter and then interacts with a cross-origin iframe, events in the iframe will not have those UTM parameters unless you explicitly pass them via postMessage.

Consent Mode in iframes: If you have both a parent GA4 tag and an iframe GA4 tag, they receive consent updates independently. The parent page’s consent tag (via your CMP) controls the parent GA4 tag. Cross-origin iframes need their own consent implementation. Same-origin iframes can share the parent’s consent state.


Sharing client_id across cross-origin domains

Section titled “Sharing client_id across cross-origin domains”

For cross-origin iframes where you want events to appear as the same user in GA4, you must manually synchronize the client_id. The parent page has a client_id in its GA4 configuration — pass this to the iframe and use it in the iframe’s GA4 tag.

On the parent page, expose the client ID:

// Store GA4 client_id in a window object accessible to iframes (if same domain)
// Or embed it in the iframe URL as a query param
(function() {
// Wait for gtag to load
window.gtag = window.gtag || function() { (window.dataLayer = window.dataLayer || []).push(arguments); };
// After gtag is initialized, retrieve the client_id
// This works after the first GA4 event fires
gtag('get', 'G-XXXXXXXX', 'client_id', function(clientId) {
window.ga4_client_id = clientId;
// If embedding iframe URL, pass as param:
// var iframeUrl = 'https://iframe-domain.com/form?ga_client_id=' + clientId;
});
})();

Inside the cross-origin iframe, use the shared client_id:

// Retrieve client_id from URL param or parent message
var urlParams = new URLSearchParams(window.location.search);
var sharedClientId = urlParams.get('ga_client_id');
// Configure gtag to use the shared client_id
window.dataLayer = window.dataLayer || [];
gtag('js', new Date());
gtag('config', 'G-IFRAME_PROPERTY_ID', {
'client_id': sharedClientId // Use the parent's client_id
});

This is most practical when the iframe URL is under your control. If the iframe is from a third party, this approach won’t work.


Cross-origin messaging patterns for events and context

Section titled “Cross-origin messaging patterns for events and context”

For more advanced scenarios where the iframe needs to share context (user ID, custom dimensions, etc.) with the parent, extend the postMessage pattern:

Enhanced postMessage payload:

// Inside iframe — send rich context along with the event
function trackToParent(eventData) {
var enrichedPayload = {
type: 'GTM_EVENT',
payload: eventData,
context: {
client_id: window.ga4_client_id, // If iframe has access to its own GA4 client_id
timestamp: Date.now(),
iframe_origin: window.location.origin
}
};
window.parent.postMessage(enrichedPayload, 'https://yoursite.com');
}
trackToParent({
event: 'form_submission',
form_name: 'contact_form',
user_id: getCurrentUserId() // Pass user context from iframe
});

Enhanced listener on parent page:

window.addEventListener('message', function(event) {
if (ALLOWED_ORIGINS.indexOf(event.origin) === -1) return;
if (!event.data || event.data.type !== 'GTM_EVENT') return;
var payload = event.data.payload;
var context = event.data.context || {};
// Add context (like user_id) to every event from this iframe
var enrichedEvent = Object.assign({}, payload, {
iframe_client_id: context.client_id,
iframe_origin: context.iframe_origin
});
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(enrichedEvent);
});

This pattern allows the iframe to share its own GA4 client_id, user context, and other metadata with the parent, giving you more control over attribution and segmentation.


If the iframe and parent page are on the same domain, push directly to the parent’s dataLayer:

// Inside the same-origin iframe
window.parent.dataLayer = window.parent.dataLayer || [];
window.parent.dataLayer.push({
event: 'form_submission',
form_name: 'embedded_quote',
form_origin: window.location.href
});

No listener tag is needed on the parent page — the push goes directly into the parent’s dataLayer and GTM picks it up immediately.

If you have GTM installed inside the iframe as well as the parent page, you have two separate GTM containers running. Events in the iframe will fire tags in the iframe’s GTM container, not the parent’s. This is fine if you want separate analytics per iframe, but it means ecommerce events in the iframe will not automatically combine with the parent’s session unless you set up cross-domain tracking or postMessage forwarding.

For most cases, the postMessage bridge approach (events fire in the parent’s GTM container) is simpler and leads to better unified attribution.

Open the browser console on the parent page and watch for messages:

// Temporary debug listener — remove after testing
window.addEventListener('message', function(e) {
console.log('postMessage received:', e.origin, e.data);
});

If you see messages but they are not forwarding to the dataLayer, check the ALLOWED_ORIGINS array.

If you see no messages, the iframe code is not calling postMessage. Add console.log inside the iframe to verify the code is executing.

  1. Open GTM Preview on the parent page
  2. Interact with the iframe (submit a form, click a button)
  3. The event should appear in the GTM Summary pane on the parent page
  4. Verify the event name and parameters match what the iframe is sending
  5. Confirm Tags Fired shows your GA4 tag with correct parameter values

Origin validation fails silently. If the origin in ALLOWED_ORIGINS does not exactly match event.origin (including protocol and port), the message is silently dropped. Log event.origin during testing to see the exact string.

iframe sends events before listener is set up. If the iframe loads and sends a message before the parent page’s Custom HTML tag has run, the message will be lost. Use a DOM Ready trigger (not Page View) to ensure the listener is attached earlier.

Safari ITP blocks third-party cookies in iframes. If the iframe is on a third-party domain, Safari Intelligent Tracking Prevention will block cookies set by that domain inside the iframe. This affects any GA4 tag running inside the cross-origin iframe — another reason to track from the parent page via postMessage.