WegWorks Logo
WegWorksSimplifying Tomorrow
Home Services Blog → Adobe Analytics → Adobe Experience Platform → Adobe Target → AEM → Google Analytics 4 → Segment CDP About Contact

Fixing Cross-Domain Tracking After ITP 2.3 Fintech

When our client's conversion attribution dropped 67% overnight—specifically for Safari users—we traced the problem to Apple's ITP (Intelligent Tracking Prevention) 2.3 update silently breaking their cross-domain loan application funnel. Here's the technical deep-dive on how we diagnosed the issue and implemented a compliant fix.

67%
Attribution Loss (Safari)
3 Domains
In Loan Funnel
98.7%
Recovery Achieved

The Business Context

Our client, a consumer lending fintech (we'll call them "WegLoans"), has a loan application process spanning three domains:

Marketing needed to attribute completed loans back to campaigns, but Safari users were showing as "Direct" traffic with no campaign attribution—destroying their ability to optimize ad spend.

LOAN APPLICATION FUNNEL
═══════════════════════════════════════════════════════════════════════

   wegloans.com          apply.wegloans.com      secure.verifypartner.com
   ┌──────────────┐        ┌──────────────────┐      ┌────────────────────────┐
   │              │        │                  │      │                        │
   │  Marketing   │ ────►  │  Application     │ ───► │  KYC Verification     │
   │  Landing     │        │  Form            │      │  (3rd party)          │
   │              │        │                  │      │                        │
   └──────────────┘        └──────────────────┘      └────────────────────────┘
         │                        │                           │
         │                        │                           │
         ▼                        ▼                           ▼
   [ECID: ABC123]          [ECID: ???]                [ECID: ???]
   [Campaign: FB_Q4]       [Campaign: ???]            [Campaign: ???]

   BEFORE FIX: Safari users lose identity at each domain transition

Understanding the Problem: ITP 2.3

What ITP Does

Apple's Intelligent Tracking Prevention (ITP) is a privacy feature in Safari that restricts cross-site tracking. Key restrictions affecting analytics:

ITP Version Cookie Restriction Impact on Analytics
ITP 1.0 3rd-party cookies blocked after 24h Mild - most tracking still worked
ITP 2.0 3rd-party cookies blocked immediately Moderate - cross-domain issues begin
ITP 2.1 Client-side cookies capped at 7 days Severe - returning visitor tracking broken
ITP 2.3 URL decoration (query params) stripped Critical - cross-domain stitching fails

The ITP 2.3 Killer Feature

ITP 2.3 identifies "link decoration" (query parameters used for tracking) and strips them when navigating between sites classified as cross-site trackers. Adobe's adobe_mc parameter was being stripped on domain transitions.

How Adobe Analytics Cross-Domain Works

Adobe Analytics uses the adobe_mc query parameter to pass visitor identity between domains:

# Standard cross-domain URL structure
https://apply.wegloans.com/start?adobe_mc=MCMID%3D12345678%7CMCORGID%3DORG%40AdobeOrg%7CTS%3D1699876543

# Decoded components:
# MCMID=12345678          ← Experience Cloud ID
# MCORGID=ORG@AdobeOrg    ← Organization ID  
# TS=1699876543           ← Timestamp

When ITP strips this parameter, the destination domain can't identify the visitor and generates a new ECID—breaking the session continuity.

Affected Browsers

Safari (macOS)
⚠️ Affected
Safari (iOS)
⚠️ Affected
All iOS Browsers*
⚠️ Affected
Chrome/Firefox
✓ Not Affected

*All iOS browsers use WebKit engine, so Chrome/Firefox on iOS are also affected by ITP.

Diagnosing the Issue

Step 1: Segment by Browser

First, we segmented conversion data by browser to isolate the problem:

-- Adobe Analytics Data Warehouse query
SELECT 
    browser,
    COUNT(DISTINCT visitor_id) as visitors,
    SUM(CASE WHEN event = 'loan_complete' THEN 1 ELSE 0 END) as conversions,
    SUM(CASE WHEN marketing_channel = 'Direct' AND event = 'loan_complete' 
        THEN 1 ELSE 0 END) as direct_conversions
FROM loan_funnel_data
WHERE date_range = 'last_30_days'
GROUP BY browser
ORDER BY visitors DESC;

Results clearly showed the problem:

Browser Conversions "Direct" Attribution Direct %
Chrome Desktop 4,521 892 19.7%
Safari Desktop 2,103 1,789 85.1%
Safari iOS 3,847 3,412 88.7%
Chrome iOS 1,203 1,067 88.7%

Step 2: Verify Parameter Stripping

We added diagnostic logging to catch the exact moment of failure:

// Diagnostic code added to apply.wegloans.com
(function() {
    // Log incoming URL parameters
    const params = new URLSearchParams(window.location.search);
    const adobeMc = params.get('adobe_mc');
    
    // Check referrer domain
    const referrer = document.referrer;
    const referrerDomain = referrer ? new URL(referrer).hostname : 'none';
    
    // Log to custom dimension for analysis
    s.eVar99 = JSON.stringify({
        has_adobe_mc: !!adobeMc,
        referrer_domain: referrerDomain,
        user_agent: navigator.userAgent.substring(0, 100),
        timestamp: Date.now()
    });
    
    // Send diagnostic beacon
    if (!adobeMc && referrerDomain.includes('wegloans.com')) {
        // Parameter should exist but doesn't - ITP stripped it
        s.events = 'event99'; // Custom "ITP Impact" event
        s.tl(true, 'o', 'ITP Parameter Strip Detected');
    }
})();

Step 3: Confirm ECID Fragmentation

We queried the identity graph to see the fragmentation:

// Check for multiple ECIDs per user session
async function analyzeEcidFragmentation() {
    const query = `
        SELECT 
            session_id,
            COUNT(DISTINCT ecid) as ecid_count,
            ARRAY_AGG(DISTINCT ecid) as ecids,
            browser
        FROM analytics_hits
        WHERE date = CURRENT_DATE - 7
          AND page_url LIKE '%wegloans%'
        GROUP BY session_id, browser
        HAVING COUNT(DISTINCT ecid) > 1
    `;
    
    const results = await runQuery(query);
    
    // Found: Safari sessions averaging 2.3 ECIDs per session
    // Chrome sessions: 1.02 ECIDs per session
    console.log('Safari fragmentation:', 
        results.filter(r => r.browser.includes('Safari'))
               .reduce((sum, r) => sum + r.ecid_count, 0) / 
        results.filter(r => r.browser.includes('Safari')).length
    );
}

The Solution: Server-Side Identity Passing

ITP can't strip what it can't see. We moved identity passing from client-side URL parameters to server-side mechanisms.

Architecture Overview

AFTER FIX: Server-side identity passing
═══════════════════════════════════════════════════════════════════════

   wegloans.com                    apply.wegloans.com
   ┌──────────────────┐              ┌──────────────────────┐
   │                  │              │                      │
   │  1. User clicks  │              │  4. Server reads     │
   │     "Apply Now"  │              │     session cookie   │
   │                  │              │                      │
   │  2. JS captures  │   HTTP 302   │  5. Injects ECID     │
   │     ECID + data  │─────────────►│     into page        │
   │                  │   + Cookie   │                      │
   │  3. Sets 1st-    │              │  6. Analytics loads  │
   │     party cookie │              │     with correct ID  │
   │                  │              │                      │
   └──────────────────┘              └──────────────────────┘
                                              │
                                              │ Same pattern
                                              ▼
                                     secure.verifypartner.com

Implementation: Step by Step

Step 1: Capture Identity on Source Domain

// wegloans.com - capture ECID before navigation
document.querySelectorAll('a[href*="apply.wegloans.com"]').forEach(link => {
    link.addEventListener('click', function(e) {
        e.preventDefault();
        
        const targetUrl = this.href;
        const ecid = Visitor.getInstance('ORG@AdobeOrg').getMarketingCloudVisitorID();
        const mcorgid = 'ORG@AdobeOrg';
        const timestamp = Math.floor(Date.now() / 1000);
        
        // Capture additional context
        const sessionData = {
            ecid: ecid,
            orgId: mcorgid,
            ts: timestamp,
            campaign: s.campaign || '',
            referrer: document.referrer,
            landingPage: s.eVar1 || window.location.href
        };
        
        // Set first-party cookie (not affected by ITP)
        setCookie('ql_identity', btoa(JSON.stringify(sessionData)), {
            domain: '.wegloans.com',  // Shared across subdomains
            path: '/',
            secure: true,
            sameSite: 'Lax',
            maxAge: 1800  // 30 minutes
        });
        
        // Also send to server for cross-domain handoff
        fetch('/api/identity-handoff', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                ...sessionData,
                targetDomain: 'apply.wegloans.com'
            })
        }).then(() => {
            // Navigate after identity is stored server-side
            window.location.href = targetUrl;
        });
    });
});

