Implementation Guide
🏗️ Why Native Integration
Section titled “🏗️ Why Native Integration”Section 01 introduces the case for native identity management. This section makes it concrete: here is why you build UIAF into your application rather than bolting it on via tags.
Ad blockers cannot block your own code. Filter lists target known tracking domains. googletagmanager.com is on every list. yourdomain.com/api/collect is not — it is indistinguishable from any other API call your application makes.
Server-set cookies survive Safari ITP. A Set-Cookie header from your own server (same IP as the website) gets 400-day lifetime on Safari. JavaScript cookies get 7 days (24 hours with click ID link decoration). Your web server is, by definition, the same IP. No CNAME needed, no third-party infrastructure, no IP alignment worries.
Application-priority execution. Your server processes the request before any client-side script loads. By the time GTM initializes, your middleware has already read the existing cookie, captured UTMs and referrer from HTTP headers, and passed that context to the page.
Full HTTP access. Server-side code reads Referer, Cookie, and User-Agent headers, sets Set-Cookie, and accesses URL parameters — all before any client-side processing begins.
No external dependency. UIAF runs in your application process. If your application is up, UIAF is up. If your analytics breaks because a CDN is down, you have an external dependency problem.
⚙️ Integration Architecture
Section titled “⚙️ Integration Architecture”The server reads HTTP headers and refreshes existing consented cookies. The client reads consent, resolves identity, requests a server-set cookie for new UIDs, and delivers payloads.
🖥️ Server-Rendered Applications (PHP/WordPress, Django, Rails, .NET, Laravel)
Section titled “🖥️ Server-Rendered Applications (PHP/WordPress, Django, Rails, .NET, Laravel)”The most natural fit. Every page request passes through application code.
Server-side: middleware/filter. Intercept every request before the controller renders the page.
Client-side: inline script in page template. Handles storage sync, consent reading, and endpoint delivery.
Mock Code: Server Middleware
Section titled “Mock Code: Server Middleware”// PSEUDOCODE -- Server middleware
function uiafMiddleware(request, response, next): uid = getCookieFromRequest(request, "uiaf_uid") consent_tier = parseConsentTier(request, UIAF_CONFIG) // see below
// T4: dormant — do not process if consent_tier >= 4: request.uiaf = { uid: null, pre_consent: null, consent_tier: 4 } next() return
// Refresh existing cookie if consent allows identity (T0/T1/T2) if uid != null AND consent_tier <= 2: setServerCookie(response, "uiaf_uid", uid, { maxAge: 34560000, path: "/", secure: true, sameSite: "Lax", httpOnly: false })
// Server-side pre-consent capture (HTTP headers, no device storage) pre_consent = { utms: extractUTMs(request.query), referrer: extractDomain(request.headers.get("Referer")), landing_page: request.path }
// Inject into template — client reads this after consent check request.uiaf = { uid, pre_consent, consent_tier } next()
// Parse consent tier from server-side signals// Config is set once per site deployment, not per requestfunction parseConsentTier(request, config): // 1. Check GPC (Sec-GPC header) if request.headers.get("Sec-GPC") == "1" AND config.gpc_jurisdiction: if config.gpc_strict: return 4 // Strict: treat GPC as full opt-out else: return 2 // Conservative: T2 minimum (no ad data, analytics OK) // In non-binding jurisdictions, GPC is noted in the payload but does not override tier
// 2. Read CMP cookie consent_cookie = getCookieFromRequest(request, config.cmp_cookie_name)
// 3. No CMP cookie if consent_cookie == null: if config.cmp_expected: return 3 // CMP on site but cookie not set yet = pre-consent else: return 0 // No CMP on this site = T0
// 4. Parse CMP cookie try: consent = parseCMPCookie(consent_cookie) catch (error): return config.corrupted_cookie_tier // Fail safe: configurable (default T3)
if consent.statistics AND consent.marketing: return 1 if consent.statistics: return 2 if not consent.statistics AND not consent.marketing: return 4 // Reject all return 3 // Partial or ambiguous
// Site-level configuration (set once per deployment, not per request)// config = {// cmp_expected: true, // Does this site have a CMP?// cmp_cookie_name: "CookieConsent", // Cookiebot. OneTrust: "OptanonConsent"// gpc_jurisdiction: true, // Is this site subject to CA/CO/CT/TX/OR?// gpc_strict: false, // true = GPC -> T4, false = GPC -> T2// corrupted_cookie_tier: 3 // Tier on parse failure: 3 (pre-consent) or 4 (dormant)// }Mock Code: Client-Side Template Block
Section titled “Mock Code: Client-Side Template Block”// PSEUDOCODE -- Inline in page template// Server-rendered pages use a self-contained IIFE with its own state.// The consent-transition logic is inlined (not shared with SPA module scope)// because an IIFE's closure is isolated from other scripts on the page.
(function() { var ctx = {{ uiaf_context_json }}; var _consent = readConsentState(); // section 07: GPC > CMP > GCM var _identity = null;
// Consent change handler — operates on THIS closure's _consent and _identity function handleTransition(oldConsent, newConsent) { if (newConsent.tier >= 4) { // attribution defaults to {first_touch: null, last_touch: null, count: 0, is_new_touch: false} sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, page: getPageContext() }, newConsent)); clearAllUIAFStorage(); _identity = null; return; } if (oldConsent.tier >= 3 && newConsent.tier <= 2) { // Upgrade: create identity now _identity = resolveIdentity(); if (_identity.is_new) { fetch("/api/uiaf/cookie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uid: _identity.uid }), credentials: "include" }); } syncAllStorage(_identity.uid); var attribution = captureAttribution(window.location.href, newConsent); sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: attribution, page: getPageContext() }, newConsent)); return; } if (oldConsent.tier == 2 && newConsent.tier <= 1) { // T2 -> T1: re-capture click IDs from current URL if still present var attribution = captureAttribution(window.location.href, newConsent); sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: attribution, page: getPageContext() }, newConsent)); return; } if (oldConsent.tier <= 1 && newConsent.tier == 2) { removeClickIdsFromStorage(); clearRetryQueue(); sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: loadAttribution(), page: getPageContext() }, newConsent)); return; } if (oldConsent.tier <= 2 && newConsent.tier == 3) { clearAllUIAFStorage(); _identity = { uid: null, session_id: getOrCreateSessionId(), resolution_method: "ephemeral", confidence: "low", is_new: false }; // attribution defaults to {first_touch: null, last_touch: null, count: 0, is_new_touch: false} sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, page: getPageContext() }, newConsent)); return; } // Same tier or unhandled transition: just send consent_update sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: loadAttribution(), page: getPageContext() }, newConsent)); }
// Register listener BEFORE T4 check (T4 user can re-consent) onConsentChange(function(newConsent) { handleTransition(_consent, newConsent); _consent = newConsent; });
if (_consent.tier >= 4) { return; } // T4: dormant (listener still active)
if (_consent.tier <= 2) { _identity = resolveIdentity();
if (_identity.is_new) { fetch("/api/uiaf/cookie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uid: _identity.uid }), credentials: "include" }); }
syncAllStorage(_identity.uid); var attribution = captureAttribution(window.location.href, _consent);
sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, attribution: attribution, pre_consent: ctx.pre_consent, page: getPageContext() }, _consent ));
} else if (_consent.tier == 3) { _identity = { uid: null, session_id: getOrCreateSessionId(), resolution_method: "ephemeral", confidence: "low", is_new: false }; sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, pre_consent: ctx.pre_consent, page: getPageContext() }, _consent )); }})();Platform-Specific Hooks
Section titled “Platform-Specific Hooks”| Platform | Middleware mechanism | Where to register |
|---|---|---|
| PHP / Laravel | Middleware class | app/Http/Kernel.php |
| WordPress | init hook or mu-plugins | functions.php or must-use plugin |
| Django | Middleware class | MIDDLEWARE in settings.py |
| Rails | before_action | ApplicationController |
| .NET Core | Middleware pipeline | Program.cs |
💻 Single Page Applications (React, Vue, Angular)
Section titled “💻 Single Page Applications (React, Vue, Angular)”SPAs handle navigation client-side after an initial page load. The challenge: the initial HTML may be a static shell with no server to set cookies.
Init on app mount, not on every route change. Resolve identity once. Hook the router for virtual pageviews and attribution changes.
Server cookie options: If a BFF serves the HTML, set Set-Cookie there. If the HTML is static, call the cookie API endpoint (/api/uiaf/cookie) on mount to get a server-set cookie, or use an edge function.
Mock Code: App Initialization + Router Hook
Section titled “Mock Code: App Initialization + Router Hook”// PSEUDOCODE -- Runs once on app mount// Module-scoped state, accessible to router hook and consent listenervar _identity = nullvar _consent = null
function initializeUIAF(): _consent = readConsentState() // section 07: GPC > CMP > GCM
// Register consent listener BEFORE T4 check — a T4 user can re-consent onConsentChange(function(newConsent) { handleConsentTransition(_consent, newConsent) _consent = newConsent })
if _consent.tier >= 4: return // T4: dormant (but listener is active)
if _consent.tier <= 2: _identity = resolveIdentity() // returns {uid, session_id, ...}
if _identity.is_new: fetch("/api/uiaf/cookie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uid: _identity.uid }), credentials: "include" })
syncAllStorage(_identity.uid) installRouteHookIfNeeded() // track subsequent SPA navigations var attribution = captureAttribution(window.location.href, _consent)
sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, attribution: attribution, page: getPageContext() }, _consent )) else: // T3: session-only (route hook installed on upgrade if consent changes) _identity = { uid: null, session_id: getOrCreateSessionId(), resolution_method: "ephemeral", confidence: "low", is_new: false } sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, page: getPageContext() }, _consent ))
// Consent change handler — implements section 07 state machinefunction handleConsentTransition(oldConsent, newConsent):
// === DOWNGRADES ===
if newConsent.tier >= 4: // Any -> T4: send final consent_update, then clear everything // attribution defaults to {first_touch: null, last_touch: null, count: 0, is_new_touch: false} sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, page: getPageContext() }, newConsent )) clearAllUIAFStorage() // cookies, localStorage, sessionStorage, retry queue _identity = null return
if oldConsent.tier <= 1 AND newConsent.tier == 2: // T1 -> T2: remove click IDs from stored attribution, keep UID removeClickIdsFromStorage() // clear click_ids from uiaf_attribution clearRetryQueue() // queued T1 payloads may contain click IDs sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: loadAttribution(), page: getPageContext() }, newConsent )) return
if oldConsent.tier <= 2 AND newConsent.tier == 3: // T1/T2 -> T3: delete UID and attribution from all client storage clearAllUIAFStorage() _identity = { uid: null, session_id: getOrCreateSessionId(), resolution_method: "ephemeral", confidence: "low", is_new: false } // attribution defaults to {first_touch: null, last_touch: null, count: 0, is_new_touch: false} sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, page: getPageContext() }, newConsent )) return
// === UPGRADES ===
if oldConsent.tier >= 3 AND newConsent.tier <= 2: // T3/T4 -> T1 or T2: user just granted consent — full initialization _identity = resolveIdentity() if _identity.is_new: fetch("/api/uiaf/cookie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uid: _identity.uid }), credentials: "include" }) syncAllStorage(_identity.uid) var attribution = captureAttribution(window.location.href, newConsent) // Install route hook if not already installed (SPA/SSR upgrade path) installRouteHookIfNeeded() sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: attribution, page: getPageContext() }, newConsent )) return
if oldConsent.tier == 2 AND newConsent.tier <= 1: // T2 -> T1: re-capture click IDs from current URL if still present var attribution = captureAttribution(window.location.href, newConsent) sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: attribution, page: getPageContext() }, newConsent )) return
// Same tier or unhandled transition: just send consent_update sendToEndpoint(assemblePayload( { event: "consent_update", identity: _identity, attribution: loadAttribution(), page: getPageContext() }, newConsent ))
// Route hook installation — called on init AND on consent upgradevar _routeHookInstalled = falsefunction installRouteHookIfNeeded(): if _routeHookInstalled: return _routeHookInstalled = true router.onRouteChange(onRouteChange) // or equivalent for your framework
// Router hook -- runs on every client-side navigationfunction onRouteChange(newUrl, previousUrl): if _consent == null OR _consent.tier >= 4: return
// Pass previousUrl as referrer (SPA navigation has no document.referrer update) var attribution = captureAttribution(newUrl, _consent, previousUrl)
sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, attribution: attribution, page: { url: newUrl, path: extractPath(newUrl), referrer: previousUrl, title: document.title } }, _consent ))📄 Static Sites (Hugo, Jekyll, Astro Static, GitHub Pages)
Section titled “📄 Static Sites (Hugo, Jekyll, Astro Static, GitHub Pages)”No server-side code. Every response is a pre-built file.
Edge function (recommended). Cloudflare Workers, Vercel Edge Middleware, Netlify Edge Functions, or AWS CloudFront Functions intercept the response and set Set-Cookie before it reaches the browser.
Client-side fallback. If edge compute is unavailable (GitHub Pages, plain S3 bucket), all logic runs in JavaScript. Safari identity persistence drops to 7 days. identity.resolution_method will report new (first visit) or cookie — downstream systems see identity.confidence: "high" or "low" indicating reduced reliability.
Mock Code: Edge Function
Section titled “Mock Code: Edge Function”// PSEUDOCODE -- Edge function (Cloudflare Workers / Vercel Edge)
function handleRequest(request): response = fetch(request) // get static page from origin
uid = getCookieFromRequest(request, "uiaf_uid") consent_tier = parseConsentTier(request, UIAF_CONFIG) // see below
// Only refresh existing cookie if consent allows identity persistence if uid != null AND consent_tier <= 2: response.headers.append("Set-Cookie", "uiaf_uid=" + uid + "; Max-Age=34560000; Path=/; Secure; SameSite=Lax") // If no uid or consent prohibits identity: do NOT set a cookie. // Client-side code handles UID creation after consent is verified.
return response
// Parse consent tier — see parseConsentTier above (same function, same config)⚡ Hybrid/SSR Frameworks (Next.js, Nuxt, SvelteKit, Remix)
Section titled “⚡ Hybrid/SSR Frameworks (Next.js, Nuxt, SvelteKit, Remix)”Best of both worlds: server middleware sets the cookie on every request, client hydration handles subsequent navigation.
Server middleware: Next.js middleware.ts, Nuxt server middleware, SvelteKit hooks.server.ts, Remix loaders.
Client hydration: After the server renders HTML, the client syncs storage and sets up router hooks.
Mock Code: Server Middleware + Client Hydration
Section titled “Mock Code: Server Middleware + Client Hydration”// PSEUDOCODE -- Server middlewarefunction middleware(request): response = NextResponse.next() uid = request.cookies.get("uiaf_uid") consent_tier = parseConsentTier(request, UIAF_CONFIG) // GPC + CMP cookie (see parseConsentTier above)
// T4: dormant — do not process if consent_tier >= 4: response.locals.uiaf_context = { uid: null, pre_consent: null, consent_tier: 4 } return response
// Refresh existing cookie if consent allows identity (T0/T1/T2) if uid != null AND consent_tier <= 2: response.cookies.set("uiaf_uid", uid, { maxAge: 34560000, path: "/", secure: true, sameSite: "lax", httpOnly: false }) // Do NOT create new UIDs server-side — client handles creation via cookie API
// Server-side pre-consent capture (HTTP headers, not device storage) pre_consent = captureUTMsAndReferrer(request)
// Inject context into rendered page template (NOT response headers) response.locals.uiaf_context = { uid, pre_consent, consent_tier } return response
// PSEUDOCODE -- Client hydration (runs once after mount)// Uses the same module-scoped _identity / _consent as SPA (see above).// handleConsentTransition is shared — it already mutates _identity.
function onMount(): var ctx = readServerContext() // from server-rendered template, NOT headers _consent = readConsentState() // section 07: GPC > CMP > GCM
// Register consent listener BEFORE T4 check (T4 user can re-consent) onConsentChange(function(newConsent) { handleConsentTransition(_consent, newConsent) _consent = newConsent })
if _consent.tier >= 4: return // T4: dormant (but listener is active)
if _consent.tier <= 2: _identity = resolveIdentity()
if _identity.is_new: fetch("/api/uiaf/cookie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uid: _identity.uid }), credentials: "include" })
syncAllStorage(_identity.uid) installRouteHookIfNeeded() // shared route hook (same as SPA)
var attribution = captureAttribution(window.location.href, _consent) sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, attribution: attribution, pre_consent: ctx.pre_consent, page: getPageContext() }, _consent )) else: // T3: session-only _identity = { uid: null, session_id: getOrCreateSessionId(), resolution_method: "ephemeral", confidence: "low", is_new: false } sendToEndpoint(assemblePayload( { event: "page_view", identity: _identity, pre_consent: ctx.pre_consent, page: getPageContext() }, _consent ))The server middleware refreshes existing cookies and provides context via template injection. New UIDs get server-set cookies via the client-side cookie API call.
🍪 Server-Side Cookie Proxy for ITP Resilience
Section titled “🍪 Server-Side Cookie Proxy for ITP Resilience”Three approaches for 400-day cookies on Safari.
1. Application-Level (Best). Your web server refreshes existing cookies and provides a /api/uiaf/cookie endpoint for new UIDs. Both use Set-Cookie headers from your server’s IP, giving 400-day lifetime on Safari. No extra infrastructure. This is the default approach in every pattern above.
2. Reverse Proxy Path. Route yourdomain.com/uiaf/collect through a reverse proxy to your sGTM endpoint. The browser sees your domain, your IP. Safari treats it as first-party.
// PSEUDOCODE -- reverse proxy rulelocation /uiaf/collect { proxy_pass https://your-sgtm-server.example.com/collect; proxy_set_header X-Forwarded-For $remote_addr;}3. CNAME Subdomain. data.yourdomain.com CNAME to your sGTM server. Warning: Safari 16.4+ checks IP matching. If the CNAME target IP differs from the website IP, cookies are capped at 7 days. Only works when sGTM is hosted on the same infrastructure as the website.
🧪 Testing and Validation
Section titled “🧪 Testing and Validation”Test Matrix
Section titled “Test Matrix”| Scenario | What to verify | Expected outcome |
|---|---|---|
| New visitor (Chrome) | UID created, cookie set, identity.is_new: true in payload | Cookie with 400d expiry, localStorage synced |
| Return visitor (Chrome) | UID persists | Same UID, cookie refreshed |
| New visitor (Safari) | Server-set cookie | 400d (server-set) or 7d (JS-set) |
| Return after 8 days (Safari, JS cookie) | Cookie expired | Recovery from localStorage, identity.resolution_method: 'localstorage_recovery' in payload |
| Private/incognito | Session-only | New UID every window, no persistence |
| Cookie cleared | Recovery from localStorage | identity.resolution_method: 'localstorage_recovery' in payload |
| Campaign visit | Attribution captured | first_touch set, payload has UTMs + click IDs |
| Direct visit after campaign | No attribution update | last_touch unchanged |
| Consent granted (T1) | Full capability | UID created, all data captured |
| Consent denied (T4, subsequent page load) | System dormant | No UID, no storage, no endpoint call (final consent_update was sent on the page where rejection occurred) |
| Analytics only (T2) | Click IDs stripped | click_ids: {}, data_quality: "stripped" |
Browser Developer Tools Validation
Section titled “Browser Developer Tools Validation”- Application > Cookies:
uiaf_uidpresent with correct format ({uuid}.{timestamp}),Secure,SameSite=Lax,HttpOnly=false, ~400-day expiry - Application > Local Storage:
uiaf_uidmatches cookie value,uiaf_attributioncontains JSON with touchpoint data - Network: Endpoint POST body matches schema (section 04),
_metareflects current consent tier, response is 200/204 - Console: No errors from UIAF code
⚠️ Common Mistakes
Section titled “⚠️ Common Mistakes”-
Setting cookies via JavaScript when server-set is available. JS cookies get 7 days on Safari. Server cookies get 400. Use the server.
-
Storing raw PII in cookies or localStorage. Hash before storage (section 08). Browser storage is accessible to any script on the page.
-
Not syncing storage mechanisms. Setting only the cookie without writing to localStorage defeats the recovery hierarchy (section 02).
-
Stripping click IDs from the URL. Breaks Google Consent Mode detection, platform pixels, and downstream deduplication (section 03). Capture the value, leave the URL intact.
-
Not including consent state in endpoint payload. The
consentobject is required (section 04). Without it, the endpoint cannot make routing decisions. -
Using tracking-related cookie names. Names matching
_ga,_fbp,_gcl,_tracker,analyticsare targeted by ad blockers (section 05). Use neutral names. -
Firing endpoint calls before consent is resolved. Wait for the CMP to provide a definitive state before sending payloads.
-
Not handling SPA route changes. Capturing only the initial page load misses every subsequent client-side navigation.