Blog · 2026-06-21 · window.opener · Tab-napping · COOP · MCP Servers

MCP Server window.opener Security: tab-napping, Cross-Origin-Opener-Policy, rel=noopener, and opener-based phishing

When an MCP server renders a link with target="_blank", the opened tab receives a live window.opener reference pointing back to the MCP client window. A malicious destination page can call window.opener.location = "https://attacker.com/login-phishing", silently replacing the MCP client UI with a credential-harvesting fake — while the user is reading the newly opened tab. This class of attack is called tab-napping and it remains one of the most underappreciated cross-origin browser vulnerabilities. This post covers the full model: how window.opener leaks, what rel=noopener and rel=noreferrer do and don't fix, why Cross-Origin-Opener-Policy: same-origin provides stronger protection at the browsing-context level, and the precise SkillAudit severity ratings for each variant.

How window.opener is created

Every time a browser opens a new tab or window — whether via <a target="_blank">, window.open(), or a form with target="_blank" — the browser records an opener browsing context for the newly created window. The new window can access this reference via the global window.opener property.

Critically, this reference is cross-origin accessible in a limited but dangerous way: a cross-origin page cannot read DOM or cookies from the opener, but it can set window.opener.location to navigate the opener to any URL. This is a deliberate browser behavior (some sites rely on it for OAuth popups), but when tool output in an MCP client renders unvetted links, it becomes an injection vector.

<!-- MCP tool output renders this link -->
<a href="https://attacker.com/tool-result" target="_blank">View result</a>

<!-- Inside attacker.com/tool-result, this JavaScript runs immediately -->
<script>
  // window.opener is the MCP client window.
  // We can redirect it even though we're cross-origin.
  if (window.opener) {
    // Replace the MCP client with a fake login page
    window.opener.location = 'https://attacker.com/fake-skillaudit-login';
    // Or just harvest: attacker waits until user returns to the "MCP client" tab
  }
</script>

	

What makes this dangerous for MCP servers specifically: MCP tool output frequently renders links to external resources — web search results, documentation pages, GitHub repositories, API reference pages. Every one of those external links, if rendered with target="_blank" and without rel="noopener", creates an opener channel. If any linked page has been compromised (or is controlled by an attacker who targeted the MCP server), they receive a direct handle to the MCP client window — the most sensitive surface in the entire agent pipeline.

The tab-napping attack in detail

Tab-napping works because the victim's attention model does not match the browser's security model. Here is the complete attack flow when an MCP server renders unsafe external links:

1

MCP tool processes request — searches the web, reads a document, or calls an API. Returns HTML output including a link to an external resource: <a href="https://attacker.com/report" target="_blank">View full report</a>

2

User clicks the link — a new tab opens for the "external report." The opened page is either attacker-controlled or a compromised legitimate page. window.opener inside the new tab points to the MCP client window.

3

User reads the opened tab — while focused on the new tab, the attacker page executes window.opener.location = 'https://attacker.com/fake-mcp-login' in a setTimeout or when document.visibilityState changes to visible (indicating the user is on this tab).

4

MCP client tab is silently replaced — the MCP client URL bar now shows the attacker's phishing page. The user sees nothing unusual; the tab indicator might not flash.

5

User returns to MCP client tab — sees a login screen or session-expired message. Enters credentials, which are captured by the attacker. The MCP session state, API keys, and agent context are at risk.

The attack is silent, requires no browser vulnerability, and works in every major browser without any user interaction beyond clicking a rendered link. It has been known since 2010 but remains prevalent because developers conflate visual appearance (looks like an ordinary link) with security posture (creates a cross-origin window handle).

The fix: rel="noopener" and rel="noreferrer"

The primary fix is to add rel="noopener" to every <a target="_blank"> link. This instructs the browser not to set window.opener in the newly opened tab — the opened page's window.opener will be null, not a reference to the MCP client.

<!-- Before (vulnerable) -->
<a href="https://example.com" target="_blank">External link</a>

<!-- After (safe) -->
<a href="https://example.com" target="_blank" rel="noopener">External link</a>

<!-- Also safe (noreferrer implies noopener + suppresses Referer header) -->
<a href="https://example.com" target="_blank" rel="noreferrer">External link</a>

	

rel=noopener vs rel=noreferrer: the tradeoff

rel="noreferrer" does two things that rel="noopener" does not:

Attribute Severs window.opener Suppresses Referer header Affects navigation history
rel="noopener"YesNo — Referer is still sentNo
rel="noreferrer"Yes (implied)Yes — no Referer headerBrowser may treat as new session for analytics
Neither (vulnerable)NoNo — Referer is sentStandard

