Windows, iframes & postMessage
Learn how popups, iframes, sandboxing, postMessage, origin checks, and clickjacking defenses let browser windows communicate safely.
- 01Open popups safelyOpen a popup only from a user click, handle a blocked popup, and explain what
noopenerdoes. - 02Use iframes safelySay what a page may do with an iframe from its own origin and from another origin, and why
allow-scriptsplusallow-same-originis risky. - 03Check messagesSend data with
postMessage, then checkevent.origin,event.source, and the data before acting on it.
Several windows, one careful page
A web app is not always one page in one tab. It often uses other windows too:
- A popup: a new tab or window, for example for signing in.
- An
iframe: a box inside your page that shows another page, for example a payment form. - A sandboxed frame: an iframe with most of its powers turned off, for content you don't trust.
Each of these is a browsing context: a separate place where a page lives. Each one has its own window object.
The main idea of this lesson is: a reference is not permission. A reference is a variable that points at another window. Holding one doesn't mean you can look inside. The browser still decides what you may read, change, or send.
The browser decides with the same-origin policy from the CORS lesson. Two pages have the same origin when their scheme (like https), host, and port all match. Only then may one page freely read the other.
When one page opens a new one, the new page gets window.opener. That is a reference back to the page that opened it. An untrusted new page can use it to send your page to a fake login page. This attack is called reverse-tabnabbing.
The noopener option cuts that reference, so the new page sees null. For a link, write target="_blank" together with rel="noopener". That attribute does the same job for links as the noopener option does for window.open.
The table compares the two kinds of extra window. Each row is one question, answered for a popup and for an iframe.
| Question | Popup | Iframe |
|---|---|---|
| How do you make one? | Call window.open(), or use a link with target="_blank". | Put an <iframe> element in the page. |
| Where does it show up? | In its own tab or window, in most browsers. | Inside the current page, like an image. |
| What is it often used for? | Signing in with another site, print previews, extra tools. | Maps, payment forms, videos, chat boxes, and code demos. |
| What is the safe habit? | Open it from a click, check for null, and use noopener. | Use sandbox, load only pages you need, and check every message. |
Some windows in your house look into another room of your own house. You can reach through and move things. Other windows look into your neighbor's house. You can see that the house is there, but you can't reach in. If you want something from your neighbor, you knock and pass a note.
- In real life: A window into another room of your own house
- In JavaScript: A same-origin iframe: you may read and change it
- In real life: A window into your neighbor's house
- In JavaScript: An iframe from another origin: you can see it but not reach in
- In real life: Knocking and passing a note
- In JavaScript: Sending a message with
postMessage
This lesson teaches five safe habits:
- Open a popup only when the user clicks.
- Treat a frame from another origin as closed.
- Send each message to one named origin.
- Check every message when it arrives.
- Stop other sites from putting your important pages in a frame.
window.open and popups
INTERACTIVEA popup is a new tab or window. JavaScript opens one with window.open(url, target, features):
urlis the page to load.targetis a name for the new window.featuresis a text list of options, such as the size ornoopener.
It returns a reference to the new window. But browsers have popup blockers. They usually allow a popup only when window.open runs right inside a user action, such as a button click. This is called a user gesture. A call from a timer or from page load is often blocked. Then window.open returns null.
Safe popup code has four steps:
- Open the popup from a click.
- Check whether you got
null. - Use only what the browser allows.
- Close the popup when you are done.
const link = document.createElement("a");
link.href = "https://news.example";
link.target = "_blank";
link.rel = "noopener";
console.log(link.rel);
const popup = { opener: null };
console.log(popup.opener);Lines 1 to 4 build a link that opens news.example in a new tab, with rel="noopener". Line 5 prints noopener. Line 7 makes a stand-in object for a new tab opened with noopener. Its opener is null, so line 8 prints null.
noopener, the new tab can reach back through window.opener and send your page somewhere else. With it, opener is null.The lab below opens about:blank. That is an empty page with the same origin as the page that opened it, so the lab may write into it. The lab writes a heading, reads the popup's closed property, and closes it. The second button opens a popup with noopener. It shows that window.open then returns null.
button.addEventListener("click", () => { const popup = window.open("about:blank", "lessonPopup", "width=360,height=240"); if (!popup) return "blocked"; popup.document.write("<h1>Hello from the opener</h1>"); console.log(popup.closed); popup.close();}); const safePopup = window.open("about:blank", "_blank", "noopener");console.log(safePopup);Press a button. The popup opens because you clicked.
Browsers usually allow this popup because window.open runs right inside a click handler. Code that tries later, without a click, is often blocked.
For a link that opens a new tab, write target="_blank" rel="noopener". Many browsers now add noopener to such links by themselves. Write it anyway. It tells the next reader that the new page must not control yours.
Iframes and same-origin access
An iframe element shows another page inside the current page. The page around it is called the parent. What the parent may do depends on the frame's origin:
- Same origin: the parent may read and change the frame's DOM (its elements), call its functions, and use the same storage.
- Another origin: the parent still gets a reference to the frame's window. But most reads through it throw a
SecurityError.
<iframe id="preview" srcdoc="<p>Preview</p>"></iframe>
const frame = document.querySelector("#preview");
console.log(frame.contentDocument.body.textContent);
console.log(frame.contentWindow === frame.contentDocument.defaultView);Line 1 is an iframe. Its srcdoc attribute holds the frame's HTML right in the attribute. A page made this way has the same origin as the parent, so the parent may read it.
- Line 3 finds the iframe element.
- Line 4 reads the frame's text through
contentDocument, the frame's document. - Line 5 checks that
contentWindow, the frame's window object, is the window of that same document.
If src pointed to another origin, contentWindow would still exist. But contentDocument would be null, and reading the frame's document through its window would throw.
The table sums this up. Each row is one kind of frame: what your code can usually do with it, and what it can't.
| Kind of frame | What your code can usually do | What your code cannot do |
|---|---|---|
| An iframe from your own origin | Read and change its DOM, call its functions, and use the same storage. | Get around the sandbox, if the frame has one. |
| An iframe from another origin | Keep a reference to its window. Call postMessage, focus, blur, and close. Read closed, length, and frames. Set location in limited ways. | Read its DOM, cookies, storage, full URL, variables, or most properties. |
A sandboxed iframe without allow-same-origin | Use only the powers its sandbox tokens turn on. Its messages show the origin "null". | Act like a normal page from your origin. |
A frame from another origin still allows a short list of things through its window reference:
- Send it a message with
postMessage. - Call
focus,blur, orclose. - Read a few simple properties:
closed,length, andframes. - Set its
locationto load a new page, in limited ways.
It can't read the other page's DOM, cookies, storage, variables, or full URL.
The browser doesn't care that both pages run on your laptop. If the scheme, host, or port is different, the origins are different. For example, http://localhost:3000 and http://localhost:4000 are two origins. The URL objects lesson shows how to read each part of a URL.
Frames can hold frames, so the windows on a page form a tree, like folders inside folders. Each window has names for the windows around it.
const frame = document.querySelector("iframe");
console.log(window.parent === window);
console.log(window.top === window);
console.log(window.frames.length);
console.log(frame.contentWindow === window.frames[0]);- Line 1 finds the iframe element.
- Line 2 checks whether
parent, the window just outside this one, is this window itself. - Line 3 checks whether
top, the outermost window, is this window itself. - Line 4 prints how many child frames this page has.
- Line 5 checks that
frames[0]is the same window as the iframe'scontentWindow.
The table lists these names. Read each row as: this name, what it points to, and when you use it.
| Name | What it points to inside a frame | What you use it for |
|---|---|---|
| window | This page's own window. | Use this page's own tools and data. |
| parent | The window just outside this one. At the top, it is window itself. | Talk to the page that holds this frame. |
| top | The outermost window. | Find out whether this page is inside a frame. |
| frames | A list of this page's child frames. | Reach a child frame by number or name, when allowed. |
On a page that is not inside a frame, window.parent === window and window.top === window are both true. Inside an iframe, parent is the page just outside it, and top is the outermost page. These names only point at windows. They don't get past the origin checks.
postMessage and origin checks
STEP THROUGHTwo windows from different origins can't read each other. They can still work together by sending messages. The sender calls otherWindow.postMessage(data, targetOrigin). A little later, the other window gets a message event. The event has three parts you will check:
event.data: a copy of what was sent.event.origin: the origin of the page that sent it.event.source: the window object that sent it.
targetOrigin is the origin the receiving window must have. If that window has moved to a page from another origin, the browser doesn't deliver the message.
Anyone can push a note through your mail slot. So before you act on a note, you read the return address. You also check who handed it in. When you send a note, you write a name on the envelope. If someone else lives at the house now, the note is not delivered.
- In real life: The note
- In JavaScript:
event.data, a copy of what was sent - In real life: The return address
- In JavaScript:
event.origin - In real life: The person who handed it in
- In JavaScript:
event.source - In real life: The name on the envelope
- In JavaScript:
targetOrigin
Sandboxed frames need extra care. A sandboxed frame is an iframe with the sandbox attribute, which turns off most of its powers. Without the allow-same-origin token, the frame gets an opaque origin: an origin with no name. Message events show it as the text "null".
You can't write an opaque origin as a targetOrigin. So to send to such a frame, you must use "*", which means "any origin". That makes the checks on the receiving side even more important.
const frame = document.querySelector("iframe");const trustedWindow = frame.contentWindow; window.addEventListener("message", (event) => { if (event.origin !== "null") return; if (event.source !== trustedWindow) return; show("accepted", event.data);}); trustedWindow.postMessage({ kind: "ping" }, "*");Press a button to send a message.
The trusted frame is sandboxed without allow-same-origin, so its origin is the text null. This page's own origin doesn't match it, so that message is never delivered. Only * delivers it. Then the parent checks the origin, the source, and the data.
The lab has two sandboxed frames made from srcdoc. Both have the origin "null". Only one is the trusted frame: its contentWindow is saved in trustedWindow. The stranger frame can send the same data, but it fails the event.source check.
"null", so the origin check can't tell them apart. The parent also checks event.source, so only the trusted frame's message is accepted.Step through the same checks below. Pick the trusted frame or the stranger, then watch lines 5 and 6 decide.
Pick the trusted frame or the stranger. Then step through the origin check and the source check.
script
const trustedFrame = "lesson-frame"; function receiveMessage(event) { const originOK = event.origin === trustedOrigin; const sourceOK = event.source === trustedFrame; if (!originOK || !sourceOK) return "rejected"; return "accepted: " + event.data.kind;} receiveMessage(incomingEvent);The message data is copied with structured clone: the browser makes a full copy of the data for the other window. The two windows never share one object. Functions and DOM elements can't be copied, so you can't send them. The next lesson covers copying in detail. Here, the habit to learn is: check a message before you act on it.
function acceptMessage(data) {
if (!data || data.type !== "cart-ready" || typeof data.id !== "number") return "reject";
return "accept";
}
console.log(acceptMessage({ type: "cart-ready", id: 3 }));
console.log(acceptMessage({ type: "cart-ready", id: "3" }));- Line 2 returns
"reject"unlesstypeis"cart-ready"andidis a number. - Line 5 passes an
idof3, a number, so it printsaccept. - Line 6 passes
"3", a string that only looks like a number.typeofgives"string", so it printsreject.
The origin and source checks tell you who sent a message. This check makes sure the data is what your code expects.
- Checks
event.originandevent.sourcebefore changing anything - Sends a secret token with
targetOrigin"*" - Accepts only objects with
type: "cart-ready"and a numberid - Runs whatever command name arrives in
event.data.action - Uses
child.postMessage(message, "https://pay.example")for a payment frame - Accepts every message whose origin is
"null"
Sort each handler. Look for four things: a named target origin, an origin check, a source check, and a check of the data.
Sandboxed frames
INTERACTIVEThe sandbox attribute starts an iframe with most of its powers turned off. An empty sandbox blocks scripts, forms, popups, and dialogs. It also takes away the frame's real origin.
You add powers back one at a time with tokens: words you list in the attribute. Each token turns one power back on:
allow-scriptslets scripts run.allow-formslets forms submit.allow-popupslets the frame open popups.allow-modalslets it show dialogs, such asalert.allow-same-originkeeps the frame's real origin instead of"null".
<iframe
sandbox="allow-scripts"
allow="fullscreen; payment"
srcdoc="<p>Limited preview</p>"
></iframe>Line 2 lets this frame run scripts, and nothing else from the sandbox list. Line 3 is a different attribute, allow. It is a permissions policy: it turns on chosen browser features, here full screen and payments.
The two attributes do different jobs. sandbox limits what the page may do. allow turns on browser features. So allow="payment" does not let a frame read the parent page.
// The parent rebuilds the iframe with selected tokens.frame.sandbox = "allow-scripts allow-forms";frame.srcdoc = predefinedHtml; // Inside predefinedHtml:parent.postMessage({ scriptRan: true, origin: self.origin, canSubmitForms: sandbox.contains("allow-forms"), canOpenPopups: sandbox.contains("allow-popups"), canShowModals: sandbox.contains("allow-modals")}, "*");nullStart with everything off. Then turn on only the tokens the frame really needs. Without allow-scripts, this frame can't run the script that sends its report.
Be very careful with sandbox="allow-scripts allow-same-origin" when the framed page has the same origin as yours. Its scripts can run, and it keeps your origin. So a script inside it can reach your page and remove the sandbox. For content you don't trust, leave out allow-same-origin. Talk to the frame only with checked messages.
That's why the sandbox demo in the CORS lesson and the message lab here both use fixed srcdoc HTML with sandbox="allow-scripts". The code can run, but the frame keeps an opaque origin. Everything it says goes through a few checked messages.
Clickjacking: when the visual page lies
INTERACTIVEClickjacking tricks a user into clicking a button on another page. It is also called UI redressing. It doesn't read the other page. It works like this:
- The attacker puts an important page, such as a bank page, in an iframe on their own site.
- They make that frame invisible and show a harmless-looking decoy instead.
- They line up the bank's real button with the decoy's fake button.
- The user clicks the fake button, but the click lands on the real one.
Picture a clear plastic sheet laid over a real button. A Play button is printed on the sheet. You press Play, but your finger pushes the Transfer money button under the sheet.
- In real life: The picture printed on the sheet
- In JavaScript: The decoy page you see
- In real life: The button under the sheet
- In JavaScript: The real button in the framed page
- In real life: Thinking you pressed Play
- In JavaScript: The user sees only the decoy
- In real life: Actually pressing Transfer
- In JavaScript: The browser sends the click to the framed page
In the lab, a decoy covers a fake bank frame. Lower the decoy's opacity to see the page hiding behind it.
<iframe title="fake bank" sandbox srcdoc="...bank button..."></iframe><div class="decoy"> <p>Looks like a video player.</p> <button>Play</button></div> /* Lower the decoy opacity to reveal the hidden frame underneath. */Video preview
You see the decoy, but another page can sit behind it.At high opacity you see only a harmless video decoy. Lower it, and the fake bank page behind it shows through. The real defense is a header sent by the bank's server, not a script on the bank's page.
The fix belongs to the important page, not to the user. The table lists the defenses. Each row says who sets the defense and what it does.
| Defense | Who sets it | What it does |
|---|---|---|
X-Frame-Options: DENY | The server | No site may show this page in a frame. |
X-Frame-Options: SAMEORIGIN | The server | Only pages from the same origin may show it in a frame. |
Content-Security-Policy: frame-ancestors 'self' https://partner.example | The server | Lists exactly which sites may show the page in a frame. This is the newer way. |
| A script that tries to break out of the frame | The page's own JavaScript | Doesn't work reliably. The attacker's page can block, delay, or sandbox the script. |
// Response headers, sent before the page is framed
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'A response header is a line of information the server sends along with the page. Line 2 tells the browser that no page may show this page in a frame. Line 3 says the same thing in the newer Content-Security-Policy way. The browser reads these headers before it shows the page, so it can refuse the frame before anyone clicks.
This lesson page doesn't send these headers, because it has no important buttons to protect. An admin site, an account settings page, or a payment page should send them. On this site, the admin pages send X-Frame-Options: DENY.
Where you will use this
You will meet these tools in everyday web work:
- Signing in with another site. Open the sign-in site in a popup from a click. When sign-in ends, the last page in the popup sends one success message back to your page.
- Payment forms. Show the payment company's page in an iframe. Send it setup data with its exact
targetOrigin. Accept only messages from its origin and its window. - Content previews. Show a draft page in a sandboxed iframe, so it can't change the editor page. The preview sends messages for things like its height.
- Live code demos. Run demo code in sandboxed
srcdocframes. The frame sends results back as messages, and the page checks each one.
- Save the window you expect: the popup or the iframe's
contentWindow. - Send with an exact
targetOrigin. Use"*"only for a frame with an opaque origin. - On arrival, check
event.origin,event.source, and the fields ofevent.data. - Ignore messages you don't know. Don't throw an error from the message handler.
- Protect important pages with
X-Frame-Optionsor CSPframe-ancestors.
Common misconceptions
"I have a reference to the window, so I can read it."
Not if it has another origin. The reference allows only a few things, mainly postMessage. Most reads are blocked.
"Checking the origin is always enough."
Every frame with an opaque origin shows "null". One page can also hold several frames from the same origin. So check event.source too.
"Sending with targetOrigin "*" is always unsafe."
Sometimes you need it, for a frame with an opaque origin. Then never send secrets with it, and check every message that comes back.
"A sandbox makes any iframe safe."
The tokens matter. allow-scripts together with allow-same-origin lets a same-origin page remove its own sandbox.
"A script on my page can stop clickjacking."
Not reliably. The attacker's page can block or sandbox that script. Use server headers: X-Frame-Options or CSP frame-ancestors.
Practice: safe windows and frames
6 EXERCISESA parent page wants to send a short message with no secrets to a sandboxed srcdoc frame. The frame's origin is "null". Which targetOrigin must the parent use?
Use *. It is the only way to deliver to a frame with an opaque origin. Keep the message free of secrets. The frame still checks event.origin, event.source, and the data when the message arrives.
Read the code and predict the word it prints.
const trustedOrigin = "null";
const trustedSource = "frame-a";
const event = { origin: "null", source: "frame-b" };
console.log(event.origin === trustedOrigin && event.source === trustedSource ? "accept" : "reject");It prints reject. The origin is "null", which matches. But the source is frame-b, not the trusted frame-a, so the && is false.
This object stands in for a popup. What two values print, in order?
const popup = { closed: false, close() { this.closed = true; } };
console.log(popup.closed);
popup.close();
console.log(popup.closed);It prints false, then true. A real popup window has a closed property that works the same way.
Name one header or CSP rule that stops other sites from putting an important page in a frame.
Good answers are X-Frame-Options or CSP frame-ancestors. A script alone can't reliably stop clickjacking.
Find the security problem in this handler. Then type the risky pair of sandbox tokens from this lesson.
window.addEventListener("message", (event) => {
if (event.origin === "null") {
runCommand(event.data.action);
}
});window.addEventListener("message", (event) => {
if (event.origin !== "null") return;
if (event.source !== trustedFrame.contentWindow) return;
if (!event.data || event.data.type !== "preview-ready") return;
markPreviewReady();
});The first handler trusts every message from an opaque origin and runs any command name it is sent. The safer handler checks the origin, the source, and the data, then does one known thing. The risky token pair is allow-scripts with allow-same-origin.
Write a small check for messages from a cart iframe. Accept { type: "cart-ready", id: 3 }. Reject a message whose id is the string "3".
function acceptMessage(data) {
if (!data || data.type !== "cart-ready" || typeof data.id !== "number") return "reject";
return "accept";
}
console.log(acceptMessage({ type: "cart-ready", id: 3 }));
console.log(acceptMessage({ type: "cart-ready", id: "3" }));The first object has the right type and a number id, so it prints accept. The second has the string "3", so the check prints reject.
Quiz: check your understanding
8 QUESTIONSYour score counts first tries. Every choice has an explanation, so read them even when you get it right.
Question 1 of 8After calling
window.open, what should your code check first?Choose an answer to see the explanation.
Question 2 of 8What does this message check print for the stranger frame?
Read the code, then predictconst trustedOrigin = "null"; const trustedSource = "lesson-frame"; const event = { origin: "null", source: "stranger-frame" }; console.log(event.origin === trustedOrigin && event.source === trustedSource ? "accept" : "reject");Choose an answer to see the explanation.
Question 3 of 8Why do you sometimes need
targetOrigin"*"to send to a sandboxedsrcdocframe?Choose an answer to see the explanation.
Question 4 of 8Which of these can you do with a window from another origin?
Choose an answer to see the explanation.
Question 5 of 8What is risky about
sandbox="allow-scripts allow-same-origin"on a page from your own origin?Choose an answer to see the explanation.
Question 6 of 8This object stands in for a popup. What does the code print?
Read the code, then predictconst popup = { closed: false, close() { this.closed = true; } }; console.log(popup.closed); popup.close(); console.log(popup.closed);Choose an answer to see the explanation.
Question 7 of 8Which defense against clickjacking works reliably?
Choose an answer to see the explanation.
Question 8 of 8What does
window.top === windowtell a page?Choose an answer to see the explanation.
Key takeaways
- Open a popup only from a user click. Check for
null, and usenoopenerso the new page can't reach back. - Your page may read an iframe from its own origin. An iframe from another origin is closed, but you can still send it messages.
postMessagesends a copy of the data. It arrives a little later as amessageevent.- Before acting on a message, check
event.origin,event.source, and the fields ofevent.data. - A sandbox turns off a frame's powers, and tokens turn them back on one by one. Don't give a same-origin page both
allow-scriptsandallow-same-origin. - Server headers stop clickjacking:
X-Frame-Optionsand CSPframe-ancestors.
One-liner.
Windows and frames can work together safely: the browser limits what each may touch, and your code checks every message before it acts.
Up next: Messaging & structured cloning.