Even though it can be a bit of work, it’s still possible to have third-party cookies work in an embedded cross-domain website that’s inside of an iframe. Even with Safari’s new restrictions, it can still be accomplished through their new experimental API.
Introduction
Back in February of 2020, Google began rolling out their change to how third-party cookies are handled. This move was to help stop embedded cross-domain sites, often social media sites, from tracking your movement around the web without you knowing. There were two basic changes made:
- The cookie
SameSite
value now defaults to Lax
instead of None
- If a value of
None
was explicitly set, then Secure
must also be set
This caused most cross-domain embedded websites to no longer be able to use cookies, even those which are not malicious, as the browser began to block them.
The good news is it’s still possible to use third-party cookies from an embedded cross-domain website inside of an iframe. The bad news is it’s more difficult now, and Safari / iOS have additional steps using experimental APIs to make this work.
This article will go through the basics of getting this scenario working using an example node.js web host. The technology stack isn’t important though – any web host and language can accomplish the same.
The Old Way
The first thing we’re going to do is create a basic example that has 2 websites on different domains, with one embedded in the other in an iframe. To do this, we’re going to take advantage of the fact that localhost
is treated as a different domain from 127.0.0.1
which means we can do all of this testing on our local machine. Hurrah.
To start, install node.js on your machine and some sort of IDE if you don’t already have them. In addition to node.js, we’ll also need express, a quick and easy web host package on top of node.js. It can be installed with npm install express
when run from your working project directory.
First, we’ll make app.js which is the entry point for our two websites:
'use strict'
const express = require('express');
const app = express();
const embeddedApp = express();
const port = 3000;
const embeddedPort = 3001;
app.use(express.static('www'));
app.listen(port, () => {
console.log(`Open browser to http://localhost:${port}/ to begin.`);
});
embeddedApp.use(express.static('www2', {
setHeaders: function (res, path, stat) {
res.set('Set-Cookie', "embeddedCookie=Hello from an
embedded third party cookie!;Path=/");
}
}));
embeddedApp.listen(embeddedPort, () => {
console.log(`Embedded server now running on ${embeddedPort}...`)
});
In addition, we’ll create two folders in the same directory, one named www for the top-level application, and another named www2 for the embedded application. In both directories, we’ll create an index.html as follows:
www/index.html
<head>
<title>Hello World top URL with embedded iframe</title>
<link rel="stylesheet" href="content/basic.css">
</head>
<body>
<h1>Cross domain iframe cookie example</h1>
<iframe src="http://127.0.0.1:3001/" width="100%" height="75%"></iframe>
</body>
www2/index.html
<head>
<title>Hello World from third party embedded URL</title>
<link rel="stylesheet" href="content/basic.css">
<script type="text/javascript" src="scripts/index.js"></script>
</head>
<body>
<h2>I am cross-domain embedded content in an iframe</h2>
<div id="cookieValue">Cookie cannot be found,
it's being rejected by the browser...</div>
</body>
And lastly, we’ll create some JavaScript to try and get the cookie, in this case, put in www2/scripts/index.js:
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
document.addEventListener('DOMContentLoaded', event => {
const cookieValue = getCookie('embeddedCookie');
if (cookieValue) {
document.getElementById('cookieValue').innerText = cookieValue;
}
});
Finally, we’re ready to run this. It can be run using the command line: node app.js.
Once running, we can browse to http://localhost:3000/ which will show us:
Notice the text that the cookie cannot be found. This means our JavaScript could not find the cookie.
This is because we’re emitting the "old style" cookie with no SameSite
and no Secure
values. This can be seen if the Developer Tools are opened and the Document request is inspected:
The cookie is highlighted in yellow by the browser as it is being actively rejected. So how do we fix this?
The New Way – SameSite, Secure and HTTPS
In order to make this work, we must modify the cookie we’re sending to include SameSite=None
to avoid the new default of Lax
:
res.set('Set-Cookie', "embeddedCookie=Hello from an embedded third party cookie!;
Path=/;SameSite=None");
But this isn’t enough, and if you load the page like this, you’ll see the same problem – Developer Tools will show the SameSite=None
, but still reject it:
This is because we also need to set the Secure
value as per Google’s second change. The Secure
value indicates the cookie should only be accepted over a secure HTTPS connection.
In order to get this to work, we must move the web application to HTTPS. To get HTTPS working locally, we can use self-signed certificates (this is just for development, after all). To do that, I followed this guide which helped immensely.
The first step is to generate self-signed SSL keys, which can be done with the openssl
commands:
openssl req -x509 -newkey rsa:2048 -keyout keytmp.pem -out cert.pem -days 365
openssl rsa -in keytmp.pem -out key.pem
To make this easier, I’ve included generated self-signed keys already made inside of the project which are valid for one year.
Now we can modify the cookie to include both Secure
and SameSite=None
:
res.set('Set-Cookie', "embeddedCookie=Hello from an embedded third party cookie!;
Path=/;Secure;SameSite=None");
We’ll also need to modify our app.js file to support HTTPS instead of HTTP. The completed modified file looks like this:
'use strict'
const fs = require('fs');
const https = require('https');
const express = require('express');
const app = express();
const embeddedApp = express();
const port = 3000;
const embeddedPort = 3001;
const key = fs.readFileSync('./ssl/www-key.pem');
const cert = fs.readFileSync('./ssl/www-cert.pem');
const embeddedKey = fs.readFileSync('./ssl/www2-key.pem');
const embeddedCert = fs.readFileSync('./ssl/www2-cert.pem');
app.use(express.static('www'));
const server = https.createServer({ key: key, cert: cert }, app);
server.listen(port, () => {
console.log(`Open browser to https://localhost:${port}/ to begin.`);
});
embeddedApp.use(express.static('www2', {
setHeaders: function (res, path, stat) {
res.set('Set-Cookie', "embeddedCookie=Hello from an embedded third party cookie!;
Path=/;Secure;SameSite=None");
}
}));
const embeddedServer = https.createServer({ key: embeddedKey, cert: embeddedCert },
embeddedApp);
embeddedServer.listen(embeddedPort, () => {
console.log(`Embedded server now running on ${embeddedPort}...`)
});
And we’ll need to update the URL on the iframe in www/index.html:
<iframe src="https://127.0.0.1:3001/" width="100%" height="75%"></iframe>
Running this will create two HTTPS websites instead of HTTP ones.
However, if you browse to the outer website, https://localhost:3000/, it will show a big scary red error with the text NET::ERR_CERT_INVALID
because the certificate is not trusted:
To bypass this, both the inner and outer websites (https://localhost:3000 and https://127.0.0.1:3001) must be opened at the top-level (in a tab), and "thisisunsafe
" must be typed. After typing this magical phrase, the website will show:
It works! We can now send a cookie from our embedded website on a different domain to the client.
But there’s still a problem with Apple …
Safari on macOS and iOS
If you open this exact same website in Safari on macOS or iOS, you’ll see the following:
That’s right, it’s back to not working. This is because Safari won’t accept third-party cookies at all even with the new SameSite
and Secure
values set on the cookie. To make matters more frustrating, if you open the Developer Tools and inspect the response, there’s no cookie listed (and no reason for rejection):
Fortunately, this too can be solved using Safari’s experimental storage access API. The process is outlined in this webkit article, and it can be summed up as follows:
- In the embedded site, use the experimental
document.hasStorageAccess()
to determine if access is available to the cookie. - If access is not available, have a button that, when pressed, will call
document.requestStorageAccess()
. This method will only work from a UI event (and will consume it). - If the request fails, then the user either denied the request or has never opened the embedded website as a first-party website (and we must help them do that).
To implement this, we’re first going to add a button to www2/index.html:
<button id="requestStorageAccessButton">Click to request storage access</button>
Then we’re going to modify the JavaScript in www2/index.js to use the new experimental API by adding the following:
if (!!document.hasStorageAccess) {
document.hasStorageAccess().then(result => {
if (!result) {
const requestStorageAccessButton =
document.getElementById('requestStorageAccessButton');
requestStorageAccessButton.style.display = "block";
requestStorageAccessButton.addEventListener("click", event => {
document.requestStorageAccess().then(result => {
window.location.reload();
}).catch(err => {
window.top.location = window.location.href +
"requeststorageaccess.html";
});
});
}
}).catch(err => console.error(err));
The process is simple: first check if the experimental API exists at all (it won’t outside of Safari), and if it does check for access, and if access isn’t there, then request it when the user clicks the button by opening a new page called requeststorageaccess.html.
The last step is to create this new page, requeststorageaccess.html:
<head>
<title>Request Storage Access</title>
<link rel="stylesheet" href="content/basic.css">
<script type="text/javascript" src="scripts/requeststorageaccess.js"></script>
</head>
<body>
<h2>Hi there. This is my brand. Learn about it, then click the button.</h2>
<button id="theButton">Click to return</button>
</body>
With the following JavaScript to handle the button click inside requeststorageaccess.js:
document.addEventListener('DOMContentLoaded', event => {
document.getElementById('theButton').addEventListener("click", event => {
window.history.back();
});
});
We’re going back in the history here since we know we’d be re-directed from our main page. Now if we restart node and render this in Safari, we’ll see the following:
Clicking the ‘Request’ button will forward the user to the new page we created on the embedded domain:
Note the URL is our embedded URL, https://127.0.0.1:3001/. This is important and the user would normally want to know what company exists on this URL. Once they know, they would (ideally) click the button to go back to where they came from, which will once again show the same page as above.
After returning, the user must click one more time on the ‘request’ button where they’ll finally receive a prompt from Safari:
Clicking ‘Don’t Allow’ will reject the Promise
from requestStorageAccess()
and ultimately lead us back to opening a page (because we don’t know why we got a rejection), while clicking ‘Allow’ will execute our code to reload the page and finally work:
And the cookie will also finally show up in Safari’s Development Tools:
As a developer note, once you accept the browser prompt to ‘Allow’, the only way to undo it is to clear the website data from Safari->Preferences…->Privacy->Manage Website Data…
If you want to re-try this process, remove the data for the embedded URL and start the process again.
Conclusion
While it can be quite a bit of work, it’s still possible to have third-party cookies work in an embedded cross-domain website that’s inside of an iframe.
Even with Safari’s new restrictions, it can still be accomplished through their new experimental API.
The full source to run the completed project is available attached to this article and also in my github repository. The source includes packages.json which means you can run npm install
in the directory to get the required packages followed by node app.js
to run the application.
History
- 21st April, 2022: Initial version