This is the implementation guide. If you want the reasoning — what "site" means, why None needs Secure, how the three values differ — read SameSite cookies explained first. What follows assumes you have decided and now have to ship it across an application that already has cookies in production.
Step 1 — List Every Cookie and Who Needs It Cross-Site
The decision is per cookie, so the migration starts with an inventory. For each cookie you need one fact: does any legitimate flow send it from a page on a different site?
# Everything set on a single response
curl -sI https://example.com | grep -i '^set-cookie:'
# Login flows usually set the session on a redirect
curl -sIL https://example.com/login | grep -i '^set-cookie:'Then walk the flows that actually cross sites — SSO redirects, hosted checkout, an embedded widget on a partner page, a payment provider returning by POST — with DevTools open on the Network tab. Any cookie that a request in one of those flows depends on is a None candidate. Everything else is not.
| Cookie | Sent cross-site by design? | Value |
|---|---|---|
| Session / auth | No | Strict |
| CSRF token | No | Strict |
| Login hint, UI state | On inbound link clicks only | Lax |
| Locale, theme, consent | No | Lax |
| Widget state in a partner iframe | Yes | None + Secure |
| Cookie a provider POSTs back to | Yes | None + Secure |
Step 2 — Set a Global Default of Lax
Start by making the framework emit an explicit Lax on everything. It matches the browser default, so nothing changes behaviourally, and it converts "no attribute" into a written decision across the whole application in one commit.
// Express — session middleware
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { sameSite: 'lax', secure: true, httpOnly: true, path: '/' },
}));
// Individual cookies
res.cookie('locale', 'en-GB', { sameSite: 'lax', secure: true, path: '/' });# Django settings.py
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Flask
app.config.update(
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
)// PHP 7.3+ — session cookie parameters
session_set_cookie_params([
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
// Or globally in php.ini
// session.cookie_samesite = "Lax"Lax, and a few still emit None for historical reasons. Do not infer it from documentation; read the actual Set-Cookie header from a running instance.Step 3 — Override the Cross-Site Cookies to None
Now handle the exceptions from your inventory. Two rules make this safe: scope None to the specific cookie that needs it, and never apply it to a session or CSRF cookie.
// Only this cookie crosses sites. Secure is mandatory, not optional.
res.cookie('widget_state', state, {
path: '/',
secure: true,
sameSite: 'none',
maxAge: 86400 * 1000,
});The failure mode here is silent, so verify each one immediately. A None cookie sent without Secure is rejected outright — not downgraded — and nothing is logged server-side:
curl -sI https://example.com/widget | grep -i '^set-cookie:'
# Every line containing SameSite=None must also contain SecureStep 4 — Move Session Cookies to Strict, With a Companion
Strict is the right value for a session cookie and it has one visible cost: a user arriving from an email, a search result or a chat link sends no cookie on that first request, so the landing page renders logged out. The second request — now same-site — carries the cookie normally.
The fix is not to weaken the session cookie. Add a second cookie that carries no credential and says only that a session exists:
// The credential: never sent cross-site
res.cookie('__Host-session', sid, {
path: '/', secure: true, httpOnly: true, sameSite: 'strict',
});
// The hint: safe to send on an inbound link click. No session data in it.
res.cookie('signed_in', '1', {
path: '/', secure: true, sameSite: 'lax', maxAge: 7 * 86400 * 1000,
});The landing page reads signed_in, renders the logged-in shell, and the real session arrives on the next request. Clear both on logout.
Strict was solving.Step 5 — Handle Cookies You Do Not Set
Tag managers, chat widgets and embedded players write cookies on your domain, and they appear in any audit as yours. When you cannot change the code, stamp the attribute at the edge:
# nginx 1.19.3+ — applies to every cookie from this upstream
proxy_cookie_flags ~ samesite=lax secure;
# Target one cookie by name instead of all of them
proxy_cookie_flags _widget samesite=none secure;# Append SameSite only to cookies that do not already declare it
Header always edit Set-Cookie ^((?!.*SameSite).*)$ "$1; SameSite=Lax"Treat this as a stopgap. A blanket rule applies to every cookie from that upstream, so a widget that genuinely needs None breaks, and the rule is easy to forget when the upstream is later replaced. Prefer updating or removing the script.
Step 6 — Test the Flows That Actually Cross Sites
Same-site testing proves nothing here, because same-site requests carry the cookie under every value. Each of these has to be exercised from a genuinely different site:
| Flow | How to test | Breaks if |
|---|---|---|
| Inbound link while signed in | Click a link to your site from another domain | Session is Strict with no Lax companion |
| SSO / OAuth redirect | Full sign-in through the identity provider | The state cookie is Strict or Lax |
| Hosted checkout returning by POST | Complete a test payment end to end | The return cookie is not None; Secure |
| Your page in a partner iframe | Load the embed from another origin | The widget cookie is not None; Secure |
Cross-origin fetch | Call your API from another site with credentials | The cookie is Lax or Strict |
| Any of the above in Safari | Repeat with default ITP settings | The flow depends on a third-party cookie at all |
Watch the Network panel while you do it. Chrome flags cookies it dropped and names the reason, which is far faster than inferring the cause from a broken page.
Step 7 — Verify What Shipped, and Keep Verifying
Check the deployed site rather than localhost — the proxy, the environment variable and the TLS termination all differ, and a value that is right locally is routinely wrong in production.
curl -sIL https://example.com/login | grep -i '^set-cookie:'Then re-run a cookie audit on each release. Values drift: a dependency upgrade changes a default, someone adds None to fix one embed and applies it globally, a new script arrives with its own cookies. A check that ran once at rollout says nothing about the state six months later.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Cookie never stored, no error anywhere | SameSite=None without Secure | Add Secure; it is mandatory, not advisory |
| Landing page looks logged out on first click | Session cookie is Strict | Add the Lax companion cookie |
| SSO redirect loops forever | State cookie not sent on the cross-site return | Set that cookie to None; Secure |
| Embed works for you, not for customers | Third-party cookies blocked, not a SameSite issue | Move the cookie to the top-level site |
| Attribute missing from the header entirely | Typo in the value, so the browser ignored it | Only Strict, Lax, None are valid |
| Correct locally, wrong in production | A proxy or CDN is rewriting Set-Cookie | Check proxy_cookie_flags and edge rules |
| Works in Chrome, fails in Safari | ITP blocking the third-party cookie | Redesign the flow to stay first-party |
Frequently Asked Questions
1. Do I have to set SameSite if the browser already defaults to Lax?
Functionally no, for cookies that want Lax. Practically yes: an absent attribute is indistinguishable from an unconsidered one in an audit, older embedded webviews still apply the pre-2020 behaviour, and a written value survives a change of browser default. It costs nine characters per cookie.
2. My framework has a global SameSite setting. Can I just use that?
Use it to set the floor, not the answer. A single global value forces every cookie to the same policy, which means either your session cookie is weaker than it should be or your embedded widget is broken. Set the global default to Lax, then override the session and CSRF cookies to Strict and the specific cross-site cookies to None.
3. How do I roll out Strict without logging everyone out?
Changing the SameSite value does not invalidate the cookie — the browser keeps storing it, and it is still sent on same-site requests. Nobody is logged out. What changes is the first render after an inbound external link, which is why the Lax companion cookie in Step 4 exists.
4. Chrome DevTools warns about a cookie in a third-party context. What is it telling me?
That the cookie was sent or set in a cross-site context and is affected by browser third-party cookie policy. Adding SameSite=None; Secure silences the SameSite half of the warning but does not exempt the cookie from partitioning or blocking — those are a separate layer that None cannot override.
5. Does SameSite=None still work if I need cross-site cookies?
It works where third-party cookies are still permitted, and not where they are blocked or partitioned — Safari blocks them via ITP, Firefox partitions them, and Chrome restricts them. If you own both ends, the durable fix is to move the cookie onto the top-level site rather than to keep relying on third-party delivery.
6. How do I confirm the values that actually shipped?
Run the deployed page through the Cookie Security Checker — it reports the SameSite value on every cookie the response sets, including ones from third-party scripts. Check the deployed site rather than localhost, because proxy configuration and TLS termination differ.