For most MCP server output, rel="noopener noreferrer" is the correct combination — it prevents the opener reference and also prevents the MCP client's URL from leaking in the Referer header to the destination page, which may contain user-specific session paths or query parameters. However, if the destination page is a same-origin resource that needs the Referer for analytics, use rel="noopener" alone.

Modern browsers (Chrome 88+, Firefox 79+): Starting in 2021, browsers began defaulting target="_blank" to imply rel="noopener" behavior even without the attribute, when the link is cross-origin. However, this browser default does not apply in all contexts (non-HTML output, same-origin links, older browser targets, or sandboxed iframes with allow-popups). SkillAudit still flags missing rel="noopener" because the implicit behavior is not guaranteed in the full target matrix and because explicit intent documents the security decision for future maintainers.

Cross-Origin-Opener-Policy: same-origin — the stronger fix

While rel="noopener" addresses opener leakage at the link level, the Cross-Origin-Opener-Policy (COOP) HTTP header addresses it at the browsing context level. COOP instructs the browser to sever the opener relationship for all cross-origin navigations from the document — not just explicitly tagged links.

# Set in the HTTP response for the MCP client HTML document
Cross-Origin-Opener-Policy: same-origin

When Cross-Origin-Opener-Policy: same-origin is set on the MCP client's HTML response, the browser places the document into its own browsing context group. Any cross-origin page that attempts to reference window.opener pointing to the MCP client will receive null — the opener chain is severed at the browsing context level, regardless of whether the links that opened those windows had rel="noopener".

COOP also enables crossOriginIsolated — a flag that unlocks SharedArrayBuffer and high-resolution timers in the document. This is required for the strongest cross-origin isolation posture and Spectre mitigation. The combination of COOP and Cross-Origin-Embedder-Policy: require-corp achieves full process isolation in Chromium-based browsers.

COOP: same-origin

Document is placed in its own browsing context group. No cross-origin page can reference this window via window.opener, window.open() return value, or named window references. Enables crossOriginIsolated when paired with COEP. Breaks cross-origin OAuth popups that require window.opener communication.

COOP: same-origin-allow-popups

Document is placed in its own browsing context group, but popups it opens (via window.open()) inherit the same group if they navigate to a same-origin URL, and are placed in a separate group if they navigate cross-origin. Use this when the MCP client needs to open OAuth popups that postMessage back to the opener. Prevents external sites from getting window.opener back to the MCP client.

When COOP: same-origin breaks OAuth popups

The most common breakage from enabling COOP: same-origin is OAuth and SSO flows that use the popup pattern: the application opens a popup window pointing to the identity provider, the identity provider authenticates and redirects to a callback URL, and the callback page uses window.opener.postMessage() to send the auth token back to the main window.

Under COOP: same-origin, the popup and the main window are in different browsing context groups as soon as the popup navigates to the cross-origin identity provider. window.opener inside the popup is null, so postMessage fails silently. The fix is to use COOP: same-origin-allow-popups instead — this severs incoming opener references (external pages cannot get a handle to the MCP client) while preserving outgoing popup communication (popups the MCP client opens can still postMessage back).

# Caddyfile — strongest protection that preserves OAuth popups
skillaudit.dev {
  header Cross-Origin-Opener-Policy "same-origin-allow-popups"
  # Add COEP for full crossOriginIsolated (requires CORS/CORP on all subresources)
  header Cross-Origin-Embedder-Policy "require-corp"
  file_server { root /srv/product }
}

window.opener and same-origin navigation

One subtlety: if a target="_blank" link navigates to a same-origin URL, the opened page is in the same browsing context group and the window.opener reference works as expected. This is usually intentional — the same-origin page may need to call back to the opener via window.opener.postMessage() or read window.opener.location.href for a redirect. Same-origin opener communication is not a vulnerability (the same-origin policy already governs DOM access), but it means that rel="noopener" on same-origin links breaks intended functionality without providing a security benefit, since same-origin pages already have full DOM access via other means.

The correct policy is: add rel="noopener noreferrer" only to cross-origin target="_blank" links, or use COOP: same-origin-allow-popups to handle this at the header level without per-link management.

Sanitizing MCP tool output that renders links

When an MCP server returns HTML content that is rendered in the client browser, every <a> tag in that output is a potential opener channel. The sanitization approach depends on how the client renders tool output:

Server-side transformation: If the MCP server itself generates the HTML rendered in the browser, it should post-process all <a target="_blank"> tags to add rel="noopener noreferrer" before sending the response. A simple regex substitution — s/target="_blank"/target="_blank" rel="noopener noreferrer"/g — is sufficient if the server generates the HTML. For untrusted HTML from external sources (web pages fetched by the tool), run through DOMPurify with ADD_ATTR: ['target'] and then post-process.

