Why Hidden Data Dies in Chat Apps: LSB vs a Robust Watermark, Measured

Why Hidden Data Dies in Chat Apps: LSB vs a Robust Watermark, Measured

Every 'hide a message in a photo' tutorial uses LSB. Push it through a simulated WhatsApp send and the bit error rate is 49%, which is a coin flip: not degraded, gone. Four methods measured across twelve channels — why Reed-Solomon takes WhatsApp from 0/10 to 10/10, why resize defeats every classic method, and why the learned watermark that clears it is also the least visible.

September 23, 2026
Harrison Guo
13 min read
Security Lab

Every tutorial on hiding a message in a photo teaches the same method: least-significant-bit replacement. Take the payload bits, write them into the low bit of each colour byte, and the image looks identical. It is a genuinely elegant demonstration, and it is the right tool for a file that stays a file.

Then somebody sends the photo through WhatsApp, and it stops working.

This post measures exactly how badly, and what each step of fixing it costs. Four methods, twelve channels, 10 trials each — all of it reproducible with pip and one photograph.

What a chat app actually does to your photo

The “send photo” path on every major platform is a resize followed by a fresh JPEG encode. Neither step is negotiable from the sending side; it happens server-side, so it is a property of the platform, not of your phone.

So the harness approximates each one as a resize plus a JPEG re-encode:

ChannelTransform
sim TelegramJPEG quality 89, no resize
sim WhatsAppresize long edge → 1600, JPEG quality 72
sim WeChatresize short edge → 1080, JPEG quality 80
sim Instagramresize long edge → 1080, JPEG quality 80
sim WeChat → WhatsAppboth, in sequence
Channel sim Telegram
Transform JPEG quality 89, no resize
Channel sim WhatsApp
Transform resize long edge → 1600, JPEG quality 72
Channel sim WeChat
Transform resize short edge → 1080, JPEG quality 80
Channel sim Instagram
Transform resize long edge → 1080, JPEG quality 80
Channel sim WeChat → WhatsApp
Transform both, in sequence

Plus plain JPEG at quality 50–90 and a bare resize to 1080, to separate the two effects.

Read the sim prefix literally. No photograph in this post went through WhatsApp. These are parameter guesses at what those platforms do, and I have not verified one of them against a real send — so sim WhatsApp 10/10 means “10/10 through ≤1600 px at quality 72”, and nothing stronger. The prefix is carried in the channel labels themselves so that a table copied out of here cannot quietly become a platform claim.

That distinction is the point rather than a disclaimer on it. A simulator is the right instrument for isolating which transform breaks what, because you can hold everything else still. It is the wrong instrument for asserting that something survives a given app — that needs a real send to a real recipient device, which is a different exercise with a different failure surface.

The payload is 32 bytes. Each result below is 10 independent trials on the same 1600×1063 photograph, scored on exact recovery — the payload is a key reference, so “mostly right” is worth nothing.

Experiment 1: LSB, the control

method lsb   PSNR 94.4 dB
channel                           BER      ok
baseline                        0.00%  10/10
JPEG QF90                      51.29%   0/10
JPEG QF80                      51.41%   0/10
JPEG QF70                      51.41%   0/10
JPEG QF60                      50.78%   0/10
JPEG QF50                      50.74%   0/10
resize->1080 only              51.21%   0/10
sim Telegram (q89)             49.53%   0/10
sim WhatsApp (<=1600,q72)      49.38%   0/10
sim Instagram (<=1080,q80)     51.02%   0/10
sim WeChat (short<=1080,q80)   50.51%   0/10
sim WeChat->WhatsApp           49.22%   0/10

The interesting number is not that it failed. It is 49–51%.

A bit error rate near 50% is a coin flip on every bit. That is not a damaged payload, it is the absence of one — the decoder is reading thermal noise and reporting it with confidence. There is no error-correcting code that recovers from 50% BER, because there is nothing left to correct.

Note the gentlest channel: JPEG quality 90, no resize at all, 51.29%. Total loss. LSB does not degrade gracefully under lossy compression; it is deleted by the first re-encode.

The reason is structural. LSB stores the payload in precisely the part of the signal that lossy compression exists to throw away. JPEG converts the image to DCT coefficients, quantises them, and reconstructs — and reconstruction rewrites the low-order bits of nearly every pixel. The payload was written in the one place guaranteed to be overwritten.

