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.
The Business Context
Our client, a consumer lending fintech (we'll call them "WegLoans"), has a loan application process spanning three domains:
- wegloans.com — Marketing site with rate calculator
- apply.wegloans.com — Loan application (different subdomain, same root)
- secure.verifypartner.com — Third-party KYC verification (completely different domain)
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
*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
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.