iFrame Sandboxing through Reverse Proxy
Advanced Security Implementation for CRP Hosted Experience
Overview
This document describes the implementation of a secure, cookie-free iFrame embedding solution for Cardlytics offers using a server-side reverse proxy combined with HTML5 iFrame sandboxing. This approach allows publishers to display Cardlytics reward offers within their application while maintaining strict control over browser security policies — specifically preventing third-party cookies, storage, and parent page access from being available to the embedded content.
Architecture
┌─────────────────────────────────────────────────────────┐
│ User's Browser │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Publisher Application │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Sandboxed iFrame │ │ │
│ │ │ sandbox="allow-scripts allow-popups │ │ │
│ │ │ allow-forms │ │ │
│ │ │ allow-popups-to-escape-sandbox" │ │ │
│ │ │ │ │ │
│ │ │ src="/proxy/offers?a={appId}&t={token}" │ │ │
│ │ │ │ │ │
│ │ │ Origin: null (no cookie/storage access) │ │ │
│ │ │ Storage: in-memory polyfill (non-persist) │ │ │
│ │ └──────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ └─────────────────────┼──────────────────────────────┘ │
│ │ │
└────────────────────────┼─────────────────────────────────┘
│ Requests go to publisher's
│ own domain (with CORS headers)
▼
┌─────────────────────────────────────────────────────────┐
│ Publisher Backend (Reverse Proxy) │
│ │
│ /proxy/offers → Fetches & rewrites HTML │
│ + injects storage polyfill │
│ + injects router path fix │
│ /proxy/cardlytics/* → Proxies CSS, JS, images │
│ /proxy/cardlytics-api/* → Proxies API calls │
│ │
│ All responses: │
│ ✓ Set-Cookie headers stripped │
│ ✓ CORS headers added (Access-Control-Allow-Origin) │
│ ✓ Asset URLs rewritten to route through proxy │
│ │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Cardlytics Infrastructure │
│ │
│ offers.cardlytics.com → Offers UI │
│ publisher-rewards-api. → Session & Rewards API │
│ cardlytics.com │
│ publisher-cdn-us. → Static assets & config │
│ cardlytics.com │
│ │
└─────────────────────────────────────────────────────────┘
How It Works
1. Session Initialization
The publisher backend authenticates with the Cardlytics API to obtain a session token. This happens server-side — credentials never reach the browser.
POST https://publisher-rewards-api.cardlytics.com/v2/session/startSession
Headers:
Content-Type: application/json
x-source-customer-id: {customerId}
x-source-organization-id: {organizationId}
x-source-portfolio-id: {portfolioId}
Body:
{
"clientId": "{clientId}",
"clientSecret": "{clientSecret}"
}
Response:
{
"sessionToken": "eyJhbGciOi...",
"isLoggedIn": true
}
2. Reverse Proxy
Instead of loading the Cardlytics offers page directly in the iFrame, the content is served through the publisher's own backend. The proxy handles three types of requests:
| Route | Purpose | Details |
|---|---|---|
GET /proxy/offers?a={appId}&t={token} | Serves the main offers HTML page | Fetches from offers.cardlytics.com, rewrites asset URLs, injects polyfills, strips cookies |
GET /proxy/cardlytics/* | Serves static assets (CSS, JS, images, fonts) | Proxies from offers.cardlytics.com, rewrites embedded URLs in text assets, strips cookies |
ALL /proxy/cardlytics-api/* | Proxies API calls | Forwards requests to Cardlytics API endpoints, strips cookies from responses |
URL Rewriting
The proxy rewrites all asset references in the HTML and CSS/JS responses so the browser fetches them through the proxy instead of directly from Cardlytics:
/assets/main.css→/proxy/cardlytics/assets/main.csshttps://offers.cardlytics.com/assets/main.js→/proxy/cardlytics/assets/main.jsurl(/images/logo.svg)→url(/proxy/cardlytics/images/logo.svg)
Cookie Stripping
Every proxy response explicitly removes Set-Cookie headers before sending the response to the browser:
res.removeHeader("Set-Cookie");3. Injected Polyfills
Because the sandbox blocks access to browser storage APIs, the Cardlytics SPA would crash when trying to use sessionStorage or localStorage. The proxy injects two scripts into the <head> of the HTML response before the app loads:
Storage Polyfill
Provides an in-memory replacement for sessionStorage and localStorage. The polyfill implements the full Web Storage API (getItem, setItem, removeItem, clear, key, length) but stores data only in memory. Nothing persists across page reloads, and no data is written to the browser's actual storage.
(function(){
function FakeStorage(){ this._d = {}; }
FakeStorage.prototype.getItem = function(k) {
return this._d.hasOwnProperty(k) ? this._d[k] : null;
};
FakeStorage.prototype.setItem = function(k, v) {
this._d[k] = String(v);
};
FakeStorage.prototype.removeItem = function(k) {
delete this._d[k];
};
FakeStorage.prototype.clear = function() { this._d = {}; };
FakeStorage.prototype.key = function(i) {
return Object.keys(this._d)[i] || null;
};
Object.defineProperty(FakeStorage.prototype, 'length', {
get: function() { return Object.keys(this._d).length; }
});
try { window.sessionStorage; } catch(e) {
Object.defineProperty(window, 'sessionStorage', {
value: new FakeStorage(), writable: false
});
}
try { window.localStorage; } catch(e) {
Object.defineProperty(window, 'localStorage', {
value: new FakeStorage(), writable: false
});
}
})();Client-Side Router Fix
The Cardlytics SPA uses client-side routing (React Router) and expects to be served at /. Since the proxy serves it at /proxy/offers, the router would fail to match any routes. The injected script uses history.replaceState to correct the URL path before the app initializes:
history.replaceState(null, '', '/' + window.location.search);This changes the browser's address bar from /proxy/offers?a=...&t=... to /?a=...&t=... within the iFrame context, allowing React Router to match the root route correctly.
4. iFrame Sandboxing
The iFrame uses the HTML5 sandbox attribute to enforce browser-level security restrictions:
<iframe
src="/proxy/offers?a={applicationId}&t={sessionToken}"
sandbox="allow-scripts allow-popups allow-forms allow-popups-to-escape-sandbox"
/>5. CORS Configuration
Because the sandboxed iFrame (without allow-same-origin) operates with a null origin, the proxy must include CORS headers on all responses to allow the iFrame to load resources:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, Accept
A preflight OPTIONS handler is included for all proxy routes to support cross-origin requests from the sandboxed iFrame.
iFrame Sandbox Permissions
Allowed
| Permission | Reason |
|---|---|
allow-scripts | Required for the Cardlytics offers UI to render and function (React-based SPA) |
allow-popups | Allows offer links to open in new browser tabs when users click on deals |
allow-forms | Allows form submissions within the offers page (e.g., activating an offer) |
allow-popups-to-escape-sandbox | Ensures new tabs opened from offers are not restricted by sandbox rules |
Blocked
| Permission | What It Prevents |
|---|---|
allow-same-origin | Cookies, localStorage, sessionStorage, IndexedDB — the iFrame operates with a null origin and cannot read or write any browser storage. The app receives an in-memory polyfill instead. |
allow-top-navigation | The iFrame cannot redirect or navigate the parent page |
allow-top-navigation-by-user-activation | Even user-initiated actions cannot navigate the parent page |
allow-modals | No alert(), confirm(), or prompt() dialogs |
allow-pointer-lock | Cannot lock the mouse cursor |
allow-orientation-lock | Cannot lock screen orientation |
allow-presentation | Cannot start a presentation session |
allow-downloads | Cannot trigger file downloads |
allow-storage-access-by-user-activation | Cannot request storage access even with user interaction |
Security Summary
| Concern | Mitigation |
|---|---|
| Third-party cookies | Blocked at two layers — Set-Cookie headers stripped by proxy AND allow-same-origin omitted from sandbox |
| Browser storage (localStorage, sessionStorage) | Blocked — iFrame has null origin; app receives a non-persistent in-memory polyfill |
| IndexedDB | Blocked — iFrame has null origin due to sandbox restrictions |
| Parent page cookies | Inaccessible — the iFrame's null origin cannot read cookies belonging to the publisher's domain |
| Parent page navigation/hijacking | Blocked — allow-top-navigation not included in sandbox |
| Parent window DOM/JS access | Blocked — the iFrame cannot access window.parent, window.top, or any parent context |
| Credential exposure | Mitigated — API credentials are server-side only, never sent to the browser |
| Cross-origin data leakage | Mitigated — iFrame cannot access the parent window's DOM or JavaScript context |
| Unwanted downloads | Blocked — allow-downloads not included in sandbox |
| Modal/dialog abuse | Blocked — allow-modals not included in sandbox |
Implementation Reference
Backend (Express.js / Node.js)
const CARDLYTICS_BASE = "https://offers.cardlytics.com";
// CORS headers for sandboxed iFrame (null origin)
function setCorsHeaders(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers",
"Content-Type, Authorization, Accept");
}
// Preflight handler
app.options("/proxy/*", (req, res) => {
setCorsHeaders(res);
res.sendStatus(204);
});
// Main offers page proxy
app.get("/proxy/offers", async (req, res) => {
const { a, t } = req.query;
const response = await fetch(
`${CARDLYTICS_BASE}/?a=${a}&t=${t}`
);
let html = await response.text();
// Rewrite asset URLs to route through proxy
html = html.replace(
/(href|src)="\/(?!\/)/g,
'$1="/proxy/cardlytics/'
);
// Inject in-memory storage polyfill (prevents sessionStorage crash)
const storagePoly = `<script>
(function(){
function F(){this._d={};}
F.prototype.getItem=function(k){return this._d.hasOwnProperty(k)?this._d[k]:null;};
F.prototype.setItem=function(k,v){this._d[k]=String(v);};
F.prototype.removeItem=function(k){delete this._d[k];};
F.prototype.clear=function(){this._d={};};
F.prototype.key=function(i){return Object.keys(this._d)[i]||null;};
Object.defineProperty(F.prototype,'length',{
get:function(){return Object.keys(this._d).length;}
});
try{window.sessionStorage;}catch(e){
Object.defineProperty(window,'sessionStorage',{value:new F(),writable:false});
}
try{window.localStorage;}catch(e){
Object.defineProperty(window,'localStorage',{value:new F(),writable:false});
}
})();
</script>`;
// Inject router path fix (SPA expects to be at "/")
const routerFix = `<script>
history.replaceState(null, '', '/' + window.location.search);
</script>`;
html = html.replace('<head>', `<head>${storagePoly}${routerFix}`);
res.setHeader("Content-Type", "text/html; charset=utf-8");
setCorsHeaders(res);
res.removeHeader("Set-Cookie");
res.send(html);
});
// Static asset proxy (CSS, JS, images, fonts)
app.get("/proxy/cardlytics/*", async (req, res) => {
const assetPath = req.params[0];
const response = await fetch(
`${CARDLYTICS_BASE}/${assetPath}`
);
const contentType = response.headers.get("content-type");
if (contentType) res.setHeader("Content-Type", contentType);
setCorsHeaders(res);
res.removeHeader("Set-Cookie");
// Rewrite URLs in text-based assets
if (contentType?.match(/css|javascript|html/)) {
let text = await response.text();
text = text.replace(
/https:\/\/offers\.cardlytics\.com\//g,
"/proxy/cardlytics/"
);
res.send(text);
} else {
const buffer = Buffer.from(await response.arrayBuffer());
res.send(buffer);
}
});
// API proxy
app.all("/proxy/cardlytics-api/*", async (req, res) => {
const apiPath = req.params[0];
const targetUrl =
`https://publisher-rewards-api.cardlytics.com/${apiPath}`;
const fetchOptions = {
method: req.method,
headers: {
"User-Agent": req.headers["user-agent"] || "",
"Accept": req.headers["accept"] || "*/*",
"Content-Type": req.headers["content-type"] || "application/json",
},
};
if (req.method !== "GET" && req.method !== "HEAD" && req.body) {
fetchOptions.body = JSON.stringify(req.body);
}
const response = await fetch(targetUrl, fetchOptions);
const contentType = response.headers.get("content-type");
if (contentType) res.setHeader("Content-Type", contentType);
setCorsHeaders(res);
res.removeHeader("Set-Cookie");
res.status(response.status);
const buffer = Buffer.from(await response.arrayBuffer());
res.send(buffer);
});Frontend
<iframe
src="/proxy/offers?a={applicationId}&t={sessionToken}"
sandbox="allow-scripts allow-popups allow-forms
allow-popups-to-escape-sandbox"
title="Exclusive Offers"
/>Request Flow
1. Browser loads publisher page
2. Frontend calls POST /api/session (publisher's own endpoint)
3. Backend authenticates with Cardlytics API (server-side, credentials never exposed)
4. Backend returns sessionToken to frontend
5. Frontend sets iFrame src="/proxy/offers?a={appId}&t={token}"
6. Browser loads iFrame (sandboxed, null origin)
7. Proxy fetches offers.cardlytics.com, rewrites URLs, strips cookies
8. Proxy injects storage polyfill + router fix into <head>
9. iFrame renders offers content (storage calls use in-memory polyfill)
10. Any asset/API requests from iFrame go through /proxy/cardlytics/*
11. All proxy responses include CORS headers and never include Set-Cookie
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| CORS errors loading CSS/JS in iFrame | Sandbox null origin blocked by browser | Ensure all proxy responses include Access-Control-Allow-Origin: * and an OPTIONS preflight handler |
| "No routes matched location /proxy/offers" | Cardlytics SPA client-side router sees wrong path | Inject history.replaceState script to rewrite path to / before app initializes |
| "Failed to read 'sessionStorage' property" | Sandbox blocks storage access | Inject in-memory storage polyfill before app scripts load |
| Offers page loads but assets (CSS/JS) are missing | Asset URLs still pointing to Cardlytics domain | Verify URL rewriting covers all patterns: relative paths, absolute URLs, and CSS url() references |
| Offers not displaying after token expiry | Session tokens are time-limited | Implement token refresh logic or reload the iFrame with a new session token |
Updated 1 day ago