How to make a first-party cookie that survives Safari
Six lines of backend code. The rule is not about the domain, the flags or the expiry you request — it is about which side of the wire writes the cookie.
Set-Cookie response header → not capped · document.cookie → capped at 7 days
Safari rewrites the expiry of any cookie created through JavaScript down to 7 days. A cookie written by your server in a Set-Cookie response header is not capped at all. Same domain, same flags, same requested expiry — different writer, different outcome.
This is why your login session lasts months and your analytics id does not.
There is exactly one rule, and almost every article about ITP buries it under four paragraphs about privacy trends.
The rule
Safari caps the expiry of cookies created through
document.cookieat 7 days. Cookies set by a server in aSet-Cookieresponse header are not capped.
Not the domain. Not the flags. Not the expiry you asked for. Who wrote it.
This is why, in the same browser, on the same site, on the same day:
- Your login session survives for months. Server-set.
- Your analytics id disappears after a week. JavaScript-set.
The proof, in two lines
// Capped at 7 days in Safari, whatever you asked for
document.cookie = "_id=abc123; max-age=63072000; path=/"
// Not capped
Set-Cookie: _id=abc123; Max-Age=63072000; Path=/; SameSite=Lax; Secure
Identical cookie. Identical domain. Identical requested lifetime. One survives, one does not.
Writing the durable version
Rails
before_action :ensure_visitor_id
def ensure_visitor_id
return if cookies[:_fsk_id].present?
cookies[:_fsk_id] = {
value: SecureRandom.uuid,
expires: 2.years,
httponly: false, # true if only your server needs it
same_site: :lax
}
end
Express
app.use((req, res, next) => {
if (!req.cookies._fsk_id) {
res.cookie("_fsk_id", crypto.randomUUID(), {
maxAge: 63072000000, sameSite: "lax", secure: true
})
}
next()
})
Next.js middleware
export function middleware(request: NextRequest) {
const response = NextResponse.next()
if (!request.cookies.get("_fsk_id")) {
response.cookies.set("_fsk_id", crypto.randomUUID(), {
maxAge: 60 * 60 * 24 * 730, sameSite: "lax", path: "/"
})
}
return response
}
PHP
if (empty($_COOKIE['_fsk_id'])) {
setcookie('_fsk_id', bin2hex(random_bytes(16)), [
'expires' => time() + 63072000, 'path' => '/',
'samesite' => 'Lax', 'secure' => true,
]);
}
Six lines in any of them. This is genuinely the whole fix.
The flags, decided
| Flag | Value | Why |
|---|---|---|
Max-Age |
1–2 years | Chrome caps first-party at 400 days regardless |
SameSite |
Lax |
Right for a visitor id. None requires Secure and keeps getting restricted |
Secure |
true |
Always, in production |
HttpOnly |
Depends | true if only your backend uses the id; false if a snippet must read it |
Path |
/ |
The whole site |
HttpOnly deserves a note because it is widely misunderstood as the cause of durability. It is not — it is a consequence. JavaScript cannot write an HttpOnly cookie, so every HttpOnly cookie is necessarily server-set, and therefore durable. A server-set cookie without HttpOnly is exactly as durable.
Things that do not work
localStorage. Safari evicts it on the same seven-day clock. Same for IndexedDB and the Cache API. This is the most common attempted workaround and it buys nothing.
A longer requested expiry. Safari rewrites it. Asking for ten years gets you seven days.
A first-party domain, with JavaScript. The domain is not the variable. Owning the domain does not exempt you.
CNAME cloaking. Safari detects it and caps the cookie anyway. Also a disguise with a shrinking shelf life.
Verifying
Open devtools, Network tab, reload, click the document request, look at Response Headers.
Set-Cookie: _fsk_id=…; Max-Age=63072000→ server-set. Durable.- Nothing there, but the cookie exists in the Application tab → JavaScript wrote it. Capped.
That single check tells you where you stand, and most people are surprised by the answer.
If you would rather not go through devtools, the cookie inspector reads the response headers of any URL and lays out every cookie with a durability verdict.
Then use it
A durable id is only useful if the rest of the system uses it consistently: the snippet should read the same cookie, your server-side events should carry the same id, and your conversions should be confirmed from your backend against it. That is what proxy setup walks through end to end.
Frequently asked questions
Does HttpOnly make a cookie durable?
It is strongly correlated but not the cause. HttpOnly cookies must be server-set, because JavaScript cannot write them — so every HttpOnly cookie is durable by construction. A server-set cookie without HttpOnly is equally durable.
What expiry should I request?
One to two years. Beyond that Chrome caps first-party cookies at 400 days anyway, and it makes no practical difference.
Should I use SameSite=None?
No, unless you genuinely need the cookie on cross-site requests. Lax is right for a visitor id, and None requires Secure and is the configuration browsers keep restricting.
Does localStorage help?
No. Safari evicts localStorage, IndexedDB and the Cache API on the same 7-day clock. Moving the id there is not a workaround, it is the same problem in a different API.
How do I confirm mine is durable?
Load the page and look for Set-Cookie in the response headers, with a long Max-Age. If you only see the cookie in devtools' Application tab and not in the response headers, JavaScript wrote it — and it is capped.
Check any site in 10 seconds
The cookie inspector reads the Set-Cookie headers on any URL and shows which cookies are durable and which are on the 7-day clock.
Stop guessing which ad made the sale.
Flowsk Signals stitches the anonymous click to the email to the purchase — first-party, server-side, de-duplicated. One snippet, $29/mo, and every conversion comes with a receipt you can inspect.