Client-side transformation: If the MCP client renders HTML via innerHTML (after sanitization), walk all <a> elements post-insertion and enforce rel="noopener noreferrer" on any that have target="_blank":

function safeRenderToolOutput(html, container) {
  // First sanitize with DOMPurify
  const clean = DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
  container.innerHTML = clean;

  // Then enforce noopener on all target=_blank links
  container.querySelectorAll('a[target="_blank"]').forEach(a => {
    const rel = new Set((a.rel || '').split(/\s+/).filter(Boolean));
    rel.add('noopener');
    rel.add('noreferrer');
    a.rel = [...rel].join(' ');
  });
}

DOMPurify does not add rel=noopener by default. DOMPurify focuses on XSS prevention — it removes script tags and event handlers, but it does not modify rel attributes on <a> tags. You must separately post-process links after sanitization. The two-step pattern above is the correct approach: DOMPurify first (XSS prevention), then rel enforcement (opener prevention).

window.open() from MCP tool output scripts

Beyond rendered <a> tags, scripts in MCP tool output (if allowed to run) can call window.open() directly. This creates the same opener relationship but is not addressed by rel="noopener" — that attribute only applies to anchor elements, not to programmatic window opening.

The fix for window.open()-based opener creation is the noopener window feature flag:

// Vulnerable — opened window gets window.opener reference
window.open('https://example.com');
window.open('https://example.com', '_blank');

// Safe — opened window gets window.opener === null
window.open('https://example.com', '_blank', 'noopener');
window.open('https://example.com', '_blank', 'noopener,noreferrer');

For MCP clients that sandbox tool output scripts (strongly recommended), the sandbox's Content Security Policy can block window.open() entirely. The safest posture is to not run scripts from tool output at all — render HTML as static markup only.

Cross-origin window handles via named windows

A related but less-known attack surface: browsers support named windows, where window.open('https://example.com', 'mywindow') opens a window with the name mywindow. Any page in the same browsing context group can navigate this window by name using window.open('https://attacker.com', 'mywindow') — even without holding a direct JavaScript reference to the window object. This means an attacker page opened in the same browsing context group can hijack named windows, including any <iframe name="mywindow"> or windows opened programmatically with a reused name.

COOP: same-origin fully prevents this: cross-origin pages are in a separate browsing context group and cannot address named windows in the MCP client's group by name.

SkillAudit findings for window.opener

HIGHMCP tool output renders <a target="_blank"> links to external domains without rel="noopener" or rel="noreferrer". Each such link creates a cross-origin opener channel enabling tab-napping phishing against the MCP client. Score −18.
HIGHMCP client HTML does not set Cross-Origin-Opener-Policy header. External sites that obtain a reference to the MCP client window (via opener or named window) can navigate the MCP client to an attacker-controlled URL. Score −16.
MEDIUMClient-side tool output rendering does not post-process links to enforce rel="noopener" after DOMPurify sanitization. DOMPurify does not add the attribute automatically; scripts that open windows must pass noopener as a window feature. Score −12.
MEDIUMCOOP: same-origin is set but OAuth popup flow breaks: the identity provider popup navigates cross-origin and loses window.opener, so postMessage-based token delivery fails. Correct header is same-origin-allow-popups for this use case. Score −10.
LOWrel="noopener" is present on target="_blank" links but rel="noreferrer" is absent — the MCP client URL with any session identifiers in the query string is sent as Referer to the destination. Score −6.

Implementation checklist

  • Add rel="noopener noreferrer" to all target="_blank" links in statically rendered MCP output templates
  • Post-process dynamically rendered tool output HTML to enforce rel="noopener noreferrer" on all <a target="_blank"> elements after DOMPurify sanitization
  • Set Cross-Origin-Opener-Policy: same-origin-allow-popups (or same-origin if no OAuth popups) in the MCP client HTTP response header
  • Verify the COOP header is set on all HTML responses including error pages (nginx: use always flag)
  • Pass noopener as a window feature in any window.open() calls that open cross-origin URLs
  • Avoid reusing named window targets across different origins in the same browsing session
  • Test tab-napping manually: click a rendered link, then in the opened tab's console run window.opener.location = 'https://google.com' — if the original tab navigates, the fix is missing

Run a SkillAudit scan on your MCP server or Claude skill to check for opener leakage in rendered HTML output. The scanner inspects tool output rendering code for missing rel="noopener" attributes, checks HTTP response headers for COOP, and probes whether DOMPurify sanitization is followed by opener enforcement post-processing.