WHY SAFARI KILLS YOUR ATTRIBUTION AFTER SEVEN DAYS, AND THE PROXY THAT FIXES IT
A visitor clicks your ad in week one and buys in week three. On Safari the click is gone by then, the sale files under direct, and the platform that earned it gets nothing. The cookie was capped because of how it was set. Not because of what it was called.
THE SALE THAT CAME FROM NOWHERE
You run Google Ads and Meta ads. Somebody on an iPhone taps one, lands on your site, reads for a while, and leaves. Two weeks later they come back on their own, fill in your Keap form, and buy. Your report says the sale was direct. Google gets no credit. Meta gets no credit. The campaign that found them looks like it produced nothing, and you cut its budget.
Nothing errored. That is the frustrating part. The tracking snippet ran, the cookie was written, and the hidden form fields would have been populated on the first visit. What happened is quieter: on Safari, the cookie holding the click expired a week after it was set, so by the time the visitor came back there was nothing left to carry into the form. The attribution did not fail. It aged out.
We measured this on our own site in August 2026, on a physical Mac running Safari 18, and the number is exactly what the heading says: seven days stored, when 400 was asked for. This guide is what we found, why it happened, and the change that fixes it. The change is not a DNS record.
HOW A CLICK IS SUPPOSED TO SURVIVE TWO WEEKS
The web path for attribution is short, and every step of it leans on one cookie.
A visitor clicks an ad and lands with a click id in the URL: gclid, gbraid or
wbraid from Google, fbclid from Meta, plus whatever UTM (Urchin Tracking Module) parameters
the link carries, utm_source, utm_medium, utm_campaign. A snippet on the page
reads those, writes them into a first party cookie on your own domain, and posts the raw event to a collector so a
record exists outside the browser. When the visitor later submits a Keap form, the snippet fills hidden fields from
that cookie, and Keap stores the values in custom fields on the contact: first source, first campaign, the click id,
the landing page.
The cookie is the memory. Between the click and the form there may be a fortnight of nothing, and the only thing that connects the two is the cookie still being there when the form loads. Our spec asks for a 400 day lifetime, which is the ceiling Chrome allows any cookie, so the whole design assumes the browser honours the expiry it is given.
Safari does not always honour it, and the rule for when it does is not the one most people have read.
THE THREE CAPS, AS EVERYBODY UNDERSTANDS THEM
Safari's Intelligent Tracking Prevention (ITP) is the set of rules that decides how long a cookie lives. The received understanding, which is what we built on, has three parts.
First, Safari caps cookies written by script at seven days. Anything set through
document.cookie expires in a week whatever Max-Age it asks for. This is why a collector
re issues the cookie over HTTP: a Set-Cookie header from a server replaces the script written cookie
with a server set one carrying the full expiry.
Second, Safari also caps server set cookies at seven days when the host that set them is not really part of the site. Apple documents this as the mitigation for CNAME cloaking, where a tracker hides behind a subdomain of the publisher that is a CNAME record (an alias in the Domain Name System, DNS) pointing at the tracker's own servers. It exists specifically to defeat the workaround in the first rule.
Third, Chrome caps any cookie at 400 days. That is the ceiling, and we already use it.
Our own documentation used to say the second rule fires only on a CNAME, and that pointing
t.customer.com at our infrastructure with plain A and AAAA records was therefore enough to keep 400
days. That was wrong, and it was wrong in the direction that cost us the whole workaround.
WHAT SAFARI 18 ACTUALLY DID
On August 6, 2026 we loaded myvitalassistant.com three times in real Safari on a physical Mac, once with a
gclid on the URL, and read back what the browser had stored. The tracking host,
t.myvitalassistant.com, sat on proxied Cloudflare A and AAAA records. No CNAME anywhere in the chain.
This was the exact configuration our own table promised 400 days for.
// Safari 18, physical Mac, August 6, 2026. Three page loads, one with ?gclid= on it.
// Site answers from Netlify, on Amazon Web Services.
// Tracking host t.myvitalassistant.com answers from Cloudflare, A and AAAA, no CNAME.
cookie written by asked for Safari stored
_ga, _clck page scripts 400 d / 1 yr full term
_vtprobe document.cookie, our name, our domain 400 d 400 d
_vt our collector's Set-Cookie header 400 d 7 d
Read the last two rows together. The probe cookie, written by our own script with our own attributes on our own
domain, stored the full 400 days. The rule that says Safari clamps script writes to a week did not fire at all. The
real cookie, written by our collector's Set-Cookie header with the same attributes and the same expiry,
stored seven. The rule that supposedly needs a CNAME fired on a host that had none.
The distinguishing fact is the network. t.myvitalassistant.com answers from Cloudflare. The site answers from Netlify, on Amazon Web Services. A first party name on somebody else's
infrastructure is exactly the shape the mitigation exists to catch, and the DNS record type is not what gives it
away. Safari does not care whether you got there by CNAME or by A record. It cares that the host setting the cookie
is not the host serving the page.
Passing a DNS check proves nothing here. Our onboarding script fails loudly on a third party CNAME or a
name that does not resolve. It passed on t.myvitalassistant.com, the host that stores seven days. It
rules out one failure, not the failure that matters. The only check that predicts the outcome is a real cookie round
trip from the customer's own origin.
THE WORKAROUND WAS THE BUG
Here is the part that stings. The snippet wrote the cookie first, with 400 days, and Safari kept it. Then the
collector answered, and its Set-Cookie landed after the snippet's write, replacing a healthy 400 day
cookie with one capped at seven. The server side re issue was not failing to help. It was the entire cause of the
problem it existed to solve.
Nothing in the application could see this. There is no error. The collector got its request and answered it. The cookie existed, with the right name and the right attributes. The only symptom is attribution getting quietly worse for one slice of visitors, whose report says direct, which is what a report says when it has nothing to say.
It also cannot be caught by an automated browser. Our end to end suite runs on Playwright's WebKit, which is not Safari and does not implement Intelligent Tracking Prevention. It proves the collector sends the cookie with the right attributes, which is the part we control, and it cannot prove what Safari does with it. That took a real device, and until it happened the docs said the opposite of the truth with complete confidence.
PUT THE COLLECTOR ON THE SITE'S OWN HOST
The requirement that falls out of the measurement is simple to state. The collector must answer on the same
host as the page. Not a subdomain of it, the host itself: a path on the customer's own site,
customer.com/vt/*, reverse proxied to the collector by the customer's own web stack.
The browser then sees one host, the one it is already on. There is no second hostname to classify, no DNS record
pointed anywhere, and nothing for any current or future cloaking mitigation to detect, because the proxy hop happens
on the server where the browser cannot see it. The Set-Cookie header arrives from
customer.com, the same origin that stored 400 days in the table above.
Our own site runs on Netlify, so the proxy there is a redirect rule with a 200 status, which Netlify treats as a rewrite rather than a redirect. This is the live rule from our netlify.toml, and it is the whole install.
# Tracking is served from this domain rather than from the tracking host.
# Proxied through this path the browser only ever talks to myvitalassistant.com,
# so there is nothing left to classify.
[[redirects]]
from = "/vt/*"
to = "https://t.myvitalassistant.com/:splat"
status = 200
force = true
force = true matters. A 200 rewrite only wins over a real file at that path when forced, and you do not
want a stray /vt/ directory quietly taking over your tracking one deploy from now.
On Nginx the same thing is a location block. Apache and Vercel each have an equivalent, and the WordPress version ships as a plugin so a customer installs it rather than editing a server.
location /vt/ {
# Forward the path verbatim. The snippet and the collector agree on it.
proxy_pass https://<collector host>/;
# The host the browser asked for, not the one we are forwarding to. See below.
proxy_set_header X-Forwarded-Host $host;
}
The proxy also removes the DNS ask from onboarding entirely, which was the heaviest and riskiest part of it. The zone that carries a tracking subdomain usually carries the customer's production mail, and a botched edit there is an email outage, not a reporting gap. A path on a site the customer already controls is a smaller ask than a DNS record.
THE COOKIE DOMAIN UNDER A PROXY
One detail bit us the moment the collector stopped answering on its own subdomain. It derived the cookie's
Domain attribute from the host it answered on by dropping the leftmost label:
t.customer.com became .customer.com, right for a direct child of the registrable domain
and wrong for anything else. Under the proxy the collector answers on customer.com itself, and
dropping a label from that produced no Domain at all, a host only cookie invisible to
www.customer.com. Worse, customer.co.uk produced .co.uk, which the browser
rejects outright.
Three layers fixed it. The proxy sends X-Forwarded-Host, so the collector knows which host the browser
actually asked for rather than the tracking host it answered on. A two label host scopes to itself,
customer.com to .customer.com. And the snippet passes along the domain its own probe
resolved, which the collector honours only when it is a suffix of that host. The browser is the only party that can
see the public suffix list, so that hint is the only answer that is safe on country code domains, and it keeps the
server set cookie identical to the script written one, which stops you ending up with two _vt cookies
on one page.
If your proxy strips X-Forwarded-Host, add it back. It is the one header the arrangement leans
on. Without it the collector sees only the host it is forwarding to, and you are back to guessing the cookie domain
from the wrong name.
FIRST TOUCH IS WRITTEN ONCE
Fixing the lifetime gets the click to survive. The second half is making sure a later visit does not overwrite it.
The cookie carries two touches. First touch is the visit that brought the visitor in the first time:
utm_source, utm_medium, utm_campaign, the landing page and the date. Last
touch is the visit on which they converted. First touch values are write once. The snippet sets
them if they are empty and never touches them again. Last touch fields update on every visit.
// First touch: fill only when empty. Never overwrite what an earlier visit recorded.
if (!stored.first) stored.first = touch;
// Last touch: replace on every visit that carries attribution.
stored.last = touch;
// Both then ride into the Keap form as hidden fields, and land on the contact:
// vt_first_source, vt_first_medium, vt_first_campaign, vt_first_date
// vt_last_source, vt_last_campaign, vt_gclid, vt_fbclid, vt_landing_page, vt_anon_id
Why it matters is the story this guide opened with. The visitor came in from a Meta ad in week one. In week three
they came back by typing your address, or from a Google brand search, or from an email you sent. If first touch were
overwritten, the ad that earned the visit would be replaced by the visit that merely closed it, and Meta would lose
credit to whichever channel happened to be last. Both values are worth having, which is why the contact record in
Keap carries both sides: vt_first_source, vt_first_campaign and
vt_first_date on one, vt_last_source and vt_last_campaign on the other.
One more field is worth knowing about. Last touch fields update on every visit and for a long time carried no date,
so a join from a sale back to a contact read the contact's current last source against a sale from any point in
history. A January sale got credited to an August click. An optional vt_last_date field closes that.
It is attempted only after every required field has landed, so it never takes room a required one needed, and an
account without it keeps exactly the behaviour it had.
WHAT THIS FIXES, AND WHAT IT DOES NOT
Be precise about the claim, because a fix described too broadly gets installed where it does nothing.
This is about cookie lifetime on your own domain. A visitor who clicks your ad, lands on your site and comes back to your site within 400 days is remembered on Safari the way they already were on Chrome. That is the whole of it.
It is not cross site tracking, and it does not try to be. Nothing here follows a visitor to another domain or recovers a click that never reached your site. If a platform's own pixel is blocked, this does not unblock it. Safari's rules against third parties are working as designed, and a first party cookie set by the first party is exactly what those rules leave alone.
It needs the site's own host to proxy. A Keap landing page, or a page on a hosted builder that gives you no way to forward a path, cannot do this. For those the honest fallback is to accept seven days on Safari and say so. Measure before agreeing to that: coverage will look fine in aggregate and be badly wrong for a specific slice of visitors, which is worse than being wrong everywhere. Label it in the dashboard rather than hiding it, on the same principle as an unattributed revenue line.
Two things are still unmeasured, and we would rather say so than guess. Whether putting the customer's site behind Cloudflare as well, so the page and the tracking host answer from the same network, lifts the cap; if it does not, the path proxy is the only answer and the tracking hostname should be retired. And what share of a Keap customer's paid click traffic is Safari in the first place. Desktop paid search skews low. Anything driven by Meta in app browsers on iOS skews high.
This is the install we built into MyVitalAssistant, and the app reports each account's measured cookie lifetime in its tracking settings, because after August we stopped trusting any check that was not a real cookie round trip from the customer's own origin.
COMMON QUESTIONS
/vt/* to the collector and the browser never sees a second hostname.
Whether moving your whole zone to Cloudflare would lift the cap is unmeasured, and an edit on the zone that carries
your mail is not a risk worth taking for a reporting gain.The docs said 400 days, the check passed, and a real Mac stored seven. We have been connecting Keap to the rest of the stack since 2010, and the attribution work is where the documentation is thinnest. If your report says direct more often than your ad spend can explain, that is a conversation worth having.
Book a Free Consult