Safari’s new third party tracking rules, and enabling cross-domain data storage
This article is part of a series on PayPal’s Cross-Domain Javascript Suite.
Recently the Safari team announced they were adding new rules to prevent tracking using 3rd party cookies. Among other things, Safari will begin clearing cookies when a site is not loaded in first-party mode after thirty days, if it detects any third-party requests to that site (via a CORS request, iframe, embedded image, etc.)
This is a pretty interesting change: it’s definitely a good move as far as protecting privacy, preventing unwanted tracking, and avoiding adverts which follow users from one site to another. That said, this might be problematic: legitimate 3rd-party use-cases may end up being tarred by the same brush with this new policy.
In PayPal’s case, for example, we render a checkout button into an iframe, and inside this frame we use 3rd-party cookies for features like one-touch, which intelligently persists customers’ login sessions to allow them to pay on trusted devices, without re-logging in every time.
In this case (and I’m sure, many others) 3rd-party cookies aren’t something that are being used for tracking, endangering privacy, or advertising. Moreover, these cookies have been consented to implicitly by each party: the merchant has opted-in to use PayPal’s iframe-button on their page, and the buyer has opted-in to persist their authentication with one-touch.
So — this got me thinking. For legitimate cases where 3rd-party cookies are above-board, and each party has opted-in or consented, how can we mitigate Safari’s new policy?
We’ve been dealing in cross-domain components and cross-domain communication for the last few years, so let’s take advantage of post-robot and use it to store data on a totally different domain.
Ordinarily, post-robot is a messaging library which builds on the postMessage
api, to allow sending messages with full support for functions, promises, error objects, and receiving responses from other windows. So let’s take a look at how we could leverage all of that to persist data!
First, we need to figure out how we want to store the data. We’ll have to rely on some existing persistence layer, so for now let’s go with localStorage
. We also need to namespace the data domain-by-domain, so we don’t mix data from different domains and enable all kinds of security issues. Finally, since we’re dealing with localStorage
, we also need to serialize and deserialize any data we set.
So let’s create a really simple read/write store, name-spaced by domain:
function getStoreKey(domain) {
return `crossDomainStore::${ domain }`;
}function readStore(domain) {
let key = getStoreKey(domain);
let store = window.localStorage.getItem(key);
return store ? JSON.parse(store) : {};
}function writeStore(domain, store) {
let key = getStoreKey(domain);
let serializedData = JSON.stringify(store);
window.localStorage.setItem(key, serializedData);
}
Now we’ve done that, we’re ready to create a really simple pair of post-robot listeners to read and write to this store. This will enable other windows/domains to read and write to our domain’s storage, in their own name-space.
postRobot.on('read', { domain: '*' }, ({ origin, data }) => {
let store = readStore(origin);
return { result: store[data.key] };
});postRobot.on('write', { domain: '*' }, ({ origin, data }) => {
let store = readStore(origin);
store[data.key] = data.value;
writeStore(origin, store);
});
We’re setting domain: '*'
to allow any cross-domain frame to read or write data to its own namespace, but you’re free to limit this to trusted domains. If you don’t fully trust the domain, it may be worth adding extra checks here to validate what you receive and place in localStorage (for example, validating the size of each namespace, to prevent a denial of service).
Finally, we can create a small Store abstraction in the client window/iframe. This will send the read
and write
messages using post-robot to the listeners we defined earlier.
function Store(win) {
return {
get(key) {
return postRobot.send(win, 'read', { key })
.then(({ data }) => data.result);
},
set(key, value) {
return postRobot.send(win, 'write', { key, value });
}
};
}
Now we’re ready to start reading and writing data to a the other window! Let’s say we want to write data to the top level window, which is guaranteed to be a first-party domain.
let userStore = Store(window.top);userStore.set('user', { name: 'Kermit' }).then(() => {
// The data was set!
});// later...userStore.get('user').then(user => {
console.log(`Hello ${ user.name }!`);
});
We can even make this super clean with async/await!
let userStore = Store(window.top);await userStore.set('user', { name: 'Kermit' });// later...console.log(`Hello ${ (await userStore.get('user')).name }!`);
—
Obviously, this approach requires a certain degree of trust on both sides:
- The domain acting as the store needs to trust the other domain with setting arbitrary data in its name-space in
localStorage
. - The domain storing the data needs to trust the other window with whatever data is being set, or else it needs to encrypt/hash/sign the data before sending it to the untrusted domain to protect it from tampering, etc.
Some final thoughts:
- You can use the same approach to set data into
sessionStorage
, or into a local object, or a Redux store, or any other persistence layer (including a server-side store, since we avoid the need for CORS with this approach) - There are some other useful features of regular cookies that I’ve skimmed over, like expiry times. Implementing those with post-robot and cross-domain localStorage is left as an exercise to the reader.
- I’m contemplating adding this functionality directly into post-robot, so please let me know if you think this would be useful!