A page inside a cross-site iframe runs in a third-party context, so its cookies are third-party cookies. Set them with SameSite=None; Secure over HTTPS for Chrome and Edge. Safari blocks them and Firefox partitions them, so use Partitioned (CHIPS) cookies for per-embed sessions, the Storage Access API for an existing login, or token-based auth.
Why iframe cookies stop working
Browsers decide how to treat a cookie by comparing the site that owns it with the site in the address bar. A site is the scheme plus the registrable domain: https://app.example.com and https://www.example.com are the same site, while https://example.com and https://partner.com are not.
- First-party context: the user visits
app.example.comdirectly. Its cookies are first-party and behave normally. - Third-party context:
partner.comembedsapp.example.comin an iframe. The iframe's cookies are now third-party cookies, because the top-level site is someone else's.
The same Set-Cookie header that works in a tab can be dropped, blocked or put in a separate jar once the app is framed, which is why "iframe login not working" bugs often surface only on a customer's site.
Typical symptoms:
- The user logs in inside the iframe, then the next page shows them logged out.
- A redirect loop between the app and its login page.
- Form posts fail with a 403 or "CSRF token mismatch", because the CSRF cookie never arrives.
- Everything works in Chrome but not in Safari or Firefox.
If the iframe doesn't render at all, that's a different problem: the page is refusing to be framed. See why an iframe refuses to connect first.
How Chrome, Firefox and Safari handle iframe cookies
Each engine has its own third-party cookie policy, which is why a fix that works on your machine can fail for many of your users.
| Behaviour in a cross-site iframe | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Third-party cookies by default | Allowed in regular browsing (blocked in Chrome Incognito by default, or if the user turns them off) | Allowed but partitioned per top-level site (Total Cookie Protection) | Blocked (Intelligent Tracking Prevention) |
Cookie with no SameSite | Treated as Lax, not sent | Don't rely on the default; set it explicitly | Blocked as a third-party cookie |
SameSite=Lax or Strict | Not sent | Not sent | Not sent |
SameSite=None; Secure | Sent | Sent, but only from the jar for that top-level site | Blocked unless storage access is granted |
Partitioned (CHIPS) | Supported | Check current support on caniuse | Check current support on caniuse |
| Storage Access API | Supported | Supported | Supported; needs prior first-party interaction |
Two things are easy to miss. In Firefox, a login made on app.example.com directly is invisible inside the iframe on partner.com, because the iframe reads a different, partitioned jar. And Edge's tracking prevention can block cookies for domains it classifies as trackers.
Chrome still allows third-party cookies by default (Google abandoned its plan to deprecate them in 2025), but users can block them, and Safari and Firefox restrict them. Build for the restrictive case.
How to set SameSite=None for iframe cookies
The SameSite attribute controls whether a cookie is sent on cross-site requests. Requests from a cross-site iframe are cross-site, so only one value works:
| Value | Sent in a cross-site iframe? | Use it for |
|---|---|---|
Strict | No | Cookies that should never travel cross-site |
Lax | No | Normal first-party sessions (the default in Chrome and Edge since Chrome 80 in 2020) |
None | Yes, if Secure and the browser allows third-party cookies | Anything the iframe needs |
A session cookie that fails in an iframe usually looks like one of these:
# No SameSite: treated as Lax in Chrome/Edge, so never sent in the iframe
Set-Cookie: session=abc123; Path=/; HttpOnly
# SameSite=None without Secure: rejected outright
Set-Cookie: session=abc123; Path=/; HttpOnly; SameSite=None
The version that works in a cross-site iframe (where third-party cookies are allowed):
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=None
Apply this to every cookie the embedded app depends on, not just the session: CSRF tokens, load-balancer affinity cookies and feature flags all break the same way.
Secure and HTTPS
SameSite=None requires Secure, or the browser rejects the cookie. Secure cookies are only set and sent over HTTPS, so the embedded app must be served on https://. If the parent page is HTTPS, an http:// iframe would be blocked as mixed content anyway (localhost aside).
If a load balancer handles HTTPS and forwards plain HTTP to your app, many frameworks think the request is insecure and quietly skip Secure cookies. Tell the framework to trust the proxy's X-Forwarded-Proto header (examples below).
What are partitioned cookies (CHIPS)?
CHIPS (Cookies Having Independent Partitioned State) adds a Partitioned attribute. A partitioned cookie is stored in a jar keyed to the top-level site, so app.example.com embedded on partner-a.com and on partner-b.com gets two separate cookies. Because a partitioned cookie can't be used to track users across sites, browsers that restrict third-party cookies can still allow it.
Set-Cookie: __Host-embed_session=abc123; Path=/; Secure; HttpOnly; SameSite=None; Partitioned
Partitionedmust be combined withSecure.- The
__Host-prefix is recommended. It requiresSecure,Path=/and noDomain, which pins the cookie to one host. - Chromium browsers support CHIPS. For Firefox and Safari, check current support on caniuse before you rely on it.
What CHIPS is good for: a session that starts and lives inside the embed, such as a support chat, an embedded checkout, or a comments box where the user signs in within the iframe.
What CHIPS is not for: sharing the user's first-party login. If they signed in at app.example.com in a tab, that cookie sits in the unpartitioned jar, and the partitioned iframe can't see it. They'd have to sign in again inside each embedding site. To reuse the existing login, you need the Storage Access API or a token handoff.
How to use the Storage Access API in an iframe
The Storage Access API lets an embedded document ask for access to its unpartitioned, first-party cookies. It's supported in Safari, Firefox and Chrome, and it's the standard way to reuse an existing login inside a third-party iframe.
The rules:
document.hasStorageAccess()returns a promise that resolves totrueif the frame already has access.document.requestStorageAccess()must be called from a user gesture, such as a click, inside the iframe. The browser may grant it silently or show a prompt.- Safari only grants access if the user has interacted with your site as a first party before, meaning they visited it in a tab and used it.
- After access is granted, reload the frame or refetch the data that needs cookies.
A complete example for the embedded page:
<div id="gate" hidden>
<p>Continue with your example.com account.</p>
<button id="allow-cookies" type="button">Continue</button>
</div>
<script>
async function loadSession() {
const res = await fetch('/api/me', { credentials: 'include' });
if (res.ok) {
renderApp(await res.json());
} else {
showSignIn(); // no session: fall back to a popup login
}
}
async function init() {
// Older browsers: no API, just try the cookies we have
if (!document.hasStorageAccess) return loadSession();
if (await document.hasStorageAccess()) return loadSession();
// No access yet. requestStorageAccess() needs a click inside this frame.
const gate = document.getElementById('gate');
gate.hidden = false;
document.getElementById('allow-cookies').addEventListener('click', async () => {
try {
await document.requestStorageAccess();
gate.hidden = true;
await loadSession(); // refetch now that cookies are available
// or: location.reload();
} catch (err) {
// Denied, or in Safari the user has never used example.com as a first party
showSignIn();
}
});
}
init();
</script>
Explain what the button does before the browser prompt appears, and check hasStorageAccess() on every load. Browsers remember grants for a while, but a new document may still need to request access again, so keep the button path in place.
The parent page usually needs no changes. If it sandboxes the iframe, the sandbox attribute must include allow-storage-access-by-user-activation along with allow-scripts and allow-same-origin. The iframe sandbox attribute guide covers every token. The storage-access feature can also be controlled through the allow attribute.
Alternatives to third-party cookies in an iframe
If you control both sides, or you need something that works the same in every browser, skip cookies for the iframe session entirely.
Token-based auth via postMessage
The parent page authenticates the user with its own first-party session, gets a short-lived token from its backend, and hands it to the iframe. The iframe keeps the token in memory and sends it as an Authorization header. No third-party cookies are involved.
// Parent page (partner.com)
const frame = document.getElementById('app-frame');
const APP_ORIGIN = 'https://app.example.com';
window.addEventListener('message', async (event) => {
if (event.origin !== APP_ORIGIN || event.source !== frame.contentWindow) return;
if (event.data?.type === 'ready') {
const { token } = await fetch('/api/embed-token', { method: 'POST' }).then((r) => r.json());
frame.contentWindow.postMessage({ type: 'auth', token }, APP_ORIGIN);
}
});
// Inside the iframe (app.example.com)
const ALLOWED_PARENTS = ['https://partner.com'];
let token = null;
window.addEventListener('message', (event) => {
if (!ALLOWED_PARENTS.includes(event.origin) || event.source !== window.parent) return;
if (event.data?.type === 'auth') {
token = event.data.token;
start();
}
});
// This message carries no data, so a wildcard target is acceptable here
window.parent.postMessage({ type: 'ready' }, '*');
// Then call your API with headers: { Authorization: `Bearer ${token}` }
Always check event.origin and event.source, and send the token with an explicit target origin, never "*". The postMessage guide covers handshakes and validation in more depth.
A popup login window
Open your login page in a popup from a click inside the iframe. The popup is first-party, so login cookies work normally there. When login finishes, it hands a short-lived token back to the iframe and closes.
// Inside the iframe
document.getElementById('sign-in').addEventListener('click', () => {
window.open('https://app.example.com/login?mode=popup', 'login', 'width=480,height=640');
});
// Listen for { type: 'login-complete', token } and accept it only if
// event.origin === 'https://app.example.com' (the popup's origin)
// On the login page, after a successful sign-in (popup mode)
window.opener.postMessage({ type: 'login-complete', token }, 'https://app.example.com');
window.close();
A useful side effect: the popup visit counts as first-party interaction, which satisfies Safari's precondition for the Storage Access API on later visits. If the login page sends Cross-Origin-Opener-Policy: same-origin, window.opener can end up null, so relax it for the popup route.
Put the app on a subdomain of the parent
If both sites are yours, or a customer can point a DNS record at you, serve the app from a subdomain of the parent, such as app.example.com inside www.example.com. That's same-site, so the cookies are first-party and even SameSite=Lax works:
Set-Cookie: session=abc123; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax
Same-site isn't the same as same-origin. The frames still can't read each other's DOM, but cookies flow. Two caveats: domains on the Public Suffix List, such as github.io, treat each subdomain as a separate site, and if a third site frames the parent page, the whole chain becomes cross-site again.
How to set iframe cookies in Express, Django, Rails and PHP
These snippets set SameSite=None; Secure correctly. Check your framework version for Partitioned support; where it's missing, you can write the header yourself.
Express
// Trust the proxy so Secure cookies are set behind a TLS-terminating load balancer
app.set('trust proxy', 1);
// A single cookie (partitioned: true needs a recent Express version)
res.cookie('__Host-embed_session', token, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'none',
partitioned: true,
});
// express-session: cookie: { secure: true, httpOnly: true, sameSite: 'none' }
Django
# settings.py
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SECURE = True
# Behind a TLS-terminating proxy
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
Django's XFrameOptionsMiddleware sends X-Frame-Options: DENY by default, so the iframe may not load at all. Exempt the embeddable views with @xframe_options_exempt and control framing with CSP frame-ancestors instead.
Rails
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: '_app_session', same_site: :none, secure: true
Rails' default headers include X-Frame-Options: SAMEORIGIN, which blocks cross-site embedding. Remove it for the embeddable routes and use frame-ancestors, as explained in X-Frame-Options vs CSP frame-ancestors.
PHP
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'None',
]);
session_start();
// Partitioned cookie: write the header directly if setcookie() lacks the option
header('Set-Cookie: __Host-embed=abc123; Path=/; Secure; HttpOnly; SameSite=None; Partitioned', false);
How to diagnose blocked iframe cookies
The browser records exactly why it dropped a cookie. Here's where to look.
- Application > Cookies (Chrome and Edge DevTools). Select the iframe's origin under Cookies. If the session cookie is missing, it was never stored. Check the
SameSite,Secureand Partition Key columns of the cookies that are there. - Network tab. Click the request the iframe made, then its Cookies sub-tab. Tick "show filtered out request cookies" to see cookies the browser held back. Blocked cookies are highlighted, and hovering the icon next to one gives the reason, such as a
SameSite=Laxcookie in a cross-site context orSameSite=NonewithoutSecure. BlockedSet-Cookieheaders in the response are flagged the same way. - Test in Safari and Firefox. Their storage panels show which cookies the frame really has. A cookie that works in Chrome can still be missing in Safari by design.
For a quick first pass, paste the embed URL into the testiframe.com cookie checker. The Cookies tab lists every Set-Cookie the page sends on an anonymous first visit and marks each one for Chrome/Edge, Firefox and Safari, with the reason it fails. Cookies set after login or by JavaScript won't appear there, so pair it with DevTools for the full session flow. New to embedding? Start with how to embed a website in HTML. Trying to stop framing instead? See clickjacking protection.
FAQ
Why do iframe cookies work in Chrome but not in Safari?
Chrome still allows third-party cookies by default, so a cookie set with SameSite=None; Secure works in a cross-site iframe. Safari's Intelligent Tracking Prevention blocks third-party cookies by default, so the same cookie is never sent. In Safari you need the Storage Access API, a Partitioned cookie where supported, or token-based auth.
Does SameSite=None fix iframe cookies in every browser?
No. SameSite=None; Secure is required for a cookie to be sent in a cross-site iframe, but it only removes the SameSite restriction. Safari still blocks third-party cookies, Firefox still partitions them per top-level site, and any user who blocks third-party cookies in Chrome or Edge won't send them either.
How do I keep a user logged in inside an iframe?
If the user already logged in on your site directly, call document.requestStorageAccess() from a click inside the iframe, then reload or refetch the session. If the parent site can authenticate the user, pass a short-lived token to the iframe with postMessage. If both sites are yours, host the app on a subdomain of the parent so the cookies are same-site.
Can a partitioned cookie read the login from my main site?
No. A Partitioned (CHIPS) cookie lives in a separate cookie jar keyed to the top-level site. It's useful for keeping a session alive inside one embed, but it can't see the cookies your site set when the user visited it directly. To reach those, use the Storage Access API.
Why does my login loop or fail with a CSRF error inside an iframe?
The session or CSRF cookie was set with SameSite=Lax, no SameSite attribute, or without Secure, so the browser drops it in the cross-site iframe. The server then sees a new visitor on every request and redirects to login or rejects the form. Set SameSite=None; Secure on both cookies, serve over HTTPS, and plan for Safari with the Storage Access API or tokens.