┌── POST 09.27 · Cookie Audits & Scanners · 4 min read

cookieStore.set Writes Nothing on localhost in Safari: Three Traps for Consent Cookies

The Cookie Store API makes every cookie Secure, so cookieStore.set() stores nothing on localhost in Safari before 27 — measured on 26.6.2. maxAge only exists from Chrome 145, Firefox 148 and Safari 27, and expires plus maxAge together is a TypeError.

Illustration: a sealed cookie jar with one cookie left outside it.
TL;DR

The Cookie Store API is the tidy, promise-based way to write a consent cookie, and it has three properties that bite. Every cookie it writes is Secure, by specification — so in Safari before 27, on http://localhost, cookieStore.set() stores nothing and throws nothing; we measured all three variants failing on Safari 26.6.2. maxAge is new: Chrome 145, Firefox 148, Safari 27. And passing expires and maxAge together is a failure by spec — Chromium rejects it with a TypeError. Use expires, alone.

cookieStore.set() is available in Chrome since 87, Firefox since 140 and Safari since 18.4, according to MDN’s compatibility data. It is async, it rejects on failure instead of failing silently, and it reads better than assembling a document.cookie string. Those are good reasons to use it for a consent record. It just does not behave like document.cookie with nicer syntax.

1. Every write is Secure

The specification‘s “set a cookie” algorithm contains a step with no condition around it: “Append (Secure, “) to attributes.” Its security section says it plainly: “This API only allows writes for Secure cookies”. There is no option to turn it off.

On HTTPS that is invisible. On local development it collides with something we measured last week: Safari before version 27 drops Secure cookies on loopback hosts over HTTP. Put the two together and you get a consent banner that works in Chrome on localhost and never saves in Safari.

We measured it on macOS 26.6.2, on http://127.0.0.1:

                                        Safari 26.6.2    Chromium 152
cookieStore.set({ name, value })           NOT stored       stored
cookieStore.set({ ..., expires: +30d })    NOT stored       stored
cookieStore.set({ ..., maxAge: 30d })      NOT stored       stored
document.cookie = "..."  (same page)       stored           stored

No exception in Safari. The promise resolved; the cookie was simply not there. Chromium’s cookieStore.get() reported the stored cookie with secure: true, which is the spec doing what it says. Safari 27 accepts Secure cookies on loopback, so this particular trap goes away there — on Apple platforms, and on macOS only from 26.0, as the WebKit change notes. We did not test Safari 27.

2. maxAge only exists in recent browsers

The maxAge option is recent everywhere. MDN lists it from Chrome 145, Firefox 148 and Safari 27; WebKit’s release post for Safari 27 announces it as new: “Safari 27.0 now supports specifying maxAge when setting a cookie via the Cookie Store API.”

What happens in a browser that has cookieStore.set() but not maxAge — Safari 18.4 to 26.x — follows from how Web IDL treats option objects: a member the engine does not define is ignored. The write goes ahead with no lifetime, which is a session cookie, and the visitor’s consent disappears when they quit the browser. We could not measure that half on this machine: on loopback the Secure rule above blocks every write first, and we had no locally trusted HTTPS origin. Treat it as the expected behaviour, not a measured one.

3. expires and maxAge together is an error

The obvious hedge — send both, and let each browser use what it knows — is exactly what the specification forbids: “If expires is non-null: If maxAge is non-null, then return failure.” Chromium enforces it:

await cookieStore.set({ name: 'consent', value: '1',
                        expires: Date.now() + 864e5, maxAge: 86400 });
// TypeError: Failed to execute 'set' on 'CookieStore':
//            Cookie expires and maxAge cannot both be specified

So in a browser that understands both, the belt-and-braces version writes nothing at all.

One more default worth knowing

The spec’s default sameSite is "strict", and Chromium reported exactly that for a cookie written without the option. For a first-party consent cookie that is usually fine, but it is stricter than Chrome’s Lax-by-default for cookies written without the attribute. The practical difference: a Strict cookie is not sent on the first request of a visit that arrives from another site — a search result, an email link — so a server that decides what to render from the consent cookie will not see it on that request. Pass sameSite: 'lax' if your server reads it.

What to write instead

Use expires, alone. It has been part of cookieStore.set() since the method shipped in each browser, so it works wherever the API exists:

async function saveConsent(value, days = 180) {
  const expires = Date.now() + days * 864e5;
  if ('cookieStore' in window) {
    await cookieStore.set({ name: 'consent', value, expires, sameSite: 'lax' });
  } else {
    document.cookie = 'consent=' + encodeURIComponent(value) +
      '; expires=' + new Date(expires).toUTCString() +
      '; path=/; SameSite=Lax; Secure';
  }
}

And test the write, rather than trusting the resolved promise: read the cookie back with document.cookie immediately afterwards. On Safari before 27, run local development over HTTPS, or accept that localhost will not keep the cookie. The expiry itself deserves the same scrutiny — how long browsers actually keep a consent decision is covered in how long cookie consent lasts, and the other way Safari quietly shortens it in the month-first Expires bug.

A consent cookie that the API reports as written but the browser never kept is invisible until someone looks. Scan your site to see which cookies are actually stored with CookieInspector.

C
About the author
Consent Mode HQ
Editorial team at Consent Mode HQ
Read more by author ↗