跪拜 Guibai
← Back to the summary

Frontend Watermarks That Survive DevTools: From Canvas to Anti-Debugging

Niche knowledge | Security protection | The boss asks you to add a watermark to protect sensitive company information. You finish writing position: fixed + opacity: 0.1 in two minutes, only for an operations colleague to open DevTools and delete it with one click… Today, we make the watermark 'undeletable'.


Problem Scenario

The operations team is using an internal operations backend that displays tens of millions in GMV data. The security department requires all data pages to carry an employee ID watermark.

Day 1: PM: "Add a watermark." You: Cover the full screen with a position: fixed div, add pointer-events: none, done.

Day 2: Operations feedback: "I used F12 to delete the watermark div, took a screenshot, and sent it to the boss… no problem, right?" You: "…"

At its core, a CSS-only watermark is paper-thin against DevTools. It appears to 'exist' but is effectively absent; a user can break it by casually deleting a DOM node.


Root Cause Analysis

A common 'armchair' implementation of a frontend watermark:

<!-- ❌ The most fragile watermark -->
<div id="watermark" style="position:fixed;inset:0;pointer-events:none;opacity:0.08;z-index:99999;">
  <span>Confidential-ZhangSan-2026-07-23</span>
</div>

Three fatal flaws:

  1. DOM is deletable — Select the node in DevTools and press Delete, it's gone.
  2. DOM is hideabledisplay: none, visibility: hidden hides it with one click.
  3. CSS is overridable — Modifying z-index or opacity disables it directly.

Even a more 'advanced' multi-layer watermark with a scripted timed redraw can only defend against ordinary users. Anyone with a little JS debugging knowledge (breakpoints to kill timers, delete DOM) can break it just as easily.


Solutions

Solution 1: Canvas Full-Screen Overlay (Basic Reinforcement)

Draw the watermark on a Canvas, render it as a background image, and tile it using background-repeat. There is no DOM path to delete.

<!DOCTYPE html>
<html>
<head>
  <style>
    .watermarked {
      /* background image will be set in JS */
      background-repeat: repeat;
      background-size: 200px 150px;
    }
  </style>
</head>
<body id="app">
  <div>Sensitive Data Area</div>
  <script>
    function createWatermark(text = 'Confidential-ZhangSan-2026-07-23') {
      const canvas = document.createElement('canvas');
      canvas.width = 200;
      canvas.height = 150;
      const ctx = canvas.getContext('2d');
      
      ctx.rotate(-20 * Math.PI / 180);  // Rotate -20 degrees
      ctx.font = '14px Microsoft YaHei';
      ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
      ctx.fillText(text, 20, 80);
      
      document.getElementById('app').style.backgroundImage =
        `url(${canvas.toDataURL('image/png')})`;
    }
    createWatermark();
  </script>
</body>
</html>

Pros: No watermark element exists in the DOM tree; cannot be deleted via 'Delete'. Cons: Modifying the background-image CSS property overrides it, or a simple PS erasure after a screenshot removes it.


Solution 2: Canvas + MutationObserver Dual Monitoring (Intermediate Protection)

Builds on Solution 1 by adding a MutationObserver to monitor DOM/style tampering. Once watermark tampering is detected, it rebuilds immediately.

function setupWatermark(text) {
  const target = document.getElementById('app');
  let watermarkStyleId = 'wm-style';

  function applyWatermark() {
    const canvas = document.createElement('canvas');
    canvas.width = 200;
    canvas.height = 150;
    const ctx = canvas.getContext('2d');
    ctx.rotate(-20 * Math.PI / 180);
    ctx.font = '14px Microsoft YaHei';
    ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
    ctx.fillText(text, 20, 80);
    
    target.style.backgroundImage = `url(${canvas.toDataURL()})`;
  }

  // Apply for the first time
  applyWatermark();

  // MutationObserver monitors whether background-image is modified
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      if (mutation.type === 'attributes' &&
          mutation.attributeName === 'style') {
        const bg = target.style.backgroundImage;
        if (!bg || bg === 'none') {
          applyWatermark(); // Deleted? Restore immediately!
        }
      }
    }
  });

  observer.observe(target, {
    attributes: true,
    attributeFilter: ['style'],
  });

  // Extra defense: periodic check (prevents MutationObserver from being disconnected)
  const interval = setInterval(() => {
    const bg = target.style.backgroundImage;
    if (!bg || bg === 'none') {
      applyWatermark();
    }
  }, 1000);

  // Expose a cleanup function
  return () => {
    observer.disconnect();
    clearInterval(interval);
  };
}

