Back to Insights
Data Engineering 8/16/2024 5 min read

Server-Side First-Party Cookie Management & Cross-Domain Tracking for GA4 with GTM & Cloud Run

Server-Side First-Party Cookie Management & Cross-Domain Tracking for GA4 with GTM & Cloud Run

You've built a powerful server-side Google Analytics 4 (GA4) pipeline, leveraging Google Tag Manager (GTM) Server Container on Cloud Run for data enrichment, transformations, and granular consent management. This architecture provides robust data collection, enhanced privacy control, and the ability to activate richer insights.

However, a persistent challenge in modern analytics is the reliable management of user identity, particularly with the deprecation of third-party cookies and the increasing limitations placed on client-side cookies by Intelligent Tracking Prevention (ITP) in browsers like Safari and Firefox.

The problem is multi-faceted:

  • Cookie Longevity: Client-side cookies, especially those set by JavaScript, face shorter lifespans, leading to inflated user counts and broken user journeys in analytics.
  • First-Party vs. Third-Party Context: Ensuring your analytics identifiers are truly first-party to avoid browser restrictions.
  • Cross-Domain Tracking: Maintaining a consistent user identity when users navigate between related domains (e.g., shop.example.com and blog.example.com).
  • GTM Server Container's Default Behavior: While GTM SC helps with first-party context, its default GA4 client often sets cookies on its own serving domain (e.g., analytics.example.com), which might not be directly accessible by your primary website domain (www.example.com) without careful configuration.

Relying solely on client-side JavaScript for _ga cookie management risks losing valuable user journey data and undermining the accuracy of your GA4 reports. This post will guide you through mastering first-party cookie management and resilient cross-domain tracking directly from your server-side GA4 pipeline on Google Cloud.

Why Server-Side for Cookie Management?

Moving cookie management server-side offers significant advantages:

  1. Extended Cookie Lifespan: Server-set cookies can often bypass some browser ITP limitations, allowing for longer-lasting identifiers.
  2. True First-Party Context: By setting cookies from your own server-side endpoint on your custom tracking subdomain (e.g., analytics.yourdomain.com), you establish a robust first-party context that browsers are less likely to restrict.
  3. Enhanced Control: You gain programmatic control over cookie attributes (Domain, Path, Expires, SameSite, Secure, HttpOnly), allowing for more precise and secure management.
  4. Resilience: Less susceptible to ad-blockers and browser updates impacting client-side JavaScript.
  5. Seamless Cross-Domain Tracking: Facilitate consistent user IDs across different subdomains or even main domains within your ecosystem.

The Challenge: GTM SC and First-Party Cookies

When you deploy your GTM Server Container to analytics.example.com, the default GA4 client within it will attempt to set the _ga cookie on analytics.example.com.

