Click here to Skip to main content
15,884,629 members
Articles / Web Development / Node.js

Cross-Domain Embedding: Making Third-Party Cookies Work Again

Rate me:
Please Sign up or sign in to vote.
4.27/5 (3 votes)
21 Apr 2022CPOL7 min read 43.9K   169   3   2
Basics of making third party cookies work again using an example node.js web host
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:

  1. The cookie SameSite value now defaults to Lax instead of None
  2. 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:

JavaScript
'use strict'

// Define the basic imports and constants.
const express = require('express');
const app = express();
const embeddedApp = express();
const port = 3000;
const embeddedPort = 3001;

// Setup the outside app with the www folder as static content.
app.use(express.static('www'));

// Create the outside app and run it.
app.listen(port, () => {
  console.log(`Open browser to http://localhost:${port}/ to begin.`);
});

// Create the embedded app with the www2 folder as static content and
// set the cookie from the embedded app in the headers on all requests.
embeddedApp.use(express.static('www2', {
  setHeaders: function (res, path, stat) {
    res.set('Set-Cookie', "embeddedCookie=Hello from an 
             embedded third party cookie!;Path=/");
  }
}));

// Create the server and start it.
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

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

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:

JavaScript
// Helper function to get a cookie.
// From https://stackoverflow.com/questions/10730362/get-cookie-by-name
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 => {

    // Always try to get the cookie.
    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:

Image 1

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:

Image 2

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:

JavaScript
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:

Image 3

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:

JavaScript
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:

JavaScript
'use strict'

// Define the basic imports and constants.
const fs = require('fs');
const https = require('https');
const express = require('express');
const app = express();
const embeddedApp = express();
const port = 3000;
const embeddedPort = 3001;

// Get the keys and certs for HTTPS.
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');

// Setup the outside app with the www folder as static content.
app.use(express.static('www'));

// Create the outside app with the first key / cert and run it.
const server = https.createServer({ key: key, cert: cert }, app);
server.listen(port, () => {
  console.log(`Open browser to https://localhost:${port}/ to begin.`);
});

// Create the embedded app with the www2 folder as static content and
// set the cookie from the embedded app in the headers on all requests.
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");
    }
}));

// Create the server and start it.
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:

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:

Image 4

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:

Image 5

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:

Image 6

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):

Image 7

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:

  1. In the embedded site, use the experimental document.hasStorageAccess() to determine if access is available to the cookie.
  2. 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).
  3. 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:

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:

JavaScript
// Check for iOS / Safari.
if (!!document.hasStorageAccess) {
    document.hasStorageAccess().then(result => {

        // If we don't have access we must request it, but the request
        // must come from a UI event.
        if (!result) {

            // Show the button and tie to the click.
            const requestStorageAccessButton =
                  document.getElementById('requestStorageAccessButton');
            requestStorageAccessButton.style.display = "block";
            requestStorageAccessButton.addEventListener("click", event => {

                // On UI event, consume the event by requesting access.
                document.requestStorageAccess().then(result => {

                    // Finally, we are allowed! Reload to get the cookie.
                    window.location.reload();
                }).catch(err => {

                    // If we get here, it means either our page
                    // was never loaded as a first party page,
                    // or the user clicked 'Don't Allow'.
                    // Either way open that now so the user can request
                    // from there (or learn more about us).
                    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:

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:

JavaScript
document.addEventListener('DOMContentLoaded', event => {
    document.getElementById('theButton').addEventListener("click", event => {

        // Just go back to the outside iframe we came from.
        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:

Image 8

Clicking the ‘Request’ button will forward the user to the new page we created on the embedded domain:

Image 9

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:

Image 10

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:

Image 11

And the cookie will also finally show up in Safari’s Development Tools:

Image 12

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…

Image 13

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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Team Leader
Canada Canada
I am a Lead Software Architect (UI/UX), focusing mainly on developing with JavaScript, C#, ASP.NET WebApi and MVC.

Comments and Discussions

 
QuestionTest on Mac OS Ventura and Safari version 16.1 Pin
Cornelius Notohamiprodjo5-Dec-22 2:08
Cornelius Notohamiprodjo5-Dec-22 2:08 
QuestionThanks for the great example! Pin
Member 1563949020-May-22 10:53
Member 1563949020-May-22 10:53 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.