DEV Community

Cover image for How I built a browser watermark library that survives DevTools
Mohammed Hassan
Mohammed Hassan

Posted on

How I built a browser watermark library that survives DevTools

Identifying the leaker after a screenshot escapes, when the obvious tricks no longer work.

The problem

Last year, I watched a customer of ours forward a screenshot of an internal dashboard to a vendor. The screenshot showed financial figures that should have stayed inside the company. Nobody could prove who took it.

If every pixel of that screen had carried that user's email or ID in faint diagonal text, we would have known the answer in five seconds.

That is the whole idea behind a watermark. It does not prevent leaks. It makes leaks attributable.

Why naive watermarks fail

The simplest version is a <div> you put on top of the page:

<div class="watermark">alice@acme.dev</div>
Enter fullscreen mode Exit fullscreen mode

The trouble: anyone who knows F12 opens DevTools and deletes the <div> in two clicks. Or overrides its CSS. Or screenshots the page before your watermark script runs.

The naive watermark catches honest users. It does nothing against the people who actually want to leak.

What I wanted

A library that:

  1. Renders the user's identifier (email, user id, whatever) tiled across the page.
  2. Re-creates itself if someone removes it from the DOM.
  3. Resists CSS overrides that try to hide it.
  4. Optionally redirects users who open DevTools to a "you do not have access to this" page.
  5. Logs every attempt to my server. Even if every browser-side defense gets bypassed, I want a record of who tried what, when.

That last one is the part most teams forget, and it is the most important.

What I built

watermark-shield is a small library on top of watermark-js-plus, which handles the actual canvas rendering very nicely. On top of it I added:

  • A self-healing overlay that comes back if removed.
  • A MutationObserver that catches CSS injection attempts on the watermark.
  • A DevTools-detection layer that fires a callback before it redirects.
  • An audit hook so the server can log attempts.

Framework-agnostic core in plain JavaScript, with bindings for Angular, React, and Vue 3.

The whole thing is roughly 6 KB gzipped, with another 6 KB lazy chunk if you turn on the DevTools guard.

Two-line install

npm install watermark-shield
Enter fullscreen mode Exit fullscreen mode
import { WatermarkShield } from 'watermark-shield';

new WatermarkShield({
  content: user.id,
  protect: { devtool: true },
}).create();
Enter fullscreen mode Exit fullscreen mode

You get a tiled watermark of the user's id, an active DevTools detector, and the always-on passive protections. Sensible defaults; nothing else to configure.

A realistic production setup

new WatermarkShield({
  content: user.id,
  fontSize: '1.5vw',                              // scales with screen
  fontColor: { light: '#000', dark: '#fff' },     // light/dark aware
  globalAlpha: 0.18,                              // survives JPEG compression
  protect: {
    devtool: true,
    devtoolUrl: 'https://yourapp.com/security/blocked',

    onDevtoolOpen: (detectorType) => {
      // The user is about to be redirected. Fire and forget.
      // No userId in the body. The server resolves the user from
      // the session cookie or JWT, never from what the client sends.
      navigator.sendBeacon('/api/audit/devtool', new Blob(
        [JSON.stringify({
          detectorType,
          url: location.href,
          ts: Date.now(),
        })],
        { type: 'application/json' },
      ));
    },
  },
}).create();
Enter fullscreen mode Exit fullscreen mode

Three things worth pointing out.

Identity belongs to the server. The audit body has no userId. The server resolves who the user is from the session cookie or the Authorization header. If you trust the client to tell you who is logged in, an attacker can forge the audit log too.

The audit log is the real backstop. A determined attacker can defeat any browser-side defense (custom Chromium build, an extension that strips watermarks, JS overrides). But the beacon already left their browser before they got that far. The server log is what gives you attribution that survives sophisticated bypasses.

Always devtool: false in development. Otherwise your engineers cannot debug their own app. Gate it with import.meta.env.PROD or environment.production.

Framework bindings

// Angular
import { WatermarkShieldService } from 'watermark-shield/angular';
this.shield.create({ content: user.id, protect: { devtool: true } });

// React
import { useWatermarkShield } from 'watermark-shield/react';
useWatermarkShield({ content: user.id, protect: { devtool: true } });

// Vue 3
import { useWatermarkShield } from 'watermark-shield/vue';
useWatermarkShield({ content: user.value.id, protect: { devtool: true } });
Enter fullscreen mode Exit fullscreen mode

Same lifecycle in every binding: create, update, destroy.

The goal is not to prevent capture. It is to make every captured image carry the leaker's name. That changes the calculation from "no one will know" to "of course I will be caught."

Try it

npm install watermark-shield
Enter fullscreen mode Exit fullscreen mode

Source and docs: https://github.com/mhwazrah/watermark-shield

Top comments (0)