Scenario:

  • Your main website: www.example.com
  • Your GTM SC endpoint: analytics.example.com
  • A user visits www.example.com. The GTM Web Container sends an event to analytics.example.com.
  • The GTM SC sets a _ga cookie for analytics.example.com.
  • If the user navigates to blog.example.com (also tracked by www.example.com's client-side GTM), blog.example.com might try to read its own _ga cookie set on www.example.com or .example.com. The _ga cookie set by analytics.example.com isn't automatically shared.

The goal is to ensure the GTM SC sets a single _ga cookie that is valid for the entire root domain (e.g., .example.com), making it accessible and consistent across www.example.com, blog.example.com, and shop.example.com.

Our Solution Architecture: Server-Side Cookie Logic

We'll extend our server-side GA4 pipeline to include a dedicated mechanism for _ga cookie management. This involves a custom GTM Server Container template that intercepts incoming requests, potentially calls a lightweight Cloud Run service for complex cookie logic, and then issues a Set-Cookie HTTP header in the response, ensuring the _ga cookie is correctly set for the top-level domain.

graph TD
    A[Browser/Client-Side] -- HTTP Request (with _ga if exists) --> B(GTM Web Container);
    B -- Event (Data, potentially _gl linker) --> C(GTM Server Container on Cloud Run);
    C --> D{Custom Tag/Variable: Read/Manage _ga Cookie};
    D -->|Optional: External Service Call| E[Cookie Management Service (Python on Cloud Run)];
    E -- Returns desired _ga value/expiry --> D;
    D -->|Set _ga cookie header| C;
    C -- HTTP Response (with Set-Cookie:_ga) --> A;
    C -- Dispatch Enriched Event --> F[Google Analytics 4];
    C -- Other Tags/Services --> G[Other Analytics/Ad Platforms];

    subgraph Server-Side Cookie Logic
        D; E;
    end

Core Components Deep Dive & Implementation Steps

1. Understanding GTM Server Container's Built-in Client ID Management

The GTM Server Container's default GA4 Client (and Universal Analytics Client) attempts to manage the _ga cookie. However, it sets it on the domain where the GTM SC endpoint resides. For true first-party domain-wide cookies, we need to override this behavior.

A crucial aspect: when a GTM Web Container sends an event to GTM SC, it usually includes the existing _ga cookie in the Cookie header of the HTTP request. Your GTM SC can read this cookie.

2. Reading Existing _ga Cookies in GTM Server Container

Use a GTM SC Custom Variable Template to read incoming cookies from the Cookie header.

GTM SC Custom Variable Template: Read Incoming Cookies

const getRequestHeader = require('getRequestHeader');
const log = require('log');

// This custom variable template reads a specific cookie from the incoming request.
// Configuration fields:
//   - cookieName: Text input for the name of the cookie to read (e.g., '_ga')

const cookieName = data.cookieName;
const cookieHeader = getRequestHeader('Cookie');

if (!cookieHeader || !cookieName) {
    log(`No Cookie header or cookieName configured.`, 'DEBUG');
    data.gtmOnSuccess(undefined); // Return undefined if no cookie or name
    return;
}

const cookies = cookieHeader.split(';');
for (let i = 0; i < cookies.length; i++) {
    const cookie = cookies[i].trim();
    if (cookie.startsWith(cookieName + '=')) {
        const cookieValue = cookie.substring(cookieName.length + 1);
        log(`Found cookie '${cookieName}': ${cookieValue.substring(0, 20)}...`, 'DEBUG');
        data.gtmOnSuccess(cookieValue);
        return;
    }
}

log(`Cookie '${cookieName}' not found in request header.`, 'DEBUG');
data.gtmOnSuccess(undefined); // Return undefined if cookie not found

Implementation in GTM SC:

  1. Create a Custom Variable Template named Read Incoming Cookies.
  2. Paste the code. Add permission: Access request headers.
  3. Create a Custom Variable (e.g., {{Incoming GA Client ID}}) using this template, configuring cookieName to _ga. This variable will give you the client ID from the browser's _ga cookie.

3. Setting/Updating First-Party _ga Cookies from GTM Server Container

The core of server-side cookie management is using the setResponseHeader API in GTM SC to send a Set-Cookie header back to the browser. This header instructs the browser to set or update the cookie.

GTM SC Custom Tag Template: Set GA Client ID Cookie

This template will:

  1. Receive an optional client ID. If not provided, it generates a new one.
  2. Construct a Set-Cookie header with appropriate attributes for first-party, domain-wide tracking.
const setResponseHeader = require('setResponseHeader');
const getEventData = require('getEventData');
const log = require('log');
const generateGuid = require('generateGuid'); // GTM SC utility for GUIDs

// This custom tag template sets or updates the _ga cookie in the response.
// Configuration fields:
//   - clientIDToSet: Text input for the client ID to set (e.g., from an incoming _ga, or a generated one)
//   - cookieDomain: Text input for the domain to set the cookie (e.g., '.yourdomain.com')
//   - cookieExpiresDays: Number input for cookie expiration (e.g., 730 for 2 years)
//   - cookiePath: Text input for cookie path (usually '/')
//   - cookieSameSite: Text input for SameSite attribute (e.g., 'Lax', 'Strict', 'None')
//   - cookieSecure: Boolean input if cookie should be Secure (recommended: true)
//   - cookieHttpOnly: Boolean input if cookie should be HttpOnly (recommended: false for client-side JS access)

const clientIDToSet = data.clientIDToSet;
const cookieDomain = data.cookieDomain;
const cookieExpiresDays = data.cookieExpiresDays || 730; // Default 2 years
const cookiePath = data.cookiePath || '/';
const cookieSameSite = data.cookieSameSite || 'Lax';
const cookieSecure = data.cookieSecure !== false; // Default true
const cookieHttpOnly = data.cookieHttpOnly === true; // Default false

let currentClientID = clientIDToSet;

// If no client ID is explicitly provided, generate a new one
if (!currentClientID) {
    // Generate a new GA client ID (e.g., GA1.1.RANDOM_ID.TIMESTAMP)
    // This format mimics GA's client ID structure
    currentClientID = 'GA1.1.' + generateGuid() + '.' + Math.round(Date.now() / 1000);
    log('Generated new GA Client ID: ' + currentClientID.substring(0, 20) + '...', 'INFO');
}

// Calculate expiration date
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + cookieExpiresDays);
const expiresUTC = expirationDate.toUTCString();

