← Blog
§ Watermarking · 2026-05-24 · 14 min read

A robust invisible watermark in 500 lines of browser JavaScript.

We needed an invisible per-recipient watermark for the Snitch Tracker — give different copies of the same screenshot to different people, identify the leaker when one shows up somewhere it shouldn't. The hard part isn't embedding the bits. The hard part is recovering them after the file has been through Instagram's recompression pipeline, a screen- shot of a screenshot, a crop, a save-as-JPEG. This is the story of what works in browser-side JavaScript, what we got wrong, and the empirical numbers that say so.

The constraints

Four hard constraints shaped the design before we wrote a line of code.

  1. Image bytes never leave the browser. Our entire privacy story rests on this. So no server-side image processing, no GPU-accelerated ML model running on someone else's hardware.
  2. No native deps, no WASM. Pure JavaScript shipped with the static site. We can't ask people to install a Python tool to verify.
  3. Survive the dominant platform pipeline. Specifically: re-encode at JPEG q70-85 with chroma subsampling, resize to 1080-1600px longest edge. That's Instagram, WhatsApp, Twitter, Telegram, Signal (default image mode) — all of them, basically the same shape of attack.
  4. ≤100 variants per receipt, single-leaker threat model. Not adversarial collusion. We're looking for the friend who screenshot-and-sent your meme, not a state actor with copies of the algorithm.

What didn't work

Before the DWT layer, the Snitch Tracker had two cheaper signals: LSB stego on the blue channel and paired-block DC modulation on the green channel. Both have honest strengths and a hard failure mode.

LayerSurvivesDies on
LSB blue-channel stegoLossless re-encode (PNG, AirDrop, email)Any JPEG re-compression
Paired-block DC (green channel)JPEG ≥ q60 at native dimensionsAny resize

Resize is the killer. Every social platform resizes uploads by default, so “survives JPEG” without “survives resize” is effectively useless for leak detection through the channels people actually use.

Why we picked DWT+DCT+SVD

The state-of-the-art browser-feasible robust watermark is ShieldMnt's invisible-watermark Python library, specifically its dwtDctSvdmethod. The pipeline:

  1. Convert RGB → YCbCr. Embed only in the U (Cb) channel — luma changes are more visible to humans than chroma changes.
  2. 1-level Haar wavelet decomposition of U. The LL band is the low-frequency approximation that survives subsequent quantization.
  3. Tile LL into 4×4 blocks. For each block, compute the 2D DCT.
  4. Find the largest singular value of the DCT block via power iteration. Modify it to encode one watermark bit.
  5. Inverse DCT, inverse DWT, rebuild U, recombine with original Y and V, convert back to RGB.

The key insight: modifying the largest singular value is structurally robust because it carries most of the block's energy. JPEG quantization, noise, and mild filtering preserve it better than they preserve any individual DCT coefficient.

The port

The ShieldMnt library is Python with NumPy + PyWavelets + SciPy. None of those exist in the browser. We needed pure JavaScript implementations of:

  • RGB ↔ YCbCr (BT.601, full range) — ~20 lines
  • 1-level 2D Haar DWT, forward + inverse — ~60 lines
  • 4×4 2D orthonormal DCT, forward + inverse — ~40 lines
  • Power iteration for the largest singular value of a 4×4 matrix — ~30 lines
  • Per-block embed + extract via singular-value cell shift — ~50 lines
  • Glue: quadrant tiling, payload encoding (16-byte CRC32-framed), extraction search — ~250 lines

Total: about 500 lines of TypeScript, no dependencies, runs in any modern browser in under a second per quadrant.

Quadrant redundancy for crop resistance

A single-payload embedding dies on any crop because the block grid shifts. Fix: embed the full 128-bit payload into each of 4 image quadrants independently. At extract time, try each quadrant; the first one whose recovered bytes pass CRC32 wins. As long as one quadrant survives intact, the watermark recovers — gives us tolerance for crops up to ~50% from any edge.

The synthetic test that lied

With the algorithm in place we built a Node test rig: generate synthetic 512×512 images with realistic frequency content, embed a payload, apply “JPEG” (8×8 DCT block quantize with the standard luminance table), test recovery. Results:

identity (no attack):                100% recovered
JPEG q40-q90:                        100% recovered
resize 0.5x-2x:                      100% recovered
crop ≤50% any edge:                  100% recovered
JPEG q40 + crop 25%:                 100% recovered
JPEG q40 + resize 0.85x:             100% recovered

We shipped on those numbers. Vol3 of our external audit came back two weeks later: the algorithm fails on the real Instagram pipeline.

