01 //Introduction
Reverse tabnabbing is a client-side phishing vector that lets a page opened from your site silently navigate the original tab to an attacker-controlled URL. The carrier is almost always an outbound target="_blank" link, a window.open() call, or a form submission that opens a new tab. The attack is over a decade old, was originally described by Mathias Bynens in 2014, and still ships in fresh reports every quarter because the vulnerable pattern, an anchor with target="_blank" and no rel="noopener", is the most natural way to write the markup.
The bug class sits in a quiet seam of the web platform. Your application is correct, the browser is correct, and the same-origin policy is enforced. The vulnerability lives in the moment a new tab is opened with a back-reference to the opener. The new tab cannot read the opener's DOM (that would be a cross-origin read), but it can assign window.opener.location, which performs a top-level navigation. Once the attacker page swaps the original tab to a pixel-perfect phishing clone of your login screen, the user has no obvious cue that anything has changed. CWE-1022 (Use of Web Link to Untrusted Target with window.opener Access) is the canonical reference.
Modern Chromium, Firefox, and Safari ship an implicit rel="noopener" for anchors with target="_blank" (since Chrome 88, Firefox 79, Safari 12.1). That fixed the default for fresh HTML, but did not retroactively fix four other paths: window.open() (still needs an explicit noopener in the features string), <form target="_blank"> submissions, server-rendered HTML cached or rendered in older engines, and embedded surfaces (PDFs, SVG, WebViews, email clients, Electron shells) that do not always follow the modern default. Code review still has to enforce noopener everywhere because the implicit default cannot be relied on across every consumer of your markup.
OWASP catalogues the issue in its Reverse Tabnabbing page and in the HTML5 Security Cheat Sheet. The defensive recipe is identical across frameworks: every anchor with target="_blank" carries rel="noopener noreferrer", every window.open passes noopener in the features string, every Markdown or rich-text renderer is configured to emit the rel attribute, and the application sets Cross-Origin-Opener-Policy: same-origin as a backstop. This module walks through the attack surface, the canonical payloads, and the per-framework fix that closes the bug class properly.
A page renders <a href="https://partner.tld" target="_blank">Partner</a> without a rel attribute. The partner site is compromised and serves window.opener.location = 'https://login-app.tld/signin'. What does the victim see?
02 //The Attack Flow
Every reverse tabnabbing attack follows the same four-stage flow: the victim clicks an outbound link from your site that opens a new tab, the browser preserves a window.opener reference in the new tab, the attacker page runs a one-line script that assigns window.opener.location to a phishing URL, and the original tab silently navigates to the attacker's clone while the victim is reading the new tab. By the time the victim returns to what they think is your site, it is not your site anymore.
target="_blank" link to an attacker-controlled URLwindow.openerwindow.opener.location = 'https://evil.tld/login'Cross-origin script access is blocked by the same-origin policy, but cross-origin navigation via window.opener.location is allowed. The attacker cannot read your DOM, but they can replace the page the user thinks they came from. By the time the victim flips back, the original tab no longer shows your site, it shows whatever the attacker chose, and the user has no obvious cue that the tab was hijacked.
The shape of a realistic exploit depends on what the new tab's origin is willing to do. Most reverse-tabnabbing reports involve a third-party site that the application links to: a partner page, an outbound advertiser, a user-submitted URL in a comment or profile, a Markdown-rendered link in a forum post. The attacker does not need to compromise your site to weaponise the bug, they only need a page that your users will click through to. That is why user-submitted content with outbound links is the highest-value sink: the attacker controls the destination directly.
Common Sink Surfaces in Real Applications
| Where outbound links live | Typical attacker payload |
|---|---|
| User profile bios, social links | Self-submitted attacker URL that triggers on click |
| Comment / post bodies (Markdown rendered) | Inline link to an attacker page |
| Forum signatures, custom statuses | Long-lived link that targets every visitor of every thread |
| Outbound advertising / affiliate links | Compromised ad network or partner content |
| Help-centre articles linking to vendor docs | Compromised vendor doc page |
| Email templates opened in webmail | Tracking link from a marketing platform |
| Documentation iframes, embedded sandboxes | Third-party docs widget with target=_blank links |
| Single sign-on / OAuth callbacks rendering "Continue to | Compromised IdP page or look-alike |
| File preview viewers that render hyperlinks (PDF, DOCX, SVG) | Crafted document with a malicious annotation link |
Markdown is the lingua franca of user-submitted text on the web: comments, READMEs, issue trackers, support tickets, knowledge-base articles. Every Markdown link [label](https://attacker.tld) compiles to an <a href>. Many renderers add target="_blank" by default for outbound URLs, and many forget to add rel="noopener noreferrer". Almost every reverse-tabnabbing CVE you will read started life in a Markdown renderer, a forum post, or a profile bio.
1// VULNERABLE, the link opens a new tab and leaves window.opener
2// populated for the destination origin.
3export function OutboundLink({ href, children }: { href: string; children: React.ReactNode }) {
4 return (
5 // BAD, target="_blank" without rel="noopener noreferrer".
6 // Implicit noopener only applies to <a target="_blank"> in the latest browser
7 // engines. WebViews, Electron shells, older Safari builds, PDF viewers,
8 // and many embedded engines still leak window.opener.
9 <a href={href} target="_blank">
10 {children}
11 </a>
12 );
13}
14
15// An attacker submitting a profile URL https://attacker.tld/landing produces an
16// anchor whose destination can navigate the original tab via
17// window.opener.location = 'https://evil-clone.tld/login'.
18// Every visitor who clicks the attacker's profile link is one navigation away
19// from a phishing page that looks identical to your login.The team patches every <a target="_blank"> to add rel="noopener noreferrer". A pentester reports window.open(url) calls in three files still leak the opener. Why is that a separate fix, and what does it tell you about the original review?
03 //Payload Anatomy & Trigger Patterns
A new tab becomes a reverse-tabnabbing sink the moment it carries a non-null window.opener for a cross-origin destination. The HTML spec preserves that reference by default for any new browsing context created with a target name, which includes target="_blank", named targets like target="popup", and window.open without explicit noopener. The defensive bytes are rel="noopener" on the markup side and "noopener" in the window.open features string on the JS side. rel="noreferrer" is the legacy cross-browser equivalent and also strips the Referer header, which is usually a good thing.
<a target="_blank" href=...>withoutrel="noopener"<form target="_blank" action=...>submissionswindow.open(url)withoutnoopenerin the features string<area target="_blank">in image maps<base target="_blank">applied document-wide- Server-rendered HTML pulled from user-submitted Markdown or rich-text editors
- Comment bodies, profile bios, and any user-supplied HTML
- Email templates rendered inside a webmail iframe with
target="_blank" dangerouslySetInnerHTMLin React with sanitised but unattributed<a>tags- Markdown renderers that emit external links with no rel attribute
- PDF and SVG viewers that follow
<a target>annotations - Browser-extension content scripts that
window.openpage URLs
The full trigger set is { target="_blank", target="_new", target="<named>" } plus any window.open() call whose features string lacks noopener. If any of these reach the DOM without rel="noopener noreferrer" and the destination is not under your first-party control, you have a tabnabbing sink. Markdown-rendered links and rich-text editors are by far the most common forgotten path.
Payload Catalogue, What an Attacker Actually Writes
1// 1) The original Bynens 2014 payload, still works in any engine
2// that does not enforce the implicit noopener default.
3if (window.opener) {
4 window.opener.location = 'https://login-app.tld/signin';
5}
6
7// 2) Delayed, defeats users who notice the immediate redirect.
8// Fires after the victim has switched away and back.
9setTimeout(() => {
10 if (window.opener) window.opener.location = 'https://login-app.tld/signin';
11}, 15_000);
12
13// 3) Combined with postMessage abuse, the opener may listen for messages
14// on a permissive origin check and trust attacker-supplied data.
15if (window.opener) {
16 window.opener.postMessage({ type: 'login', token: 'fake' }, '*');
17}A common half-fix is to add only rel="noopener" and call it done. noopener nulls window.opener, which closes the tabnabbing path, but the request to the destination still carries a full Referer header (the URL of your page, including query strings that may leak session tokens, search queries, or internal route IDs). Adding noreferrer strips the Referer as well, and as a bonus also nulls the opener on older engines that implemented noreferrer before noopener existed. The defensible rule is to ship both attributes together: rel="noopener noreferrer".
A reviewer suggests adding rel="noopener" to every outbound anchor. A teammate replies 'that still leaks the Referer, and on a few older WebViews the opener is preserved when only noopener is set without noreferrer'. Who is right and what is the minimal correct rule?
04 //Mitigation Patterns
There are three correct mitigations, in rough order of preference. Render every outbound anchor with rel="noopener noreferrer" via a shared component (so the attribute can never be forgotten); wrap window.open in a helper that always passes noopener; and set Cross-Origin-Opener-Policy: same-origin on every document so the browsing-context group is isolated from any popup that does slip through. Each one is a few lines of code; the discipline is to apply them at every sink, not just the homepage.
Mitigation Options at a Glance
| Strategy | How | When to use |
|---|---|---|
| Shared outbound-link component | One | Always. This is the primary control; banning bare in lint is the durable enforcement. |
| safeOpen() wrapper for window.open | A helper that calls window.open(url, "_blank", "noopener,noreferrer") and asserts the result | Every programmatic popup, OAuth handoff, print-preview, share-sheet, or download trigger. |
| Cross-Origin-Opener-Policy: same-origin | Server-set HTTP response header that isolates the browsing-context group | Every authenticated document. Closes the residual gap when a single anchor slips through review. |
| Configure Markdown / rich-text renderers | Tell the renderer to add target="_blank" and rel="noopener noreferrer" on outbound links | Anywhere user-submitted text becomes HTML: comments, profiles, knowledge-base articles, tickets. |
| Block data: and javascript: in href | Allow-list http(s): and mailto: protocols on user-supplied URLs before rendering | Belt-and-braces for tabnabbing combined with XSS; trim the protocol surface to what you actually need. |
| Drop target="_blank" entirely | Open outbound links in the same tab; let the user middle-click if they want a new tab | High-trust paths where you can afford the UX change; removes the sink class outright. |
1// components/OutboundLink.tsx, one canonical component used at every outbound link.
2//
3// Strategy: render <a target="_blank" rel="noopener noreferrer"> by default,
4// and let callers opt out only for first-party links (same origin).
5// A lint rule bans bare <a target="_blank"> elsewhere in the codebase.
6
7import { AnchorHTMLAttributes } from 'react';
8
9const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
10
11function isFirstParty(href: string): boolean {
12 try {
13 const u = new URL(href, window.location.origin);
14 return u.origin === window.location.origin;
15 } catch {
16 return false;
17 }
18}
19
20function assertSafeHref(href: string): void {
21 try {
22 const u = new URL(href, 'https://app.tld');
23 if (!SAFE_PROTOCOLS.has(u.protocol)) {
24 throw new Error(`unsafe protocol: ${u.protocol}`);
25 }
26 } catch {
27 throw new Error('invalid href');
28 }
29}
30
31type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
32
33export function OutboundLink({ href, children, ...rest }: Props) {
34 assertSafeHref(href);
35 const sameOrigin = typeof window !== 'undefined' && isFirstParty(href);
36 return (
37 <a
38 href={href}
39 target={sameOrigin ? undefined : '_blank'}
40 rel={sameOrigin ? undefined : 'noopener noreferrer'}
41 {...rest}
42 >
43 {children}
44 </a>
45 );
46}
47
48// Use the component at EVERY outbound link. Do not render bare <a> elsewhere.
49// <OutboundLink href={partnerUrl}>Partner site</OutboundLink>Every "I added rel=noopener everywhere" comment in a code review is incomplete. window.open(url, target) calls without an explicit noopener in the features string still hand back a window object whose opener points at the caller. The defensive form is window.open(url, "_blank", "noopener,noreferrer"), which returns null. If you need the window reference (printing, focusing back later), set win.opener = null on the returned object before assigning anything to it, but prefer the noopener form because it also prevents the new tab from running in the same process as your document.
1// lib/safe-open.ts, the only allowed wrapper for window.open.
2//
3// Lint bans direct window.open calls. Every popup, OAuth handoff, share-sheet,
4// or print-preview goes through this helper, which guarantees noopener and
5// noreferrer in the features string.
6
7const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
8
9export function safeOpen(rawUrl: string, target: string = '_blank'): Window | null {
10 let parsed: URL;
11 try {
12 parsed = new URL(rawUrl, window.location.origin);
13 } catch {
14 throw new Error('safeOpen: invalid URL');
15 }
16 if (!SAFE_PROTOCOLS.has(parsed.protocol)) {
17 throw new Error(`safeOpen: refusing protocol ${parsed.protocol}`);
18 }
19 // The features string is the load-bearing argument. Without "noopener",
20 // the returned window keeps a back-reference to the opener, which is the
21 // exact channel the attack uses.
22 const win = window.open(parsed.toString(), target, 'noopener,noreferrer');
23 // Belt-and-braces: even with noopener in the features string, defensively
24 // null the opener on the returned reference if the browser hands one back.
25 if (win) win.opener = null;
26 return win;
27}1<!-- components/OutboundLink.vue -->
2<template>
3 <a
4 :href="href"
5 :target="external ? '_blank' : undefined"
6 :rel="external ? 'noopener noreferrer' : undefined"
7 >
8 <slot />
9 </a>
10</template>
11
12<script setup lang="ts">
13const props = defineProps<{ href: string }>();
14
15const SAFE = new Set(['http:', 'https:', 'mailto:']);
16const url = new URL(props.href, window.location.origin);
17if (!SAFE.has(url.protocol)) {
18 throw new Error('OutboundLink: unsafe protocol');
19}
20const external = url.origin !== window.location.origin;
21</script>
22
23<!-- Global directive that prevents raw <a target="_blank"> in templates -->
24<!-- plugins/eslint-no-bare-blank-target.ts enforces the rule at build time. -->1// outbound-link.directive.ts, applied wherever an anchor has target="_blank".
2import { Directive, ElementRef, HostBinding, Input, OnInit } from '@angular/core';
3
4@Directive({ selector: 'a[target="_blank"]', standalone: true })
5export class OutboundLinkDirective implements OnInit {
6 @Input() href = '';
7
8 @HostBinding('attr.rel') rel = 'noopener noreferrer';
9
10 constructor(private host: ElementRef<HTMLAnchorElement>) {}
11
12 ngOnInit(): void {
13 const SAFE = new Set(['http:', 'https:', 'mailto:']);
14 let u: URL;
15 try {
16 u = new URL(this.href, window.location.origin);
17 } catch {
18 throw new Error('OutboundLinkDirective: invalid href');
19 }
20 if (!SAFE.has(u.protocol)) {
21 throw new Error(`OutboundLinkDirective: refusing protocol ${u.protocol}`);
22 }
23 }
24}1// lib/markdown-safe.js, the post-processor every Markdown renderer
2// must run before HTML reaches the response body.
3//
4// Strategy: tell the renderer to emit target="_blank" with the right rel,
5// and post-process anything user-supplied (HTML pasted into a rich-text
6// editor, imported docs, raw HTML blocks) to enforce the same default.
7
8import sanitizeHtml from 'sanitize-html';
9
10export function renderUserMarkdown(html) {
11 return sanitizeHtml(html, {
12 allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
13 allowedAttributes: {
14 ...sanitizeHtml.defaults.allowedAttributes,
15 a: ['href', 'name', 'target', 'rel'],
16 },
17 // Strip dangerous protocols outright; tabnabbing combines badly with
18 // javascript: and data: URLs in the same href.
19 allowedSchemes: ['http', 'https', 'mailto'],
20 transformTags: {
21 a: (tagName, attribs) => {
22 const href = attribs.href || '';
23 let isExternal = true;
24 try {
25 isExternal = new URL(href, 'https://app.tld').origin !== 'https://app.tld';
26 } catch {
27 /* keep external default */
28 }
29 if (isExternal) {
30 attribs.target = '_blank';
31 attribs.rel = 'noopener noreferrer';
32 } else {
33 delete attribs.target;
34 delete attribs.rel;
35 }
36 return { tagName: 'a', attribs };
37 },
38 },
39 });
40}The pattern that actually works in practice is the same as for prepared statements, HTML sanitisation, and the mail header validator: one component or helper in a shared library, plus a lint rule that flags any anchor or window.open not fed through it. The component is twenty lines of code. The discipline is what closes the bug class, every new outbound link must go through the helper, and code review enforces that.
A developer says 'modern Chrome and Firefox already null window.opener for target="_blank" anchors, so we do not need rel=noopener'. Why is that not enough?
05 //Conclusion
Reverse tabnabbing is a textbook case of trust transferred across a navigation seam. The application is correct; the browser is correct; the same-origin policy is enforced. The vulnerability lives in the moment a new browsing context is opened with a back-reference the destination did not need. Fix it the same way every adjacent bug class is fixed: a single component, applied at every sink, paired with a structured popup helper and an HTTP posture (COOP, CSP, Referrer-Policy) that limits the damage when something does slip through.
Render every outbound anchor through a shared OutboundLink component · Always set rel="noopener noreferrer" (add nofollow ugc for user-submitted content) · Wrap every window.open in a safeOpen helper that passes noopener,noreferrer in the features string · Configure Markdown and rich-text renderers to add target and rel on outbound links · Block javascript:, data:, and other unsafe protocols in href · Ban bare <a target="_blank"> via react/jsx-no-target-blank or equivalent · Add a Semgrep / CodeQL rule that flags window.open without noopener · Set Cross-Origin-Opener-Policy: same-origin, Referrer-Policy: strict-origin-when-cross-origin, and a tight CSP on every authenticated document.
When you see a new outbound link in a diff, the first question is not "does it open the right URL", it is "what reference does the destination get back to my tab, and how?" If the answer involves a raw anchor or a bare window.open, every byte in the destination URL is potentially a phishing primitive. The fix is one component and one helper away. Pair this module with the Open Redirect, Clickjacking, and postMessage Attacks guides for the adjacent classes where the bug is "an output sink hands the attacker a navigation or messaging channel back into your application."