Step 2: Server-Side Identity Storage

// Node.js API endpoint - /api/identity-handoff
const express = require('express');
const redis = require('redis');
const crypto = require('crypto');

const app = express();
const redisClient = redis.createClient();

app.post('/api/identity-handoff', async (req, res) => {
    const { ecid, orgId, ts, campaign, referrer, landingPage, targetDomain } = req.body;
    
    // Generate secure handoff token
    const handoffToken = crypto.randomBytes(32).toString('hex');
    
    // Store identity data in Redis (TTL: 5 minutes)
    const identityData = {
        ecid,
        orgId, 
        ts,
        campaign,
        referrer,
        landingPage,
        targetDomain,
        createdAt: Date.now(),
        userAgent: req.headers['user-agent'],
        ip: req.ip  // For fraud detection
    };
    
    await redisClient.setEx(
        `identity:${handoffToken}`,
        300,  // 5 minute TTL
        JSON.stringify(identityData)
    );
    
    // Set secure handoff cookie
    res.cookie('ql_handoff', handoffToken, {
        domain: '.wegloans.com',
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        maxAge: 300000  // 5 minutes
    });
    
    res.json({ success: true, token: handoffToken });
});

// Endpoint for apply.wegloans.com to retrieve identity
app.get('/api/identity-retrieve', async (req, res) => {
    const handoffToken = req.cookies.ql_handoff;
    
    if (!handoffToken) {
        return res.status(404).json({ error: 'No handoff token' });
    }
    
    const identityData = await redisClient.get(`identity:${handoffToken}`);
    
    if (!identityData) {
        return res.status(404).json({ error: 'Token expired or invalid' });
    }
    
    // Validate request origin
    const data = JSON.parse(identityData);
    if (data.userAgent !== req.headers['user-agent']) {
        // Potential token theft - log and reject
        console.warn('User agent mismatch on identity retrieve', {
            expected: data.userAgent,
            actual: req.headers['user-agent']
        });
        return res.status(403).json({ error: 'Validation failed' });
    }
    
    // Delete token after use (one-time use)
    await redisClient.del(`identity:${handoffToken}`);
    
    res.json(data);
});