Specifically: a 1200×800 PNG, resized to 1080-long-edge, JPEG q85 → returned “NO WATERMARK RECOVERED.” The marketing copy that said “100% recovery in test suite for combined attacks” was demonstrably false on the first thing the auditor tried.

What the synthetic test missed

Two bugs, both ours, both about the gap between the synthetic rig and reality.

Bug 1: no chroma subsampling. Our fake JPEG ran per-channel quantization on R, G, B directly. Real JPEG converts RGB → YCbCr and subsamples Cb and Cr at 4:2:0 — quarter resolution. Our watermark lives in U (Cb). So real JPEG halves the spatial resolution of the channel the watermark sits in, then quantizes the remainder, then upsamples back. Our fake JPEG missed all of this.

Bug 2: blind canonical-size search. When the verifier doesn't know the original dimensions of the embedded image (the common case — the leaked file shows up via channels the verifier doesn't control), the extractor has to guess what size to resize the attacked image back to before extracting. Our first version tried 5 fixed long-side values: [1024, 768, 512, 1280, 1920]. The auditor's original was 1200 wide, which wasn't in the list, so the attacked 1080-wide file was never re-probed at 1200. The watermark grid stayed misaligned, recovery failed.

The fixes

We rebuilt the test rig around the real jpeg-js library so the YCbCr + 4:2:0 subsampling pipeline is actually exercised. We expanded the canonical-size search:

  • Hinted dimensions when available. Receipts now store original_w + original_h at seal time; the verifier resizes to exactly those when known.
  • Scale-factor multiples of the file's own dimensions. We try the leaked file's dimensions multiplied by [0.5, 0.667, 0.75, 0.85, 0.9, 1.111, 1.25, 1.333, 1.5, 1.6, 1.778, 2.0] — these are the ratios that show up between original and platform-resized sizes in real pipelines.
  • Much wider absolute canonical list. Long-side widths from 512 up to 3840 in steps that match real screenshot conventions.
  • CRC validator drives the search. Instead of picking the highest-confidence quadrant heuristically, we test each quadrant's recovered 16 bytes against the embedded CRC32. The first one that validates wins. This short-circuits the search and eliminates the false-positive failure mode where a confident-looking but garbage extraction beats a less-confident but correct one.

The numbers, attached to the rig that actually exercises real JPEG

Re-ran the gauntlet against scripts/test-robust-watermark-real.mjs which pipes every attack through real jpeg-js with full YCbCr + 4:2:0 conversion:

identity:                                        100% recovered
real JPEG q40-q90 at native dimensions:          100% recovered
resize 0.5x-2x then real JPEG q85:               100% recovered
1200×800 → 1080-long-edge + real JPEG q85
   (the auditor's exact gauntlet):              100% recovered
JPEG q40 + crop 25%:                             100% recovered
crop ≥60% (most of the image removed):             0% recovered
rotation more than a few degrees:                  0% recovered

The two failure cases are honest limitations of the v1 design: aggressive crop blows past the quadrant redundancy threshold, and we have no geometric synchronization template for rotation. Both are addressable in a v2 if there's pressure to ship it.

The honest threat model

This is a heuristic defense, not a cryptographic one. An attacker who reads this post can strip the watermark by:

  • Cropping more than 60% of the image area.
  • Rotating the image more than a few degrees and re-encoding.
  • Mounting an averaging attack with multiple watermarked variants of the same image (the Snitch Tracker mode is most exposed here).
  • Re-embedding their own watermark over ours with sufficient strength.

For the actual threat model — “identify the friend who forwarded my screenshot through Instagram” — this is fine. For state-actor adversaries it's not. We say so on the Snitch Tracker page.

Try it yourself

The live extraction rig is at /lab — drop a sealed image, run any of the simulated platform pipelines against it in the browser, watch the recovery numbers update live. The algorithm source is in receipts/marketing/lib/robustWatermark.ts and the test rig is in receipts/marketing/scripts/test-robust-watermark-real.mjs. The repo is open — if the numbers in the rig don't reproduce on your hardware, file an issue and we'll dig in.

Why this matters past Snitch Tracker

A robust browser-side watermark that survives the common social pipeline opens a few things:

  • Leak detection without paying SaaS prices ($EchoMark and similar charge per seat).
  • Per-recipient fingerprinting for confidential documents in newsrooms — the workflow covered in the 404 Media piece on Automattic done without trusting a third-party service.
  • Counter-evidence for AI-generated screenshots: if your sealed image carries an extractable watermark AND a SHA + perceptual hash receipt AND an external timestamp anchor, the bar to fake a derivative gets meaningfully higher.

Next post: the verdict ladder for /verify — why we AND-gate perceptual hash thresholds, why we reject low-entropy hashes, and what the auditor caught us doing wrong.

Drop a screenshot →
free · no signup · stays in your browser