// Construct the Set-Cookie header
let cookieHeader = `_ga=${currentClientID}; Path=${cookiePath}; Expires=${expiresUTC}; Domain=${cookieDomain}`;

if (cookieSecure) {
    cookieHeader += '; Secure';
}
if (cookieHttpOnly) {
    cookieHeader += '; HttpOnly';
}
if (cookieSameSite) {
    cookieHeader += `; SameSite=${cookieSameSite}`;
}

setResponseHeader('Set-Cookie', cookieHeader);
log(`Set-Cookie header for _ga sent: ${cookieHeader}`, 'INFO');

// Optionally, add the client ID to event data for subsequent tags
setInEventData('ga_client_id_server', currentClientID, true);

data.gtmOnSuccess();

Implementation in GTM SC:

  1. Create a Custom Tag Template named Set GA Client ID Cookie.
  2. Paste the code. Add permissions: Set response headers, Access event data, Generate GUID.
  3. Create a Custom Tag (e.g., GA Client ID Cookie Setter) using this template.
  4. Configuration:
    • clientIDToSet: Use {{Incoming GA Client ID}} (the variable you created earlier). This ensures if an existing cookie is found, it's renewed.
    • cookieDomain: yourdomain.com (note the leading dot is usually added automatically by browsers for RFC 2109, but explicitly .yourdomain.com is safer in some contexts for cross-subdomain. GTM SC will just output what you put). Let's use example.com assuming it's the root.
    • cookieExpiresDays: 730 (2 years)
    • cookiePath: /
    • cookieSameSite: Lax (recommended for general tracking)
    • cookieSecure: true
    • cookieHttpOnly: false (so client-side JS can still read it if needed)
  5. Trigger: Fire this tag on Initialization - All Pages or All Events (ensure it runs early in the event lifecycle, before your GA4 event tags).

This tag will now read any existing _ga cookie, renew it with a long expiry for your entire domain, or generate a new one if none exists. The ga_client_id_server variable will contain the client ID that was just set/renewed, which you can then use in your GA4 tags.

4. Cross-Domain Tracking: Server-Side _gl Linker Handling

When a user clicks a link from example.com to anotherdomain.com, client-side GTM typically appends a _gl parameter to the URL to carry the client ID across. Your GTM Server Container needs to be able to extract this client ID if it's present.

Updating Set GA Client ID Cookie Tag for _gl:

You can enhance the Set GA Client ID Cookie tag to prioritize _gl if it's available.

  1. Read _gl from Query Parameters: Add a new GTM SC Custom Variable {{URL Query - _gl}} to extract the _gl parameter.

    • Variable Type: URL
    • Component Type: Query
    • Query Key: _gl
  2. Modify Set GA Client ID Cookie Tag's clientIDToSet: Prioritize _gl's client ID. In your Set GA Client ID Cookie tag's configuration, change clientIDToSet to: {{URL Query - _gl}}

    • Explanation: If _gl is present, the Custom Variable will return its value. Otherwise, it will be undefined, and your tag will fall back to {{Incoming GA Client ID}} or generate a new one.
    • Note: The _gl parameter is an encoded string. The GTM SC's GA4 Client will automatically parse this and extract the underlying _ga client ID. You usually don't need to manually decode _gl for the _ga client ID unless you're bypassing the GA4 client entirely. For simplicity, let the GA4 Client handle _gl first, and if not present, then use your _ga cookie logic.

