
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.
Table of Contents
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:
| Channel | Transform |
|---|---|
sim Telegram | JPEG quality 89, no resize |
sim WhatsApp | resize long edge → 1600, JPEG quality 72 |
sim WeChat | resize short edge → 1080, JPEG quality 80 |
sim Instagram | resize long edge → 1080, JPEG quality 80 |
sim WeChat → WhatsApp | both, in sequence |
sim Telegramsim WhatsAppsim WeChatsim Instagramsim WeChat → WhatsAppPlus 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:
| Channel | Target | Source was | Resized? |
|---|---|---|---|
sim WhatsApp | long edge ≤ 1600 | 1600×1063 | no — already within it |
sim WeChat | short edge ≤ 1080 | short edge 1063 | no — already within it |
sim WeChat→WhatsApp | both | — | no |
sim Instagram | long edge ≤ 1080 | 1600 | yes → 1080×718 |
resize->1080 only | long edge ≤ 1080 | 1600 | yes → 1080×718 |
sim WhatsAppsim WeChatsim WeChat→WhatsAppsim Instagramresize->1080 onlySo 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 class | Example | Transform-domain + RS |
|---|---|---|
| Recompression, no geometry | sim WhatsApp, sim WeChat, sim Telegram | solved — 10/10 |
| Anything that rescales | sim Instagram, bare resize | 0/10 |
sim WhatsApp, sim WeChat, sim Telegramsim Instagram, bare resizeExperiment 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!
This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.
Have questions? Reach out through:
Want to see your comment featured? Mention us on X or tag us on Reddit.