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:

RoutePurposeDetails
GET /proxy/offers?a={appId}&t={token}Serves the main offers HTML pageFetches 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 callsForwards 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.css
  • https://offers.cardlytics.com/assets/main.js/proxy/cardlytics/assets/main.js
  • url(/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

PermissionReason
allow-scriptsRequired for the Cardlytics offers UI to render and function (React-based SPA)
allow-popupsAllows offer links to open in new browser tabs when users click on deals
allow-formsAllows form submissions within the offers page (e.g., activating an offer)
allow-popups-to-escape-sandboxEnsures new tabs opened from offers are not restricted by sandbox rules

Blocked

PermissionWhat It Prevents
allow-same-originCookies, 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-navigationThe iFrame cannot redirect or navigate the parent page
allow-top-navigation-by-user-activationEven user-initiated actions cannot navigate the parent page
allow-modalsNo alert(), confirm(), or prompt() dialogs
allow-pointer-lockCannot lock the mouse cursor
allow-orientation-lockCannot lock screen orientation
allow-presentationCannot start a presentation session
allow-downloadsCannot trigger file downloads
allow-storage-access-by-user-activationCannot request storage access even with user interaction

Security Summary

ConcernMitigation
Third-party cookiesBlocked 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
IndexedDBBlocked — iFrame has null origin due to sandbox restrictions
Parent page cookiesInaccessible — the iFrame's null origin cannot read cookies belonging to the publisher's domain
Parent page navigation/hijackingBlocked — allow-top-navigation not included in sandbox
Parent window DOM/JS accessBlocked — the iFrame cannot access window.parent, window.top, or any parent context
Credential exposureMitigated — API credentials are server-side only, never sent to the browser
Cross-origin data leakageMitigated — iFrame cannot access the parent window's DOM or JavaScript context
Unwanted downloadsBlocked — allow-downloads not included in sandbox
Modal/dialog abuseBlocked — 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

IssueCauseSolution
CORS errors loading CSS/JS in iFrameSandbox null origin blocked by browserEnsure 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 pathInject history.replaceState script to rewrite path to / before app initializes
"Failed to read 'sessionStorage' property"Sandbox blocks storage accessInject in-memory storage polyfill before app scripts load
Offers page loads but assets (CSS/JS) are missingAsset URLs still pointing to Cardlytics domainVerify URL rewriting covers all patterns: relative paths, absolute URLs, and CSS url() references
Offers not displaying after token expirySession tokens are time-limitedImplement token refresh logic or reload the iFrame with a new session token