const cleanup = setupWatermark('Confidential-ZhangSan-2026-07-23');

Adds three layers of defense: 1️⃣ MutationObserver monitors style attribute changes → restores instantly after tampering. 2️⃣ Periodic polling as a fallback → prevents Observer from being disconnected. 3️⃣ Watermark restoration happens within the same microtask/macrotask → no time to even take a screenshot.


Solution 3: CSS ::before/::after + Canvas Blob Deep Defense (Advanced Reinforcement)

Uses pseudo-element content: url(...) to embed the watermark image, combined with !important and pointer-events: none.

function createDeepWatermark(text) {
  const canvas = document.createElement('canvas');
  canvas.width = 400;
  canvas.height = 300;
  const ctx = canvas.getContext('2d');
  ctx.rotate(-20 * Math.PI / 180);
  ctx.font = '16px Microsoft YaHei';
  ctx.fillStyle = 'rgba(0, 0, 0, 0.06)';
  ctx.fillText(text, 40, 160);
  
  // Convert to Blob URL
  const dataUrl = canvas.toDataURL();
  
  // Inject a style with !important, rendering via pseudo-element
  const style = document.createElement('style');
  style.id = 'wm-core-style';
  style.textContent = `
    #app::before {
      content: '' !important;
      position: fixed !important;
      inset: 0 !important;
      background-image: url("${dataUrl}") !important;
      background-repeat: repeat !important;
      pointer-events: none !important;
      z-index: 2147483647 !important;
      opacity: 1 !important;
      display: block !important;
      visibility: visible !important;
    }
  `;
  document.head.appendChild(style);
  
  // Also add MutationObserver and timer
  // ... (reuse logic from Solution 2)
}

Why use ::before pseudo-element?


Solution 4: Defending Against Determined Users (Advanced Anti-Debugging)

Targets advanced users who can set breakpoints and manually delete Observers:

// Detect if DevTools is open (simple version)
const devtools = /./;
devtools.toString = function() {
  applyWatermark(); // Rebuild watermark every time Console evaluates
  return '';
};

// Override MutationObserver's disconnect method
const originalDisconnect = MutationObserver.prototype.disconnect;
MutationObserver.prototype.disconnect = function() {
  // Check if our observer is being disconnected
  if (this._isWatermarkObserver) {
    console.warn('⚠️ Watermark Protection: Disconnecting Observer is forbidden');
    return;
  }
  return originalDisconnect.call(this);
};

⚠️ Note: This kind of 'anti-anti-debugging' technique needs to be used in moderation. Excessive anti-debugging affects the normal development experience and is recommended only for internal sensitive systems.


Key Takeaways

Solution Protection Level Implementation Cost Can User Crack It?
CSS fixed overlay 5 min 1 second
Canvas background image ⭐⭐ 15 min Needs to modify CSS
Canvas + Observer ⭐⭐⭐⭐ 30 min Needs breakpoint debugging
Full-stack deep defense ⭐⭐⭐⭐⭐ 1 hour Requires very high technical skill

Practical recommendations:

The final honest truth: If a user really wants to steal your data, they can screenshot it, crop out the watermark, and send it to an AI for removal. The core value of a frontend watermark is 'post-incident traceability' — every leaked screenshot carries an employee ID watermark, allowing the security department to find the 'mole.' Deleting the watermark at the DOM level is just 'burying one's head in the sand.' The server side can precisely pinpoint the person through HTTP logs + request fingerprinting.