Dev.to · 25 min read

DoubleClick jacking: Why Your Clickjacking Headers Never See It Coming

DoubleClick jacking: Why Your Clickjacking Headers Never See It Coming

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: ` {{ appName() }} wants access to your account Scopes: {{ scopes().join(', ') }} Approve `, }) export class ConsentComponent { readonly appName = input.required(); readonly scopes = input([]); readonly approved = output(); } 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 { 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 | 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: ` {{ appName() }} wants access to your account Scopes: {{ scopes().join(', ') }} Approve @if (!armed()) { Move your mouse or press Tab to enable this button. } `, }) export class ConsentComponent { readonly appName = input.required(); readonly scopes = input([]); readonly approved = output(); 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; 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', () => { move(0, 0); move(30, 30); expect(approve().disabled).toBe(false); window.dispatchEvent(new Event('blur')); fixture.detectChanges(); expect(approve().disabled).toBe(true); }); }); src/app/gesture-gate.spec.ts import { createGestureGate } from './gesture-gate'; describe('createGestureGate on touch screens', () => { afterEach(() => vi.useRealTimers()); it('arms only after the delay that follows a focus event', () => { vi.useFakeTimers(); const states: boolean[] = []; const gate = createGestureGate((armed) => states.push(armed), { isCoarsePointer: () => true, touchDelayMs: 800, }); window.dispatchEvent(new Event('focus')); vi.advanceTimersByTime(799); expect(states).toEqual([]); vi.advanceTimersByTime(1); expect(states).toEqual([true]); gate.destroy(); }); }); Run them once without watch mode: ng test --no-watch Expected output (the CLI prints a check mark in front of each file, and a project made with ng new also lists its default app spec, which we replace near the end): src/app/consent.component.spec.ts (5 tests) src/app/gesture-gate.spec.ts (1 test) Test Files 2 passed (2) Tests 6 passed (6) Real-world version. Unit tests prove your logic, not the browser's event order. Add one manual check for every browser you support: park the cursor where the Approve button will appear, load the page with a keyboard shortcut, then click without moving the mouse. Nothing should happen. If some browser enables the button on its own after layout, you've learned something valuable before an attacker did. Under the hood. jsdom has no matchMedia, so the gate treats the test environment as a mouse-and-keyboard device, and the touch path is covered by injecting isCoarsePointer. Fake timers let the 800 ms delay run in zero real milliseconds, and the afterEach puts real timers back so the next test isn't affected. So far the fix lives inside an Angular app. What about the pages you don't own the code for? Can NGINX, Cloudflare or CloudFront deliver the gate for you? Quick version. They can't block the attack with headers, but they can deliver the script that does. For server-rendered or legacy pages, bundle the same gate as a tiny standalone script, mark the dangerous buttons with data-sensitive, and let the edge add one script tag. src/guard.ts import { createGestureGate } from './app/gesture-gate'; const targets = Array.from( document.querySelectorAll('[data-sensitive]'), ); const setDisabled = (disabled: boolean): void => { for (const el of targets) { el.disabled = disabled; } }; setDisabled(true); createGestureGate((armed) => setDisabled(!armed)); Bundle it into one file. A typical Angular project already has esbuild installed through its build tooling; if yours doesn't, install it first. npx esbuild src/guard.ts --bundle --minify --format=iife --outfile=public/dcj-guard.js Expected output, with timings that will differ on your machine: public/dcj-guard.js 1.0kb Done in 7ms Mark the buttons in the server-rendered page: consent.html Approve NGINX. Add these two locations inside the same server block as before. Neither one declares its own add_header, so the framing headers keep being inherited. /etc/nginx/conf.d/app.conf location = /static/dcj-guard.js { root /var/www/app; } location /oauth/authorize { proxy_pass http://127.0.0.1:4200; proxy_set_header Accept-Encoding ""; sub_filter '' ''; } Copy the bundle to /var/www/app/static/dcj-guard.js. Two details trip people up here. sub_filter needs the ngx_http_sub_module, which not every NGINX build includes. And the empty Accept-Encoding header makes the upstream send plain HTML, because NGINX can't rewrite a compressed body. Check that the tag arrives: curl -s https://app.example.com/oauth/authorize | grep -o '' Expected output: Cloudflare. A Worker can add the same tag with HTMLRewriter, only on the pages that need it. Types for the Workers runtime come from the @cloudflare/workers-types package. src/inject-guard.ts export default { async fetch(request: Request): Promise { const upstream = await fetch(request); const { pathname } = new URL(request.url); if (!pathname.startsWith('/oauth/authorize')) { return upstream; } return new HTMLRewriter() .on('head', { element(head) { head.append('', { html: true }); }, }) .transform(upstream); }, }; CloudFront. A response headers policy only changes headers, so it can't add a script tag. I'd put the tag in the page template or the build output at your origin and let CloudFront cache it. If you want to rewrite bodies at the edge, check the current CloudFront documentation for what it supports today. Real-world version. If you run a strict Content-Security-Policy, make sure script-src allows the guard script's origin. Also remember the script is deferred, so there's a short moment before it runs when the button is still live. If that bothers you, ship the button with the disabled attribute already in the markup and let the script enable it. The trade-off is that people with JavaScript turned off can never approve anything, which may be exactly what you want on a consent screen. Under the hood. Injecting at the edge keeps your app code untouched, but it ties your security to a string replace. The day someone removes the closing head tag from that template, the guard quietly disappears. A test that fetches the page and looks for the script tag is cheap insurance. Disabling a button handles the timing trick. But what if you want a second lock that doesn't depend on timing at all? What should the server ask for when a click can't be trusted? Quick version. Ask for something an attacker can't produce by hijacking a click. A click can be stolen. Typing into your own field can't. Here's a small component that makes the user type the app's name before the final button wakes up. src/app/typed-confirm.component.ts import { Component, input, output, signal } from '@angular/core'; @Component({ selector: 'app-typed-confirm', template: ` Type {{ expected() }} to continue Confirm `, }) export class TypedConfirmComponent { readonly expected = input.required(); readonly confirmed = output(); protected readonly typed = signal(''); } src/app/typed-confirm.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TypedConfirmComponent } from './typed-confirm.component'; describe('TypedConfirmComponent', () => { let fixture: ComponentFixture; const confirm = (): HTMLButtonElement => fixture.nativeElement.querySelector('[data-testid="confirm"]'); const type = (value: string): void => { const input: HTMLInputElement = fixture.nativeElement.querySelector('[data-testid="typed"]'); input.value = value; input.dispatchEvent(new Event('input')); fixture.detectChanges(); }; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [TypedConfirmComponent] }).compileComponents(); fixture = TestBed.createComponent(TypedConfirmComponent); fixture.componentRef.setInput('expected', 'Photo Printer'); fixture.detectChanges(); }); it('stays disabled until the exact name is typed', () => { expect(confirm().disabled).toBe(true); type('Photo'); expect(confirm().disabled).toBe(true); type('Photo Printer'); expect(confirm().disabled).toBe(false); }); it('emits once when confirmed', () => { const spy = vi.fn(); fixture.componentInstance.confirmed.subscribe(spy); type('Photo Printer'); confirm().click(); expect(spy).toHaveBeenCalledTimes(1); }); }); Real-world version. My rule: an app asking to read private data, or a change to a security setting, gets step-up verification. Make the user re-enter their password or a one-time code first. Email them when a new app is connected, and make revoking it one tap away. If a future variant slips past your gate, the attacker still can't type your user's password. Under the hood. The attacker controls the timing of clicks and which window receives them. They don't control keystrokes going into a field on your page. Typing the app's name has a side benefit, too: it forces a glance at the name, which a reflexive click never does. Use it on rare, high-impact steps only. If every screen asks for typing, people learn to type without reading. Time to see all of it running together. Does the whole thing work together? Here's the full running example. The root component walks the user through the gated consent screen, then the typed confirmation, then a final message. src/app/app.ts import { Component, signal } from '@angular/core'; import { ConsentComponent } from './consent.component'; import { TypedConfirmComponent } from './typed-confirm.component'; @Component({ selector: 'app-root', imports: [ConsentComponent, TypedConfirmComponent], template: ` @switch (step()) { @case ('consent') { } @case ('confirm') { } @case ('done') { Access granted to Photo Printer. } } `, }) export class App { protected readonly step = signal('consent'); } The CLI generates an app.html and app.css next to this file. Delete both, since the template now lives inline. Then replace the default app.spec.ts with a test that plays the whole flow, including a click that should do nothing. src/app/app.spec.ts import { TestBed } from '@angular/core/testing'; import { App } from './app'; describe('App', () => { it('walks consent, typed confirmation and done in order', async () => { await TestBed.configureTestingModule({ imports: [App] }).compileComponents(); const fixture = TestBed.createComponent(App); fixture.detectChanges(); const el: HTMLElement = fixture.nativeElement; const q = (id: string): T | null => el.querySelector(`[data-testid="${id}"]`); // a click without a gesture does nothing q('approve')!.click(); fixture.detectChanges(); expect(q('typed')).toBeNull(); // real pointer movement arms the gate document.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 0 })); document.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 40 })); fixture.detectChanges(); q('approve')!.click(); fixture.detectChanges(); expect(q('typed')).not.toBeNull(); const typed = q('typed')!; typed.value = 'Photo Printer'; typed.dispatchEvent(new Event('input')); fixture.detectChanges(); q('confirm')!.click(); fixture.detectChanges(); expect(q('done')?.textContent).toContain('Access granted'); }); }); Run everything: ng test --no-watch Expected output (again, the CLI puts a check mark before each file): src/app/app.spec.ts (1 test) src/app/consent.component.spec.ts (5 tests) src/app/typed-confirm.component.spec.ts (2 tests) src/app/gesture-gate.spec.ts (1 test) Test Files 4 passed (4) Tests 9 passed (9) If you'd rather watch it by hand, run ng serve and open the page. You'll see a disabled Approve button and the hint. Move the mouse and the button wakes up. Click it, type the app's name, press Confirm, and the final message appears. So what were those headers protecting all along? Remember the question I planted at the start? Here's the answer. X-Frame-Options and frame-ancestors protect your page from being framed by someone else's page, which is the classic clickjacking attack. They do that job well, and you should keep them. They're the right answer to a different question. What changed is that your defenses now come in layers, each answering a different question. The framing headers cover your page living inside someone else's frame. The gesture gate covers a click arriving that the user never aimed at your page. The typed confirmation covers whatever slips past both. You can paste all three from this article. It's Friday, 4:40 p.m. again, and the scanner is green again. This time you know what it can't see. You pull up the list of one-click screens you wrote down earlier, and the report line changes from "Clickjacking: protected" to something more honest. Then you close the laptop. If you know someone who ships consent screens or payment confirmations, send them this article. It might save them a very strange Monday. Bonus tips Beginner. Take the list of screens that act on a single click and add data-sensitive to every dangerous button. Even if you do nothing else this week, that list is worth more than another header. Intermediate. Record how long passes between the window gaining focus and the Approve click, and send it with the request. I'd use it as a risk signal, not as a lock, and alert on a burst of instant approvals. Advanced. Browsers change. Script the parked-cursor check from the testing section in an end-to-end run for each engine you support, so a browser update that changes the behavior fails a test instead of surprising you in production. Recap DoubleClickjacking swaps the page under a user's cursor between the two halves of a double-click, so the second click lands on your most sensitive button. Because your page is never framed, X-Frame-Options, frame-ancestors and SameSite cookies have nothing to catch, whether they're set in NGINX, Apache, Cloudflare or CloudFront. The fix that holds up is on the client: keep sensitive buttons disabled until you see real interaction, guard against held keys and focus changes, and handle touch with a delay. Back it with a typed confirmation or step-up verification for the actions that matter most, and keep your framing headers for the classic attack. What did you think? Did this approach match how you are solving it, or do you have a different take? Drop a comment. I genuinely read every single one. Found this helpful? If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference. Want more tips like this? I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one. Let us connect. Find me on LinkedIn or GitHub and let us keep the conversation going. Follow Me for More Angular & Frontend Goodness: I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides. 💼 LinkedIn — Let’s connect professionally 🎥 Threads — Short-form frontend insights 🐦 X (Twitter) — Developer banter + code snippets 👥 BlueSky — Stay up to date on frontend trends 🌟 GitHub Projects — Explore code in action 🌐 Website — Everything in one place 📚 Medium Blog — Long-form content and deep-dives 💬 Dev Blog — Free Long-form content and deep-dives ✉️ Substack — Weekly frontend stories & curated resources 🧩 Portfolio — Projects, talks, and recognitions ✍️ Hashnode — Developer blog posts & tech discussions ✍️ Reddit — Developer blog posts & tech discussions

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Cybersecurity News