The PSNR is worth noting too: 94.4 dB, effectively invisible. LSB is the most imperceptible method here and the least useful through a channel — which looks like the beginning of a clean trade-off between invisibility and robustness. Hold that thought; the last experiment does not support it.

Experiment 2: a transform-domain mark

dwtDctSvd puts the payload in the singular values of DCT blocks of a wavelet subband — structural properties of the image that survive requantisation, rather than the low bits that do not.

method dwt   PSNR 39.1 dB
channel                           BER      ok
baseline                        0.00%  10/10
JPEG QF90                       0.00%  10/10
JPEG QF80                       0.16%   7/10
JPEG QF70                       4.22%   0/10
JPEG QF60                       6.05%   0/10
JPEG QF50                       8.67%   0/10
resize->1080 only              51.68%   0/10
sim Telegram (q89)              0.04%   9/10
sim WhatsApp (<=1600,q72)       3.20%   0/10
sim Instagram (<=1080,q80)     50.12%   0/10
sim WeChat (short<=1080,q80)    0.31%   3/10
sim WeChat->WhatsApp            1.84%   0/10

This is a different kind of failure, and a much more promising one. sim WhatsApp: 3.20% BER, 0/10 exact. The mark is almost entirely intact — around 97% of bits correct — and it still scores zero, because exact recovery is the bar.

That is the signature of a problem error correction is built for. Compare it to LSB’s 49%: one is a channel that damages a codeword, the other is a channel that erases it.

Resize, however, behaves exactly like LSB did: 51.68% on a bare resize, 50.12% on Instagram. Note what that means — the mark survives quality-50 JPEG at 8.67% BER, but a lossless geometric rescale destroys it completely.

Experiment 3: adding Reed-Solomon

Same watermark, same channels. The 32-byte payload now carries an RS(32,8) codeword — 8 message bytes, 24 parity — so the decoder can repair up to 12 corrupted bytes.

method dwt-rs RS(32,8)   PSNR 39.1 dB
channel                           ok
baseline                      10/10
JPEG QF90                     10/10
JPEG QF80                     10/10
JPEG QF70                      9/10
JPEG QF60                      5/10
JPEG QF50                      0/10
resize->1080 only              0/10
sim Telegram (q89)            10/10
sim WhatsApp (<=1600,q72)     10/10
sim Instagram (<=1080,q80)     0/10
sim WeChat (short<=1080,q80)  10/10
sim WeChat->WhatsApp          10/10

sim WhatsApp: 0/10 → 10/10. sim WeChat: 3/10 → 10/10. WeChat followed by WhatsApp — two re-encodes in sequence — also 10/10. Recompression is solved, at the cost of shrinking the payload from 32 bytes to 8.

Check what the channel actually did

That paragraph is easy to over-read, so here is the part that keeps it honest. Print the received dimensions and four of the twelve channels turn out not to have resized anything at all:

ChannelTargetSource wasResized?
sim WhatsApplong edge ≤ 16001600×1063no — already within it
sim WeChatshort edge ≤ 1080short edge 1063no — already within it
sim WeChat→WhatsAppbothno
sim Instagramlong edge ≤ 10801600yes → 1080×718
resize->1080 onlylong edge ≤ 10801600yes → 1080×718
Channel sim WhatsApp
Target long edge ≤ 1600
Source was 1600×1063
Resized? no — already within it
Channel sim WeChat
Target short edge ≤ 1080
Source was short edge 1063
Resized? no — already within it
Channel sim WeChat→WhatsApp
Target both
Source was
Resized? no
Channel sim Instagram
Target long edge ≤ 1080
Source was 1600
Resized? yes → 1080×718
Channel resize->1080 only
Target long edge ≤ 1080
Source was 1600
Resized? yes → 1080×718

So on this source the WhatsApp and WeChat channels collapse into pure JPEG re-encodes, and “RS fixes WhatsApp” means “RS fixes repeated JPEG recompression”. Apart from the two channels where it fails outright, this experiment never put a rescale in front of Reed-Solomon. Claiming it beat a resize would be reading a result that is not there.

That is less of a coincidence than it looks. The harness hands the marked image back at 1600 px on the long edge, which sits at or under what the chat-style channels ask for — so those paths have nothing left to rescale. Feed-style platforms cap at 1080, below that line, and rescale anyway. Emitting at the larger cap is the difference between a channel that only recompresses and one that also resamples, and it is worth choosing deliberately rather than inheriting.

