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.1in 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:
- DOM is deletable — Select the node in DevTools and press Delete, it's gone.
- DOM is hideable —
display: none,visibility: hiddenhides it with one click. - CSS is overridable — Modifying
z-indexoropacitydisables 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?
- Cannot be directly 'selected and deleted' in DevTools.
- Even if the
<style>tag is deleted, polling can restore it. !importantoverrides most user-injected styles.
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:
- Standard backend management systems: Solution 2 (Canvas + Observer) is sufficient, covering 95% of users.
- High-security scenarios: Combine Solution 3 + Solution 4.
- Never rely solely on frontend watermarks: Frontend watermarks are a deterrent that 'guards against the honest but not the malicious.' True confidentiality relies on backend watermarks (API injection + image server stamping).
- Ultimate defense against screenshots: Frontend watermark +
Document.exitFullscreen()listener + page blur processing on focus loss + disable right-click/print. - Performance note: Do not call
toDataURLevery time a Canvas watermark is generated; cache the result once. Keep Observer callbacks lightweight to avoid high-frequency triggers.
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.