Web browsers strictly enforce the same-origin policy, meaning data stored via localStorage
or sessionStorage
on one domain (or subdomain) isn’t directly accessible on another. This is a great security feature, but it makes sharing user state (like preferences, shopping carts, or session tokens) across domains very difficult. Developers have long tried various workarounds, but they tend to be brittle or insecure:
-
Hidden iframes +
postMessage
: Embed an invisible iframe pointed at one domain, and usewindow.postMessage
to send data between domains. This can work in controlled cases, but it’s complex and error-prone (you must carefully checkmessage.origin
, maintain special pages, and coordinate loading events). Even one tutorial author notes that this approach only works for a narrow set of use cases and requires an explicit agreement between the two domains. -
Cookies across subdomains: You can set cookies with a
Domain=.example.com
attribute so subdomains share them. But cookies cannot be shared between completely different domains. Modern browsers also limit or block third-party cookies, and SameSite restrictions make cross-site sharing even harder. - URL parameters or hash fragments: Passing state in query strings (or in a shared API server and using redirects) is messy and only works on page loads. It can expose sensitive data in URLs, doesn’t sync dynamically, and is limited to initial navigation rather than live updates.
-
Deprecated tricks: Older hacks like setting
document.domain
on subdomains are deprecated for good reason – they undermine security. Libraries like Zendesk’s cross-storage used hidden iframes and message passing, but note that the last update was in 2017, reflecting how this approach has largely fallen out of favor.
None of these methods provide a clean, robust way to seamlessly sync state in real time. In short: browsers don’t let you directly share localStorage
between domains, and the old hacks to “cheat” it are brittle and hard to maintain.
A Modern Pattern: Central Sync via API/WebSocket
Instead of trying to force browser storage to be cross-domain, a modern solution is to use a centralized state service. In this pattern, all clients (from any domain) connect to a global storage/sync server. Each client reads and writes to this shared store via a simple API or WebSocket connection. The service then broadcasts changes in real time to all other connected clients. This effectively decouples state from any single domain’s storage.
The basic flow is:
- Initialize a connection from each client (on any domain) to the central service, usually via a WebSocket or long-lived HTTP connection.
-
Read/write operations (e.g.
setItem('key', value)
) are sent to the service’s backend API. - The service persists the data (in a database or durable cache) and then pushes the change to all other clients subscribed to the same object or channel.
- Each client receives the update (via WebSocket “push”) and applies it locally (e.g. updating UI or storing in in-memory cache).
This requires only a single shared “project” or “channel” ID instead of dealing with browser-local storage. All domains simply use the same credentials and object ID to access the same data. Because the syncing happens on the server side, it bypasses same-origin limits entirely: the browser simply sees a normal WebSocket or XHR request to a central (third-party) API.
The WebSocket protocol starts with an HTTP “Upgrade” handshake (client requests, server agrees), after which the connection stays open bidirectionally. When one client writes data, the backend immediately broadcasts it to all other clients on that object/channel. This means changes appear in real time on all domains with minimal delay.
In practice, you only need to set up each page or app with an SDK or client library that abstracts this. From the developer’s perspective, it looks almost like using localStorage
– you call familiar methods like setItem
and getItem
on a shared object. Under the hood, those calls go over the network to the central service, which then updates every connected session across domains. Crucially, this also handles reconnection logic, scaling, and persistence on the server side.
Without a managed sync service, implementing this yourself means running global infrastructure (load balancers, sticky-sessions, multiple servers, etc.). For example, even a simple load-balanced WebSocket deployment might look like the above. Using a hosted solution gives you global reach and reliability out of the box – you write client-side code, and the provider runs all the servers and networks needed.
Vaultrice: An Example Sync Service
One tool that embodies this modern pattern is Vaultrice, a globally-distributed real-time key-value store. Vaultrice provides a browser-friendly SDK that mimics the localStorage
API but works over the cloud. You point the SDK at a “project” ID and an object (data namespace), and it handles all the WebSocket connections and syncing for you. The result is that state is shared across domains, tabs, and even devices instantly.
In Vaultrice’s own words, it “seamlessly syncs across all tabs, devices, and even different domains,” and is “ideal for state sharing between tabs, browsers, devices, or domains”. This makes it a natural fit for things like shared user preferences, login sessions, shopping carts, or any per-user state that should persist across your sites.
For example, suppose you have a shopping cart state you want to share between site-a.com and site-b.com . With Vaultrice you might write:
import { NonLocalStorage } from '@vaultrice/sdk';
// Initialize the client with your credentials and a common object ID
const credentials = {
projectId: 'YOUR_PROJECT_ID',
apiKey: 'YOUR_API_KEY',
apiSecret: 'YOUR_API_SECRET'
};
const cartSync = new NonLocalStorage(credentials, 'shared-cart');
// On site-a.com: add an item
await cartSync.setItem('itemCount', 3);
// On site-b.com: get it or listen for updates
const itemCount = await cartSync.getItem('itemCount')
console.log(itemCount.value);
cartSync.on('setItem', 'itemCount', ({ value }) => {
console.log('Cart itemCount updated:', value); // e.g. 3
});
In this example, when site A calls setItem('itemCount', 3)
, Vaultrice stores it and if there are any clients listening also pushes an update. Any other client (on site B or anywhere) listening to that same key will immediately receive the new value. Or if you don't need a real-time update, you can simply request it with getItem('itemCount')
You never touch localStorage
or cookies at all – the SDK handles syncing for you.
Vaultrice also offers a higher-level SyncObject API that lets you treat the shared state as a regular JavaScript object. For instance, you can do:
import { createSyncObject } from '@vaultrice/sdk';
// Creates a SyncObject that joins a "live document".
const doc = await createSyncObject(credentials, 'live-document');
// Setting a property auto-syncs it
doc.title = 'Hello World'; // Under the hood: nls.setItem('title', 'Hello World')
// Listening for changes is just reading the property...
console.log(doc.title);
// but you can also explicitely subscribe for updates
doc.on('setItem', 'title', ({ value }) => {
console.log('Title changed to:', value);
});
With this pattern, you get built-in presence, broadcast messages, and reactive state without writing any server code. (Vaultrice’s docs have more examples of collaborative use cases.)
Authentication Options
Vaultrice supports two ways to authenticate clients to your project, so you can choose the one that fits your security model:
-
API Key + API Secret (auto tokens) – The simplest setup is to give each client the project’s
apiKey
,apiSecret
, andprojectId
. The SDK then automatically exchanges these for a short-lived access token and refreshes it as needed. This means you only manage one set of credentials (which you can revoke from the Vaultrice dashboard). The downside is that you’re effectively placing your secret in the client, so this approach is best used in environments you fully control (e.g. your own secure backend or a closed browser extension). -
Short-lived Access Token (manual) – Alternatively, you can generate a time-limited token on your server (using your keys) and send that single token to the client. The client then initializes with just
{ projectId, accessToken }
. This avoids putting the long-lived secret in the client altogether. The trade-off is that you must refresh the token yourself (Vaultrice lets you hook into a callback when it’s about to expire). This is useful for truly untrusted clients or public scripts. Both methods work interchangeably, so you can use API keys during development and switch to tokens in production if desired.
Security & Privacy Best Practices
Whenever you’re syncing user data across domains, it’s important to apply good security hygiene. Here are some key practices (many of which Vaultrice supports out of the box):
- Use HTTPS/TLS everywhere. All communication with Vaultrice is over TLS (HTTPS/WebSocket Secure) by default. This keeps state data encrypted in transit.
- Minimize sensitive data. Only store what you need to share (e.g. preferences, cart IDs). Avoid including highly sensitive personal data unless absolutely necessary (and consider additional encryption).
- Consider end-to-end encryption. Vaultrice offers optional client-side E2EE (Security Level 3) so that even the service can’t read your data. For maximum privacy, enable it so only your clients can decrypt shared state.
- Lock down API keys. In Vaultrice’s dashboard you can restrict each API key by allowed origin/domain or IP address. This means even if a key leaks, it can only be used from specified domains. You can also set read/write scope per key.
-
Set reasonable TTLs. If your sync entries are transient (e.g. a “featured article” or “today’s flash sale” flag), use time-to-live so data doesn’t persist longer than needed.
setItem('my-item', 'my-value', { ttl: 60 * 60 * 1000 })
By following these practices, you ensure that cross-domain syncing is as secure as possible. Remember: a compromise in one domain could be limited in scope by strong auth rules and encryption.
Conclusion
Sharing state across domains no longer needs to rely on fragile hacks. The modern solution is a dedicated sync service: clients on any domain connect to a central, real-time store and get instant updates. Tools like Vaultrice let you adopt this pattern immediately — you just embed the SDK and use a simple API, with no mandatory server to build or manage yourself.
In summary, ditch the messy iframe/cookie tricks and use a global API/WebSocket layer for cross-domain state. With Vaultrice, you get real-time, out-of-the-box syncing for your user sessions, preferences, and more — securely and reliably.