Step 3: Inject Identity on Destination Domain

// apply.wegloans.com - retrieve and inject identity
(async function() {
    // Check if we have a handoff token
    const handoffCookie = getCookie('ql_handoff');
    
    if (handoffCookie) {
        try {
            const response = await fetch('/api/identity-retrieve', {
                credentials: 'include'
            });
            
            if (response.ok) {
                const identityData = await response.json();
                
                // Configure Visitor API with retrieved ECID
                Visitor.getInstance('ORG@AdobeOrg', {
                    // Force the ECID to match the source domain
                    marketingCloudServer: 'wegloans.d1.sc.omtrdc.net',
                    marketingCloudServerSecure: 'wegloans.d1.sc.omtrdc.net'
                });
                
                // Set the retrieved ECID
                window.s_account = 'wegloansprod';
                
                // Inject ECID before Visitor API initializes
                document.cookie = `AMCV_ORG@AdobeOrg=${encodeAMCVCookie(identityData.ecid)}; domain=.wegloans.com; path=/; secure`;
                
                // Restore campaign attribution
                if (identityData.campaign) {
                    s.campaign = identityData.campaign;
                }
                
                // Restore referrer for accurate channel attribution  
                if (identityData.referrer) {
                    s.referrer = identityData.referrer;
                }
                
                console.log('Identity restored from source domain', identityData.ecid);
            }
        } catch (error) {
            console.error('Identity retrieval failed', error);
            // Fall back to new ECID - better than blocking the page
        }
    }
    
    // Continue with normal Analytics initialization
    initializeAnalytics();
})();