One implementation detail is worth stating because the obvious choice is wrong. The natural instinct with an ECC is to interleave the bits, spreading each codeword symbol across the image so that a localised failure does not destroy consecutive symbols. Here you should not. This channel’s errors arrive in bursts that fall inside a byte, and Reed-Solomon is a symbol code: a byte with eight bad bits costs exactly the same as a byte with one. Interleaving would take those cheap concentrated failures and smear them across many symbols, turning one dead symbol into twelve damaged ones. Leaving the bytes contiguous lets the burst structure work in your favour.

The wall

All three methods so far score 0/10 on any channel that rescales the image.

This is not a stronger version of the compression problem, it is a different one. A transform-domain watermark is read from coefficients at known positions. Rescale the image and every position moves; the decoder reads the wrong coefficients and returns noise. The bit error rate going back to ~50% is the tell — the codeword is not damaged, it is not being read at all. Reed-Solomon has nothing to work with, because the failure is synchronisation, not corruption.

The sharpest way to see it: dwt survives lossy JPEG quality 50 at 8.67% BER, and is destroyed by a lossless rescale. Information-theoretically the rescale threw away less; it just moved everything.

So the classic recipe cleanly splits the channel space:

Channel classExampleTransform-domain + RS
Recompression, no geometrysim WhatsApp, sim WeChat, sim Telegramsolved — 10/10
Anything that rescalessim Instagram, bare resize0/10
Channel class Recompression, no geometry
Example sim WhatsApp, sim WeChat, sim Telegram
Transform-domain + RS solved — 10/10
Channel class Anything that rescales
Example sim Instagram, bare resize
Transform-domain + RS 0/10

Experiment 4: a learned watermark

Getting past that wall needs geometric robustness by construction. TrustMark is a learned encoder–decoder — a small network trained with resizing, cropping and recompression in its augmentation set, so invariance is learned rather than hand-derived — carrying ~100 bits with its own BCH error correction.

method trustmark   PSNR 40.7 dB
channel                           ok
baseline                      10/10
JPEG QF90                     10/10
JPEG QF80                     10/10
JPEG QF70                     10/10
JPEG QF60                     10/10
JPEG QF50                     10/10
resize->1080 only             10/10
sim Telegram (q89)            10/10
sim WhatsApp (<=1600,q72)     10/10
sim Instagram (<=1080,q80)    10/10
sim WeChat (short<=1080,q80)  10/10
sim WeChat->WhatsApp          10/10

120 trials, 120 recoveries. Every channel, including the two that defeat everything else.

And it is the least visible of the robust methods. 40.7 dB against the hand-designed mark’s 39.1 dB. I expected the opposite — that buying resize invariance would cost perceptible energy — and the measurement says no. The clean trade-off the LSB result seemed to promise does not exist among methods that actually survive a channel: here the most robust method is also the least visible one.

The cost is real, it is just somewhere else. The classic recipe is pip install invisible-watermark reedsolo and runs on anything. The learned one drags in torch, about 880 MB of dependencies, and model weights that must ship with the product. On a phone that is a binary-size and battery question, not a signal-processing one — and it is the actual reason to keep the classic path as a fallback tier, rather than any argument about image quality.

Which is the design I use in WaxSeal: the learned mark as primary, dwtDctSvd + Reed-Solomon as the fallback. Before this run that was a reasonable-sounding architecture; the four tables are the part that makes it a justified one.

Reproduce it

Here is the whole thing, standing alone — the LSB control and the dwtDctSvd + Reed-Solomon tier, through a cut-down set of the same channels. Drop a photo.jpg next to it and run it.

import io, numpy as np, cv2
from PIL import Image
from imwatermark import WatermarkEncoder, WatermarkDecoder
from reedsolo import RSCodec, ReedSolomonError

def jpeg(im, q):
    b = io.BytesIO(); im.convert("RGB").save(b, "JPEG", quality=q, subsampling=2)
    return Image.open(io.BytesIO(b.getvalue()))

def rmax(im, m):                       # cap the long edge, as an upload pipeline does
    w, h = im.size; s = m / max(w, h)
    return im if s >= 1 else im.resize((round(w*s), round(h*s)), Image.LANCZOS)

CHANNELS = {
    "baseline":                   lambda im: im,
    "JPEG QF90":                  lambda im: jpeg(im, 90),
    "JPEG QF50":                  lambda im: jpeg(im, 50),
    "resize->1080 only":          lambda im: rmax(im, 1080),
    "sim WhatsApp (<=1600,q72)":  lambda im: jpeg(rmax(im, 1600), 72),
    "sim Instagram (<=1080,q80)": lambda im: jpeg(rmax(im, 1080), 80),
}

