Skip to content

Iframes and 3-D Secure ​

Русская версия: ru/iframe.md

An <iframe> shows one web page inside another. 3-D Secure uses this: the bank's challenge page is loaded inside our checkout page. That sounds simple, but the moment the two pages come from different sites (different origins), the browser puts a wall between them for safety. This document explains that wall - what each setting does, why it exists, and how our project uses it.

If you only remember one thing: the parent page and the framed page each control different things, and they cannot read each other's insides.


Two sides of the wall ​

mermaid
flowchart LR
    subgraph parent["Your page (parent)"]
      A["sets on the &lt;iframe&gt;:<br/>sandbox, referrerpolicy, allow"]
    end
    subgraph frame["Framed page (the bank)"]
      B["sets via HTTP headers:<br/>CSP frame-ancestors, X-Frame-Options,<br/>Set-Cookie SameSite/Secure, COOP/COEP/CORP"]
    end
    A -- "postMessage (check origin!)" --> B
    B -- "postMessage (check origin!)" --> A
  • The parent decides how much the frame is allowed to do (via attributes on the <iframe> tag).
  • The framed page decides who is allowed to embed it and how its cookies behave (via HTTP response headers).
  • Neither can reach into the other's DOM when they are cross-origin. They only talk through postMessage.

What the parent sets (attributes on <iframe>) ​

In this project: apps/demo/src/features/checkout/ui/three-ds-challenge.tsx.

sandbox ​

A locked-down frame that grants back only the capabilities you list.

Flag we useWhat it grantsWhy
allow-scriptsrun JavaScriptthe bank page needs it
allow-formssubmit formsto send the OTP
allow-same-originkeep its own originso its cookies work and its postMessage has a real event.origin

Flags we deliberately do not grant: allow-top-navigation (a frame should not be able to move your whole window), allow-popups, allow-modals.

mermaid
flowchart LR
    subgraph box["🔒 Sandboxed frame (locked by default)"]
      G1["✅ allow-scripts"]
      G2["✅ allow-forms"]
      G3["✅ allow-same-origin"]
      D1["🚫 top-navigation"]
      D2["🚫 popups"]
      D3["🚫 modals"]
    end

A well-known trap: allow-scripts + allow-same-origin together are dangerous only if the framed page is same-origin as the parent - then it could script its own sandbox off. Here the bank is a different origin, so allow-same-origin just lets it keep the bank's origin. It gives the frame no power over our page.

Alternative: drop allow-same-origin entirely. Then the frame runs in an "opaque" origin - no cookies, and its messages arrive with event.origin === "null", which breaks the origin check. That is why we keep it for a cross-origin bank.

referrerpolicy="no-referrer" ​

Do not tell the bank which page sent the user. Alternative: strict-origin sends only the origin, not the full URL.

allow (Permissions Policy) ​

Grants specific browser features to the frame (camera, one-time-code autofill, WebAuthn, ...). We do not need any here, so we omit it. Real bank SDKs sometimes add allow="otp-credentials; publickey-credentials-get" for OTP autofill and passkeys.

target on the form ​

The hidden form posts into the frame with target="acs-frame" (iframe mode). The same form with target="_top" sends the whole window to the bank instead - that is the redirect mode (see below).


What the framed page sets (HTTP headers) ​

In this project: apps/bank-sim/src/acs/lib.ts (securityHeaders).

Deep dive, header by header in plain words: security-headers.md.

HeaderWhat it doesAlternative
Content-Security-Policy: frame-ancestors <origin>who may embed this page (anti-clickjacking)X-Frame-Options (legacy)
X-Frame-Options: ALLOW-FROM / DENY / SAMEORIGINold version of the above; modern browsers ignore ALLOW-FROMuse frame-ancestors
Set-Cookie: ...; SameSite=None; Securelets the cookie exist inside a third-party frame (needs https)SameSite=Lax/Strict = no cross-site cookie
Cross-Origin-Opener-Policy: same-originisolate the browsing-context groupunsafe-none to turn off
Cross-Origin-Embedder-Policy: require-corprequire subresources to opt inunsafe-none
Cross-Origin-Resource-Policy: cross-originallow another origin to embed this responsesame-origin to block
X-Content-Type-Options: nosniffstop MIME sniffing-

The cookie line is the one people trip on: a frame from another site is a "third-party" context, and modern browsers drop its cookies unless they are SameSite=None; Secure. Secure means https only - which is why the ACS server runs over TLS.


Talking across the wall: postMessage ​

Cross-origin pages cannot read each other's variables or DOM (the same-origin policy). The only channel is window.postMessage. Two rules keep it safe:

  1. When you receive a message, check event.origin against an allowlist. A message alone proves nothing - any page could send a fake "success".
  2. When you send, pass an explicit target origin, never "*".
js
// receiving (parent)
window.addEventListener('message', (event) => {
  if (event.origin !== ACS_ORIGIN) return // trust only the bank
  // ...treat as a hint, then confirm with the backend
})

Golden rule for payments: the message is only a hint. The real outcome always comes from your backend (here authenticate() re-checks the status). A message can be forged; a server answer cannot.

mermaid
flowchart TD
    M1["postMessage from the bank<br/>event.origin = bank ✅"] -->|"origin OK → treat as hint"| CHK
    M2["postMessage from evil-site<br/>event.origin = attacker 🚫"] -->|"origin fails → ignored"| DROP["🛑 dropped"]
    CHK{"then ask the backend"} -->|"backend confirms"| PAID["✅ really paid"]
    CHK -->|"backend disagrees"| NO["🚫 not paid<br/>(message was faked)"]

Iframe vs full-page redirect ​

Banks do 3-D Secure in one of two shapes, and this project supports both.

IframeFull-page redirect
Where the bank showsin a frame inside your pageyour whole page navigates to the bank
Return signalpostMessage back to the parentthe bank sends a 302 to your return URL
Your statekept (no reload)wiped (full reload) - carry what you need in the URL
Cookiesthird-party (needs SameSite=None; Secure)first-party on the bank's own page
frame-ancestorsmattersnot involved (top-level page)

That last row is the real reason both exist: inside a frame the bank is a third-party (strict cookie and clickjacking rules); as a top-level redirect it is first-party and those rules relax.


How this project wires it ​

  • Parent / UI: apps/demo/src/features/checkout/ui/three-ds-challenge.tsx - the <iframe>, its sandbox/referrerpolicy, the two forms (iframe and redirect), and the origin-checked message listener.
  • Framed "bank": apps/bank-sim/src/acs/ - a standalone https server that sets the headers above and, in redirect mode, 302s back to /3ds/return. See apps/bank-sim/README.md.
  • The wider payment picture: architecture.md.

Further reading ​

Apache-2.0 · Headless engine, provider adapters and runnable examples.