flowsk.com
How it actually works

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.

Jul 22, 2026· 3 min read ·How it actually works
Quick answer

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.cookie at 7 days. Cookies set by a server in a Set-Cookie response 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.

Inspect my cookies

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.

Keep reading