The attack never puts your page in a frame, so the headers on NGINX, Cloudflare and CloudFront have nothing to block. Here is how it works, what actually stops it, and an Angular component with tests you can copy.
It's Friday, 4:40 p.m., and your security review just came back clean. The report has one line you're proud of: "Clickjacking: protected." You set X-Frame-Options and frame-ancestors on NGINX months ago, the scanner agrees, and your session cookie is SameSite=Lax.
Then a teammate drops a link in chat. "Try this. Just double-click the box."
A fake verification window pops up. You double-click. Nothing seems to happen. Ten seconds later you open your account settings, and a stranger's app is sitting in the list of connected apps with permissions you never agreed to.
You never saw a consent screen. You never even saw your own site. So how does DoubleClickjacking get past a setup that every checklist calls correct?
Here's what you'll get from this article: how the attack really works, the families it comes in, what NGINX, Apache, Cloudflare and CloudFront can and can't do about it, and a small client-side gate that closes the hole. You'll build that gate as an Angular component and test it.
One question to carry with you: if this attack never puts your page in a frame, what exactly are those headers protecting? I'll answer it near the end.
If you like security write-ups told as stories, with code you can paste into a project today, follow me here on Medium so the next one finds you.
Before we get into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular/React versions. Always refer to the official documentation for the most current API and syntax.
What happens when you double-click a fake CAPTCHA?
Quick version. Classic clickjacking hides your site inside an invisible frame and tricks you into clicking through it. Browsers fought back with headers that say "don't let other sites frame me." DoubleClickjacking skips the frame completely. The attacker's page opens a second window on top and asks you to double-click. While you're busy doing that, the window underneath quietly navigates to your real page, say an OAuth approval screen. Your first click closes the top window. Your second click lands on whatever is now underneath. That's your Approve button.
Real-world version. Security researcher Paulos Yibelo published the technique in December 2024, and the coverage that followed reported that it sidesteps X-Frame-Options, CSP and SameSite cookies. The reason is boring and important: your page is opened as a normal top-level page, in a normal window, with the victim's normal session. From the browser's point of view nothing is being framed, and nothing about the request is cross-site.
The usual targets are screens where one click does something serious: approving an OAuth app, confirming a payment, flipping a security setting. If you want more angles on the same exploit, these are worth your time: the ICOIN 2026 paper on clickjacking and double-clickjacking prevention, my earlier piece on this exploit, the Indusface explainer and the YesWeHack write-up.
Under the hood. A double-click is two presses with a gap between them. The browser delivers each press to whichever window is under the cursor at that moment. So the attacker only has to change what's under the cursor inside that gap. Here's the timeline in plain text:
time ------------------------------------------------------------> 1. attacker page opens a small window on top: "double-click to verify" 2. that window navigates the window underneath to YOUR consent page 3. first press and release -> handled by the top window, which closes itself 4. second press and release -> lands on the page that is now underneath (your Approve button) Notice what's missing. There's no iframe anywhere in that story. The user sees one fake box and does the one thing it asked for.
That's the original trick. The headline hides how many shapes it comes in, so let's count them.
How many ways can DoubleClickjacking happen?
It depends on how you count, and I don't trust anyone who gives you a single number. These are the four families I'd keep in your head.
The window swap. The original. A top window asks for a double-click while the window underneath is sent to the target page. Everything above describes this one.
The held-key variant. Researcher Jorian Woltjer pushed the idea further in a write-up on making the attack smoother with popups and keyboard input. In his research, a victim who is holding a key can hand the attacker a steady supply of user activations, which is what lets popups open and swap without a fresh click each time. Takeaway for you: a keypress isn't automatic proof of intent.
The extension and wallet variant. Reports on the original research name browser extensions as targets, including crypto wallets, Web3 transaction prompts and VPN toggles that can be switched off.
The touch variant. The same reports say phones are affected too, through double-taps instead of double-clicks.
I can't promise that list is complete, and new variants keep showing up. That's why I'd defend the behavior (a button that fires on a click nobody meant) and not any single variant.
So here's a question for you. Which screens in your app do something expensive on a single click? Write that list down, because you'll need it in a minute.
First, though, let's do what everyone does and reach for headers.
Why don't X-Frame-Options and frame-ancestors stop it?
Quick version. Those headers answer exactly one question: "may another page frame me?" In this attack nobody frames you, so there's no question for the header to answer.
Here's the screen from your Friday story. This is the wrong version, the one that still runs.
src/app/consent.component.ts
import { Component, input, output } from '@angular/core'; @Component({ selector: 'app-consent', template: ` <h2>{{ appName() }} wants access to your account</h2> <p>Scopes: {{ scopes().join(', ') }}</p> <button type="button" data-testid="approve" (click)="approved.emit()">Approve</button> `, }) export class ConsentComponent { readonly appName = input.required<string>(); readonly scopes = input<string[]>([]); readonly approved = output<void>(); } It renders an Approve button that works on the very first click. That's the weakness, and no server setting touches it.
Now the first attempt, the one every checklist suggests: set the framing headers. They're still worth having, since classic clickjacking hasn't gone anywhere, so do this and keep reading.
NGINX. Put the headers in the server block and add always so error pages get them too.
/etc/nginx/conf.d/app.conf
server { listen 443 ssl; server_name app.example.com; add_header X-Frame-Options "DENY" always; add_header Content-Security-Policy "frame-ancestors 'none'" always; location / { proxy_pass http://127.0.0.1:4200; } } Pitfall: NGINX only inherits add_header lines from the level above when the current level has none of its own. The moment a location block adds a header, your server-level ones vanish for that location. The NGINX headers module docs spell this out.
Apache. Same idea, with mod_headers.
/etc/apache2/conf-available/framing.conf
Header always set X-Frame-Options "DENY" Header always set Content-Security-Policy "frame-ancestors 'none'" Run a2enmod headers and a2enconf framing, then reload Apache.
Cloudflare. If you can't touch the origin, add a response header Transform Rule in the dashboard. If you prefer code, a Worker does the same job.
src/worker.ts
export default { async fetch(request: Request): Promise<Response> { const upstream = await fetch(request); const headers = new Headers(upstream.headers); headers.set('X-Frame-Options', 'DENY'); headers.set('Content-Security-Policy', "frame-ancestors 'none'"); return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers, }); }, }; Pitfall: headers.set replaces any Content-Security-Policy your origin already sends. If you have one, merge frame-ancestors 'none' into it instead of overwriting it.
CloudFront. Create a response headers policy and attach it to your cache behavior.
policy.json
{ "Name": "framing-protection", "Comment": "Block framing of every response", "SecurityHeadersConfig": { "FrameOptions": { "FrameOption": "DENY", "Override": true }, "ContentSecurityPolicy": { "ContentSecurityPolicy": "frame-ancestors 'none'", "Override": true } } } aws cloudfront create-response-headers-policy --response-headers-policy-config file://policy.json The command returns JSON that includes the new policy's Id. Copy it into the ResponseHeadersPolicyId field of the cache behavior that serves your app, or the policy exists and does nothing.
Whichever server you use, verify it from the outside:
curl -sI https://app.example.com/oauth/authorize | grep -i -E "x-frame-options|content-security-policy" Expected output:
x-frame-options: DENY content-security-policy: frame-ancestors 'none' Real-world version. Both headers work as designed. A browser consults them when a document is about to load inside a frame. In this attack your consent page loads into a top-level window, so there's no frame and no ancestor to check. SameSite=Lax doesn't help either: it exists to stop cross-site requests, and a top-level navigation is the exact case Lax allows.
Under the hood. This is why the report I mentioned earlier says the old defenses are blind to it. The defenses answer questions about where your page is embedded. The attack changes which window your user's next click goes to.
Every green tick above is real, and none of them touches this attack.
Have you ever added a security header, watched the scanner turn green, and later found out it was guarding a different door? Tell me which header and which door in the comments.
So if headers can't see the attack, what can? Something small, and slightly rude to your own users.
What if the button refuses to work until a human proves they're there?
Back to your Friday. Your first instinct is to open the config files again and add one more header. There's nothing left to add. Those headers describe where your page may live, and this attack doesn't care where it lives. The flaw is smaller and closer to home: your Approve button trusts every click it receives.
Quick version. Load sensitive buttons disabled. Enable them only after the page sees something a person looking at this window would do, like moving the mouse a little or pressing Tab. In the attack, the cursor is parked over the fake box when your page slides in underneath. The pointer never moves inside your page, so the button stays dead and the second click goes nowhere. This is the client-side fix Yibelo recommends in his write-up: keep critical buttons disabled until a gesture shows up.
Real-world version. The naive version, "enable on any mousemove", has holes, and I found a few by thinking about real devices. Touch screens never fire mousemove, so a pure pointer gate would lock out every phone user. A held key repeats keydown events, and we saw earlier that an attacker can get a victim to hold a key, so repeats must not count. If the window loses focus, whatever trust you earned should be thrown away. The gate below handles all of that. On touch screens it falls back to a short delay after the window gains focus, set longer than the gap between two quick taps. That delay is my pick, so tune it against your own users.
src/app/gesture-gate.ts
export interface GestureGateOptions { /** Pixels the pointer must travel before we trust it. */ minTravel?: number; /** Delay before arming on touch screens, after the window gains focus. */ touchDelayMs?: number; /** Where to listen. Defaults to the global document. */ root?: Document; /** Override for tests. Defaults to a (pointer: coarse) media query. */ isCoarsePointer?: () => boolean; } export interface GestureGate { destroy(): void; } export function createGestureGate( onChange: (armed: boolean) => void, options: GestureGateOptions = {}, ): GestureGate { const root = options.root ?? document; const win = root.defaultView ?? window; const minTravel = options.minTravel ?? 8; const touchDelayMs = options.touchDelayMs ?? 800; const isCoarsePointer = options.isCoarsePointer ?? (() => win.matchMedia?.('(pointer: coarse)').matches ?? false); let armed = false; let last: { x: number; y: number } | null = null; let travelled = 0; let timer: ReturnType<typeof setTimeout> | undefined; const set = (value: boolean): void => { if (armed !== value) { armed = value; onChange(value); } }; const clearTimer = (): void => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } }; const startTimer = (): void => { clearTimer(); timer = setTimeout(() => set(true), touchDelayMs); }; const reset = (): void => { last = null; travelled = 0; clearTimer(); set(false); }; const onMove = (e: MouseEvent): void => { if (last) { travelled += Math.hypot(e.clientX - last.x, e.clientY - last.y); } last = { x: e.clientX, y: e.clientY }; if (travelled >= minTravel) { set(true); } }; const onKey = (e: KeyboardEvent): void => { // A held key repeats. An attacker can make the victim hold a key. if (e.repeat) return; if (e.key === 'Tab') set(true); }; const onBlur = (): void => reset(); const onFocus = (): void => { if (isCoarsePointer()) startTimer(); }; const onVisibility = (): void => { if (root.visibilityState === 'hidden') reset(); }; root.addEventListener('mousemove', onMove); root.addEventListener('keydown', onKey); root.addEventListener('visibilitychange', onVisibility); win.addEventListener('blur', onBlur); win.addEventListener('focus', onFocus); if (isCoarsePointer() && root.hasFocus()) startTimer(); return { destroy(): void { clearTimer(); root.removeEventListener('mousemove', onMove); root.removeEventListener('keydown', onKey); root.removeEventListener('visibilitychange', onVisibility); win.removeEventListener('blur', onBlur); win.removeEventListener('focus', onFocus); }, }; } The gate takes a callback and returns a handle you can destroy, so it isn't tied to Angular. The component is where the signal lives. This is the fixed version of the screen from earlier.
src/app/consent.component.ts
import { Component, DestroyRef, inject, input, output, signal } from '@angular/core'; import { createGestureGate } from './gesture-gate'; @Component({ selector: 'app-consent', template: ` <h2>{{ appName() }} wants access to your account</h2> <p>Scopes: {{ scopes().join(', ') }}</p> <button type="button" data-testid="approve" [disabled]="!armed()" (click)="approved.emit()" > Approve </button> @if (!armed()) { <small data-testid="hint">Move your mouse or press Tab to enable this button.</small> } `, }) export class ConsentComponent { readonly appName = input.required<string>(); readonly scopes = input<string[]>([]); readonly approved = output<void>(); protected readonly armed = signal(false); constructor() { const gate = createGestureGate((value) => this.armed.set(value)); inject(DestroyRef).onDestroy(() => gate.destroy()); } } What you should see in the browser:
page loads -> Approve is disabled, the hint is visible mouse moves 8px+ -> Approve is enabled, the hint disappears window loses focus -> Approve is disabled again Under the hood. The gate asks for evidence that only a person at your window can produce. Another origin can't dispatch pointer events into your page, and a parked cursor produces none. That evidence has limits. Yibelo floated a browser-level header called Double-Click-Protection as an idea, and I found no sign of any browser honoring a header like that, so don't add one and expect protection. I also couldn't verify a browser-level fix when I researched this, so read current browser release notes and test the browsers you support.
Assistive technology deserves its own check. If a screen reader doesn't produce the key events the gate listens for, add the ones it does produce, or your keyboard-only users will meet a button that never wakes up. And if an attacker already has script running on your page, no gate helps, because that's a different problem.
A gate like this is only worth something if it keeps working after the next refactor, so let's pin it down with tests.
How do you test a gate you can't click through?
Quick version. You don't click. You dispatch the same events a browser would and check the button after each one. In a new Angular project, ng test runs Vitest by default since Angular 21 (see the Angular testing guide). Older projects use Karma and Jasmine, so swap vi.fn() for jasmine.createSpy() and vi.useFakeTimers() for jasmine.clock().install().
src/app/consent.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ConsentComponent } from './consent.component'; describe('ConsentComponent', () => { let fixture: ComponentFixture<ConsentComponent>; const approve = (): HTMLButtonElement => fixture.nativeElement.querySelector('[data-testid="approve"]'); const move = (x: number, y: number): void => { document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y })); fixture.detectChanges(); }; const press = (init: KeyboardEventInit): void => { document.dispatchEvent(new KeyboardEvent('keydown', init)); fixture.detectChanges(); }; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ConsentComponent] }).compileComponents(); fixture = TestBed.createComponent(ConsentComponent); fixture.componentRef.setInput('appName', 'Photo Printer'); fixture.componentRef.setInput('scopes', ['read:photos']); fixture.detectChanges(); }); it('starts disabled', () => { expect(approve().disabled).toBe(true); }); it('ignores a click when no gesture happened', () => { const spy = vi.fn(); fixture.componentInstance.approved.subscribe(spy); approve().click(); expect(spy).not.toHaveBeenCalled(); }); it('enables after real pointer travel and then emits', () => { const spy = vi.fn(); fixture.componentInstance.approved.subscribe(spy); move(0, 0); move(20, 20); expect(approve().disabled).toBe(false); approve().click(); expect(spy).toHaveBeenCalledTimes(1); }); it('arms on Tab but not on a repeating key', () => { press({ key: 'Tab', repeat: true }); expect(approve().disabled).toBe(true); press({ key: 'Tab' }); expect(approve().disabled).toBe(false); }); it('disarms when the window loses focus', () => { moveSource:https://dev.to/codewithrajat/doubleclick-jacking-why-your-clickjacking-headers-never-see-it-coming-1hghRelated posts
Google Search Engine does not index Blogger/Blogspot blog due to?m=1 – solution
Building a Federated Copilot: Part 2, Real PingFederate Token Exchange
Your PDF never leaves your browser: how I built 16 PDF tools with no uploads
Docker said healthy. Our Laravel deployment still failed readiness.
SSA Maximum Taxable Earnings 2027: New $192,300 Social Security Wage Limit