Refined Logic for Client ID Priority: The most robust approach is for the GA4 Client itself to process _gl and the _ga cookie first. Then, your custom cookie setter tag should confirm that the GA4 Client has used a client ID, and if so, re-set that same client ID for your first-party domain.

GTM SC Flow with Prioritization:

  1. GA4 Client (built-in): First processes _gl (if present), then _ga cookie from request. It will establish a client ID for the event.
  2. Custom Variable ({{GA4 Client ID}}): Extract the client ID that the GA4 Client ultimately used. This is often available via {{Client Name}}.clientId if you use a Client Name type variable. Or directly from the eventData after the GA4 client has processed it.
    • Best way to get the final Client ID: Create a Custom Variable of type Client Name (e.g., GA4 Client), and then create another Custom Variable of type Event Data pointing to _event_metadata.client_id. This client_id is the one the GA4 Client ultimately settled on.
  3. Set GA Client ID Cookie Custom Tag:
    • Configure clientIDToSet to {{Event Data - _event_metadata.client_id}}.
    • This ensures that the cookie you set server-side is the same client ID that GA4 will use for the current hit, maintaining consistency.

This ensures that whether the client ID comes from _gl, an existing _ga cookie, or is newly generated, your server-side cookie setter acts on the final, resolved client ID for that event.

5. Using the Server-Managed Client ID in Your GA4 Tag

Once your Set GA Client ID Cookie tag has run and populated ga_client_id_server in eventData, you can instruct your GA4 Configuration tag to use this server-managed client ID.

GTM SC GA4 Configuration Tag:

  1. In your GA4 Configuration Tag, under "Fields to Set", add:
    • Field Name: client_id
    • Value: {{Event Data - ga_client_id_server}} (your custom variable from the cookie setter tag).
  2. Also ensure you disable GA4's default cookie writing behavior in the tag:
    • Field Name: _set_cookies
    • Value: false

This tells the GA4 tag to use the client_id you've explicitly provided (which your server-side logic has ensured is present and correctly set via the Set-Cookie header), rather than letting GA4 try to manage cookies on its own.

Benefits of This Server-Side Approach

  • Reliable User Identity: Achieve more accurate user counts and session continuity in GA4 by controlling _ga cookie lifespan and attributes.
  • Future-Proofing: Reduce reliance on client-side mechanisms that are increasingly vulnerable to browser restrictions.
  • Enhanced Cross-Domain Tracking: Seamlessly stitch user journeys across multiple domains under your control, providing a holistic view of user behavior.
  • Centralized Control: Manage all aspects of your primary analytics identifier from a single, server-side environment.
  • Improved Compliance: With HttpOnly or custom SameSite settings, you can enhance the security and privacy aspects of your identifiers.

Advanced Considerations

  • Client-Side Cookie Access: If you set HttpOnly: true on your _ga cookie, client-side JavaScript (including your client-side GTM Web Container) will not be able to read or modify it. Ensure your client-side setup doesn't depend on directly reading _ga if you choose this.
  • SameSite Attribute:
    • Lax: Default, generally safe. Sends cookies with top-level navigations and cross-site requests (GET) only.
    • Strict: Most restrictive. Only sends cookies with requests originating from the same site. Breaks cross-domain linking.
    • None: Allows cross-site requests but requires Secure. Use with caution as it has privacy implications. For standard GA4 tracking, Lax or no explicit SameSite (which defaults to Lax in modern browsers) is usually appropriate.
  • User-ID Integration: Combine this robust client ID management with GA4's user_id feature (when an authenticated user ID is available) for the most comprehensive and privacy-safe user stitching.
  • Monitoring: Use Cloud Logging to monitor your GTM SC for logs from your custom tags to ensure cookies are being set as expected. Check browser developer tools to confirm _ga cookies are correctly updated with the desired domain and expiry.

Conclusion

Achieving reliable, long-lasting user identity and seamless cross-domain tracking is fundamental to accurate analytics. By taking control of _ga cookie management directly from your GTM Server Container on Cloud Run, you establish a resilient, first-party foundation for your GA4 data. This server-side approach empowers you to bypass client-side limitations, future-proof your analytics against evolving browser privacy features, and gain a clearer, more consistent view of your customer journeys across your digital properties. Embrace server-side cookie management to elevate the quality and longevity of your GA4 data.