Fixing Safari ITP tracking issues

Fixing Safari ITP tracking issues

Safari's Intelligent Tracking Prevention (ITP) restricts how long JavaScript-written cookies can survive in a visitor's browser. If you're running A/B tests, heatmaps, polls, or funnels with Zoho PageSense, this can cause visitors to be treated as new users prematurely — skewing your experiment results and breaking multi-step funnels. This guide explains what ITP does, how it affects PageSense tracking, and how to fix it in under 10 minutes with a cookie sync endpoint.

What Is Safari ITP?  

Apple introduced ITP to prevent cross-site user tracking. Each version tightened the restrictions:

ITP Version
 What It Does

ITP 2.1
Cookies written via JavaScript (document.cookie) expire after 7 days
ITP 2.2
If a visitor arrives via a decorated link (a URL with query strings or fragments from a tracking domain), JavaScript cookies expire in 24 hours

ITP 2.3
Extends the 24-hour rule to website cookies — non-cookie local storage also expires after 7 days of inactivity

A decorated link is a URL with a query string or fragment (e.g., ?utm_source=newsletter or #section) originating from a domain classified as a cross-site tracker by Safari.
ITP is active on: Safari on iOS 12.2+, iPadOS, and macOS Mojave / High Sierra / Catalina and later.

How ITP Affects Zoho PageSense  

PageSense stores visitor tracking data — which variation a visitor saw, where they are in a funnel, their session progress — in browser cookies and localStorage. When ITP cuts those short, here's what can go wrong:
  1. Visitors get re-bucketed into experiments. If a returning visitor's cookie has expired, PageSense treats them as a new visitor. They may be assigned to a different variation than before, creating an inconsistent experience and polluting your test results with duplicate entries.
  2. Funnels break mid-journey. If your conversion funnel spans more than 7 days (or 24 hours for visitors from decorated links), PageSense loses the visitor's progress. A visitor who converted on day 8 won't be counted, because the tracking data that tied their earlier steps together has expired.
  3. Unique visitor counts inflate. The same person returning after a cookie expiry registers as a new unique visitor. Over the course of an experiment, this can significantly distort your sample size and conversion rates.
  4. Surveys lose partial progress. If a visitor doesn't complete a PageSense survey within the 7-day localStorage window, their answers are gone — and because the cookie still identifies them as having started the survey, it won't be shown to them again.

NotesNote: The cookie and localStorage expiry issue only applies to Safari. Chrome, Firefox, and Edge are not affected.

Cookies set by your server via the Set-Cookie HTTP response header are exempt from ITP's expiry cap. The fix is simple: host a lightweight endpoint on your server that reads PageSense's cookies from the browser request and re-issues them as long-lived server-set cookies.
PageSense's tracker automatically calls this endpoint on Safari (at most once per hour per visitor) — you just need to tell it where the endpoint lives.
Nothing is stored or sent to Zoho. The endpoint only echoes PageSense's existing cookie values back to the visitor's browser with an extended expiry. All logic runs on your own server.

Setup

Step 1: Deploy the sync endpoint  
Host one of the code snippets below on the same domain as your website (e.g., at https://yoursite.com/pagesense-sync).
Before deploying, update the COOKIE_DOMAIN value in the snippet to your registrable domain with a leading dot — for example, .yoursite.com. If your site is on app.yoursite.com, the domain should still be .yoursite.com so the cookie is accessible across subdomains.
Notes
Note about the domain value: In all snippets below, replace .example.com with your own registrable domain. For example, if your website is store.acmecorp.com, use .acmecorp.com. If you're unsure of the correct value, contact Zoho PageSense support.
Python (Flask)
  1. python
  2. """
  3. PageSense cookie-sync endpoint (Safari ITP fix) — Python / Flask.
  4. Re-issues PageSense first-party cookies via Set-Cookie so Safari
  5. honours their full lifetime. Host on the same domain as your site.

  6.     from pagesense_sync import pagesense_sync_blueprint
  7.     app.register_blueprint(pagesense_sync_blueprint)
  8. """

  9. from flask import Blueprint, request, make_response

  10. # --- CONFIG: replace with your registrable domain, with leading dot ---
  11. # e.g. ".acmecorp.com" for a site on store.acmecorp.com
  12. COOKIE_DOMAIN = ".example.com"

  13. MAX_AGE_SECONDS = 34560000  # 400 days

  14. pagesense_sync_blueprint = Blueprint("pagesense_sync", __name__)


  15. @pagesense_sync_blueprint.route("/pagesense-sync", methods=["GET"])
  16. def pagesense_sync():
  17.     is_https = (
  18.         request.is_secure
  19.         or request.headers.get("X-Forwarded-Proto", "") == "https"
  20.     )

  21.     response = make_response("", 204)
  22.     response.headers["Cache-Control"] = "no-store"

  23.     for name, value in request.cookies.items():
  24.         if name.startswith("zab") or name.startswith("zps"):
  25.             response.set_cookie(
  26.                 name,
  27.                 value,
  28.                 max_age=MAX_AGE_SECONDS,
  29.                 path="/",
  30.                 domain=COOKIE_DOMAIN,
  31.                 secure=is_https,
  32.                 httponly=False,  # tracker must be able to read these cookies
  33.                 samesite="None" if is_https else "Lax",
  34.             )

  35.     return response

Node.js (Express)
  1. javascript
  2. /**
  3. * PageSense cookie-sync endpoint (Safari ITP fix) — Node.js / Express.
  4. * Re-issues PageSense first-party cookies via Set-Cookie so Safari
  5. * honours their full lifetime. Mount on the same domain as your site.
  6. *
  7. *   const pagesenseSync = require('./pagesense-sync.node.js');
  8. *   app.get('/pagesense-sync', pagesenseSync);
  9. */

  10. // --- CONFIG: replace with your registrable domain, with leading dot ---
  11. // e.g. ".acmecorp.com" for a site on store.acmecorp.com
  12. const COOKIE_DOMAIN = '.example.com';

  13. const MAX_AGE_SECONDS = 34560000; // 400 days

  14. module.exports = function pagesenseSync(req, res) {
  15.     const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https';
  16.     const raw = req.headers.cookie || '';

  17.     const setCookies = raw.split(';').reduce((acc, pair) => {
  18.         const idx = pair.indexOf('=');
  19.         if (idx < 0) return acc;
  20.         const name = pair.slice(0, idx).trim();
  21.         const value = pair.slice(idx + 1).trim();
  22.         if (name.indexOf('zab') === 0 || name.indexOf('zps') === 0) {
  23.             acc.push(
  24.                 name + '=' + value +
  25.                 '; Max-Age=' + MAX_AGE_SECONDS +
  26.                 '; Path=/' +
  27.                 '; Domain=' + COOKIE_DOMAIN +
  28.                 (isHttps ? '; Secure; SameSite=None' : '; SameSite=Lax')
  29.                 // intentionally NOT HttpOnly — tracker must read these cookies
  30.             );
  31.         }
  32.         return acc;
  33.     }, []);

  34.     if (setCookies.length) {
  35.         res.setHeader('Set-Cookie', setCookies);
  36.     }
  37.     res.setHeader('Cache-Control', 'no-store');
  38.     res.status(204).end();
  39. };
Java (Servlet)
  1. java
  2. import java.io.IOException;
  3. import javax.servlet.annotation.WebServlet;
  4. import javax.servlet.http.Cookie;
  5. import javax.servlet.http.HttpServlet;
  6. import javax.servlet.http.HttpServletRequest;
  7. import javax.servlet.http.HttpServletResponse;

  8. /**
  9. * PageSense cookie-sync endpoint (Safari ITP fix) — Java Servlet.
  10. * Re-issues PageSense first-party cookies via Set-Cookie so Safari
  11. * honours their full lifetime. Deploy on the same domain as your site.
  12. */
  13. @WebServlet("/pagesense-sync")
  14. public class PageSenseSyncServlet extends HttpServlet {

  15.     // --- CONFIG: replace with your registrable domain, with leading dot ---
  16.     // e.g. ".acmecorp.com" for a site on store.acmecorp.com
  17.     private static final String COOKIE_DOMAIN = ".example.com";

  18.     private static final int MAX_AGE_SECONDS = 34560000; // 400 days

  19.     @Override
  20.     protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
  21.         boolean isHttps = request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto"));

  22.         Cookie[] cookies = request.getCookies();
  23.         if (cookies != null) {
  24.             for (Cookie cookie : cookies) {
  25.                 String name = cookie.getName();
  26.                 if (name.startsWith("zab") || name.startsWith("zps")) {
  27.                     // Build header manually to set SameSite
  28.                     // (javax.servlet Cookie has no SameSite support)
  29.                     StringBuilder header = new StringBuilder();
  30.                     header.append(name).append('=').append(cookie.getValue())
  31.                           .append("; Max-Age=").append(MAX_AGE_SECONDS)
  32.                           .append("; Path=/")
  33.                           .append("; Domain=").append(COOKIE_DOMAIN);
  34.                     if (isHttps) {
  35.                         header.append("; Secure; SameSite=None");
  36.                     } else {
  37.                         header.append("; SameSite=Lax");
  38.                     }
  39.                     // intentionally NOT HttpOnly — tracker must read these cookies
  40.                     response.addHeader("Set-Cookie", header.toString());
  41.                 }
  42.             }
  43.         }

  44.         response.setHeader("Cache-Control", "no-store");
  45.         response.setStatus(HttpServletResponse.SC_NO_CONTENT);
  46.     }
  47. }
.NET (ASP.NET Core)
  1. csharp
  2. using Microsoft.AspNetCore.Http;
  3. using Microsoft.AspNetCore.Mvc;
  4. using System;

  5. /// <summary>
  6. /// PageSense cookie-sync endpoint (Safari ITP fix) — ASP.NET Core.
  7. /// Re-issues PageSense first-party cookies via Set-Cookie so Safari
  8. /// honours their full lifetime. Host on the same domain as your site.
  9. /// </summary>
  10. [ApiController]
  11. public class PageSenseSyncController : ControllerBase
  12. {
  13.     // --- CONFIG: replace with your registrable domain, with leading dot ---
  14.     // e.g. ".acmecorp.com" for a site on store.acmecorp.com
  15.     private const string CookieDomain = ".example.com";

  16.     private const int MaxAgeSeconds = 34560000; // 400 days

  17.     [HttpGet("/pagesense-sync")]
  18.     public IActionResult Sync()
  19.     {
  20.         bool isHttps = Request.IsHttps
  21.             || string.Equals(Request.Headers["X-Forwarded-Proto"], "https",
  22.                StringComparison.OrdinalIgnoreCase);

  23.         foreach (var cookie in Request.Cookies)
  24.         {
  25.             if (cookie.Key.StartsWith("zab", StringComparison.Ordinal)
  26.                 || cookie.Key.StartsWith("zps", StringComparison.Ordinal))
  27.             {
  28.                 Response.Cookies.Append(cookie.Key, cookie.Value, new CookieOptions
  29.                 {
  30.                     MaxAge   = TimeSpan.FromSeconds(MaxAgeSeconds),
  31.                     Path     = "/",
  32.                     Domain   = CookieDomain,
  33.                     Secure   = isHttps,
  34.                     HttpOnly = false, // tracker must read these cookies
  35.                     SameSite = isHttps ? SameSiteMode.None : SameSiteMode.Lax
  36.                 });
  37.             }
  38.         }

  39.         Response.Headers["Cache-Control"] = "no-store";
  40.         return NoContent();
  41.     }
  42. }
Step 2: Tell PageSense where the endpoint is  
Add the following line before your PageSense SmartCode in your site's <head>:
  1. html
  2. <script>window.zpsITPSyncUrl = "/pagesense-sync";</script>
  3. <!-- PageSense SmartCode below -->
That's all. PageSense will automatically call the endpoint on Safari and iOS browsers, at most once per hour per visitor.

How It Works  

The endpoint reads the PageSense cookies the browser already sends with every request (cookies starting with zab or zps) and re-issues them via Set-Cookie response headers with a 400-day expiry. Server-set cookies are not subject to ITP's 7-day cap.
The endpoint returns 204 No Content with Cache-Control: no-store, so it never gets cached and always runs fresh. Cookies are set as HttpOnly: false — this is intentional, since PageSense's tracker needs to read them from JavaScript.

What the Endpoint Does and Doesn't Do  

Extends cookie lifetime beyond ITP's 7-day cap

Fixes A/B test variation consistency on return visits

Fixes multi-step funnel tracking across sessions

Fixes inflated unique visitor counts from Safari

Works across subdomains of the same registrable domain

Does not support cross-domain tracking (Safari blocks this entirely)


Resolve cross-domain tracking issues in Split-URL experiments :

When you run a Split URL experiment across different domains, browsers may block the cookies required for cross-domain tracking. As a result, PageSense may not be able to recognize visitors after they are redirected to the variation page, which can lead to incomplete or inaccurate experiment tracking. To address this, PageSense temporarily passes the required experiment information through the variation URL instead of relying only on cookies. This ensures that visitors continue to be tracked correctly, even when browser privacy restrictions affect cross-domain tracking.
After the variation page reads the information, PageSense automatically removes the temporary tracking parameters from the browser's address bar without reloading the page. Visitors continue to see a clean URL while the experiment is tracked accurately.
Example : Suppose you're running a Split URL experiment with the following pages:
When a visitor lands on the original page, PageSense assigns them to the variation and temporarily redirects them to:
Where:
  • ps_exp identifies the experiment.
  • ps_var identifies the assigned variation.
  • ps_visitor identifies the visitor.
Once the variation page receives this information, PageSense removes the temporary parameters, and the visitor sees:

How it works  
  1. The visitor lands on the original page.
  2. PageSense assigns the visitor to a variation.
  3. The visitor is redirected to the variation page with temporary tracking parameters.
  4. The variation page reads the information and continues tracking the visitor.
  5. PageSense removes the temporary parameters from the URL without refreshing the page.
Note: This method helps ensure reliable tracking for Split URL experiments across different domains, even when browser privacy settings restrict cross-domain cookies.

Known Limitation: Survey and Poll Progress  

The cookie sync endpoint fixes cookie-based tracking but cannot help with localStorage. If a visitor is mid-way through a PageSense survey or poll and doesn't complete it within 7 days, their progress is stored in localStorage — which ITP can still expire.
What happens in this scenario: the visitor's cookie (now server-set and long-lived) still identifies them as having seen the survey, so PageSense won't show it again. But their in-progress answers are gone. There's currently no workaround for this limitation; it's a Safari-imposed constraint on localStorage that applies regardless of cookie handling.

Frequently Asked Questions  

1. Will these cookies really persist for 400 days?
The cookie is issued with Max-Age=34560000 (400 days — the maximum browsers allow). However, PageSense manages its own logical expiry: each cookie's intended lifetime is stored in a metadata cookie (zpsCookieMeta). PageSense reads this and treats any cookie past its intended expiry as absent, deleting it client-side. So the 400-day server expiry is a ceiling, not a guarantee of how long the data actually lives.

2. Is there any security risk in hosting this endpoint?
No. The endpoint only re-sends cookie values that the visitor's browser already carries. Nothing is stored, logged, or forwarded to Zoho. The sync script runs entirely on your own server and never transmits cookies to a third party.

3. Does this affect non-Safari browsers?
No. PageSense's tracker only calls the sync endpoint on Safari and iOS browsers where ITP is active. Chrome, Firefox, and Edge are unaffected.

4. Does this work if my site spans multiple domains?
No. The cookie domain must match the registrable domain of the site where PageSense is installed. Tracking across entirely separate domains (e.g., siteA.com and siteB.com) is not supported in Safari and cannot be fixed with this approach.

5. What if visitors arrive from non-decorated links?
Nothing extra is needed. If a visitor's URL contains no query string or fragment, ITP 2.2's 24-hour cap doesn't apply — PageSense's built-in fallback to localStorage handles the standard 7-day window automatically. The sync endpoint is primarily valuable for visitors arriving via campaign links with UTM parameters or other query strings.






We’ve designed this documentation to guide you every step of the way. If you need further assistance or have any questions, don’t hesitate to contact us at support@zohopagesense.com - we’re always here to help!