bits  = lambda b: np.unpackbits(np.frombuffer(b, np.uint8))
bgr   = lambda im: cv2.cvtColor(np.array(im.convert("RGB")), cv2.COLOR_RGB2BGR)
topil = lambda a: Image.fromarray(cv2.cvtColor(a, cv2.COLOR_BGR2RGB))

def lsb_embed(cover, p):               # sequential LSB: the textbook method
    arr = np.array(cover.convert("RGB")); flat = arr.reshape(-1).copy()
    b = bits(p); flat[:b.size] = (flat[:b.size] & 0xFE) | b
    return Image.fromarray(flat.reshape(arr.shape))

def lsb_ber(recv, p):
    flat = np.array(recv.convert("RGB")).reshape(-1); n = len(p)*8
    return 1.0 if flat.size < n else float(np.mean((flat[:n] & 1) != bits(p)))

rsc = RSCodec(24)                      # RS(32,8): 8 message bytes, 24 parity

def rs_embed(cover, msg):
    e = WatermarkEncoder(); e.set_watermark('bytes', bytes(rsc.encode(bytearray(msg))))
    return topil(e.encode(bgr(cover), 'dwtDctSvd'))

def rs_ok(recv, msg):
    raw = WatermarkDecoder('bytes', 256).decode(bgr(recv), 'dwtDctSvd')
    try: return bytes(rsc.decode(bytearray(np.packbits(bits(raw)).tobytes()))[0]) == msg
    except ReedSolomonError: return False

rng = np.random.default_rng(101)
cover = rmax(Image.open("photo.jpg").convert("RGB"), 1600)
print(f"cover {cover.size}\n{'channel':28} {'LSB BER':>9} {'dwt+RS':>8}")
for name, ch in CHANNELS.items():
    p, msg = rng.bytes(32), rng.bytes(8)
    ber = lsb_ber(ch(lsb_embed(cover, p)), p)
    ok  = sum(rs_ok(ch(rs_embed(cover, msg)), msg) for _ in range(3))
    print(f"{name:28} {ber*100:8.2f}% {ok:>5}/3")
pip install invisible-watermark==0.2.0 reedsolo==1.7.0 opencv-python "numpy<2"

which prints, on my photograph:

cover (1600, 1063)
channel                        LSB BER   dwt+RS
baseline                         0.00%     3/3
JPEG QF90                       50.00%     3/3
JPEG QF50                       51.56%     0/3
resize->1080 only               51.56%     0/3
sim WhatsApp (<=1600,q72)       51.95%     3/3
sim Instagram (<=1080,q80)      52.34%     0/3

Same shape at three trials as at ten: LSB dies everywhere, the RS tier holds through recompression and falls over at 1080. For the learned tier add pip install torch torchvision trustmark (~880 MB, weights fetched on first run) and swap in TrustMark(...).encode/.decode.

Swap in your own image. The absolute numbers move with image content — a photograph with large flat regions gives the transform-domain mark less to hide in — but the shape of the result does not.

What I would not conclude from this

  • One image, ten trials. Enough to separate 0/10 from 10/10 and 50% BER from 3%; not enough to quote a survival probability to two decimal places.
  • A perfect score means the test was not hard enough. TrustMark going 120/120 tells you it clears this channel set; it tells you nothing about where it breaks. Cropping, rotation, a screenshot rather than a saved file, or downscaling well below 1080 are all outside what was measured here, and a watermark that survives recompression has no obligation to survive a crop. Finding its failure boundary needs a different harness than the one that found everything else’s.
  • Simulated channels, with unverified parameters. I did not measure what WhatsApp or WeChat actually do; the resize-and-quality figures are stand-ins I inherited and never checked against a real send. Real pipelines also add chroma subsampling choices, progressive encoding and occasional format switches. Everything here is a statement about the transforms named in the table, not about the companies named in the table.
  • Nothing here is a claim about detectability. Every method above is trivially detectable by an analyst who is looking. Robustness and undetectability are different properties, frequently confused, and a mark that survives recompression is more statistically conspicuous, not less. If your threat model requires that nobody can tell the image carries something, none of this addresses it.

🎧 More Ways to Consume This Content

I occasionally advise small teams on backend reliability, Go performance, and production AI systems. Learn more: /services

Comments

This space is waiting for your voice.

Comments will be supported shortly. Stay connected for updates!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.