function encodeAMCVCookie(ecid) {
    // AMCV cookie format
    return `MCMID|${ecid}|vVersion|5.5.0`;
}

Step 4: Handle Third-Party Domain (verifypartner.com)

For the completely separate third-party domain, we used a postMessage-based approach:

// apply.wegloans.com - Before redirect to verifypartner.com
function prepareThirdPartyHandoff() {
    const ecid = Visitor.getInstance('ORG@AdobeOrg').getMarketingCloudVisitorID();
    
    // Store in our API for cross-domain retrieval
    const handoffId = await createSecureHandoff({
        ecid: ecid,
        targetDomain: 'secure.verifypartner.com',
        returnUrl: window.location.href,
        applicationId: getApplicationId()
    });
    
    // Navigate with handoff ID (not the actual identity)
    window.location.href = `https://secure.verifypartner.com/verify?handoff=${handoffId}&return=${encodeURIComponent(window.location.href)}`;
}

// secure.verifypartner.com - Partner implements this
// They call our API with the handoff ID to get identity
async function retrievePartnerIdentity(handoffId) {
    const response = await fetch(
        `https://api.wegloans.com/partner/identity/${handoffId}`,
        {
            headers: {
                'X-Partner-Key': PARTNER_API_KEY
            }
        }
    );
    
    if (response.ok) {
        const data = await response.json();
        // Partner can now fire Analytics with our ECID
        // Using their own report suite but shared ECID
        return data.ecid;
    }
}

CNAME/First-Party Data Collection

We also implemented Adobe's first-party data collection to further mitigate ITP:

# DNS Configuration
# CNAME: metrics.wegloans.com -> wegloans.d1.sc.omtrdc.net

# Apache/Nginx proxy config for first-party collection
location /b/ss/ {
    proxy_pass https://wegloans.d1.sc.omtrdc.net/b/ss/;
    proxy_set_header Host wegloans.d1.sc.omtrdc.net;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
// Analytics configuration with first-party tracking
s.trackingServer = 'metrics.wegloans.com';
s.trackingServerSecure = 'metrics.wegloans.com';

// Visitor API configuration
Visitor.getInstance('ORG@AdobeOrg', {
    trackingServer: 'metrics.wegloans.com',
    trackingServerSecure: 'metrics.wegloans.com'
});

Results

98.7%
Safari Attribution Recovered
2.1 → 1.03
ECIDs per Safari Session
Full
Attribution Restored

After implementation, Safari "Direct" attribution dropped from 87% to 12% (in line with Chrome's baseline). The marketing team could finally see which campaigns drove loan completions across all browsers.

Privacy Considerations

⚖️ Compliance Notes

This solution maintains user privacy while enabling legitimate first-party analytics:

  • Only first-party cookies used (no third-party tracking)
  • Identity only shared between properties user explicitly navigates to
  • Handoff tokens are one-time-use and expire quickly
  • Solution respects user's browser privacy settings for third-party contexts
  • All data collection disclosed in privacy policy

Lessons Learned

1. Browser Privacy Changes Are Ongoing

ITP continues to evolve. Build your analytics architecture with adaptability in mind—server-side solutions are more resilient than client-side parameter passing.

2. Segment Everything by Browser

Safari represents 25-35% of web traffic. If you're not segmenting analytics by browser, you're missing critical data quality issues.

3. First-Party Is the Future

Third-party cookies are dying across all browsers. Invest in first-party data collection infrastructure now.

4. Test Cross-Domain Flows Regularly

This issue existed for weeks before detection. Automated testing of cross-domain visitor stitching would have caught it immediately.

Need Help with Analytics Privacy Compliance?

Browser privacy changes are constantly evolving. Contact WegWorks for expert assistance with first-party data collection, cross-domain tracking, and ITP-resilient analytics implementations.