Why hiding data in a JPEG is harder than you think
“It’s just a compressed image file format”. Or is it?
Why hiding data in a JPEG is harder than you think

Steganography | Credit: Author
To Non-members of Medium, use this link.
Most people have a mental model of a JPEG that goes something like:
“It’s a compressed image file format.”
That’s not wrong, but it skips the interesting part.
When you save a photo as a JPEG, your computer doesn’t just store the colour of every single pixel. That would take ages and use a massive amount of space. Instead, it does something much cleverer. It looks at small 8×8 squares of your image and asks a question:
“What patterns of light and dark make up this square?”

Here’s an analogy. Imagine you’re describing a photo of a sunset over text message to a friend who can’t see it. You wouldn’t say:
“Pixel 1: orange. Pixel 2: slightly more orange. Pixel 3: also orange. Pixel 4: a touch darker orange…”
That’s painful, inefficient, and would probably earn you mean clapback. You’d probably say something like:
“Mostly warm orange across the whole thing, darker towards the bottom, a few wispy cloud shapes in the middle.”
That second description carries almost the same information with a fraction of the words. JPEG does exactly this, but with math instead of words.
The math it uses is called the Discrete Cosine Transform, or DCT. It takes that 8×8 block of pixels and converts it into 64 numbers. Those 64 numbers don’t represent individual pixels anymore. They represent frequencies.

How much variation is in this block? How much contrast? How much texture? Each of those 64 numbers is a weight assigned to a different pattern of variation.
These 64 numbers are called DCT coefficients, and they’re the heart of everything that follows.
Here’s the bit that trips everyone up
Now the reason why JPEG is considered lossy, is because at some point, some information about the image goes missing. But what, where, and when? Let me break it down for you.
The DCT itself is completely reversible. You can take pixels, run them through the DCT to get coefficients, then run the coefficients through the inverse DCT and get your exact original pixels back. No quality loss. No information gone. It’s pure maths.
The lossy part happens in the next step, which is called Quantisation.
Quantisation works like this: each of those 64 coefficients gets divided by a number from a lookup table called the quantisation table, and the result gets rounded to the nearest integer. Small coefficients, the ones representing fine detail that your eyes don’t care much about anyway, get divided by large numbers and round down to zero.

That’s where the quality loss actually lives. Not in the DCT but in the rounding.
So the full pipeline for saving a JPEG looks like this:
[Pixels] ──▶ [DCT] ──▶ [Quantise] ──▶ [Huffman] ──▶ [File]
raw RGB freq integers compressed .jpg
^
(data loss here)
And opening a JPEG runs that backwards:
[File] ──▶ [Huffman decode] ──▶ [Dequantise] ──▶ [Inv. DCT] ──▶ [Pixels]
.jpg integers back ×table freq→space raw RGB
The coefficients (those 64 integers per block) after quantisation are what’s actually sitting in the JPEG file on your hard drive. Everything else is just encoding around them.
So why does this wreck basic steganography?
The classic way to hide data in an image is called the Least Significant Bit steganography. The idea is simple: every pixel colour is stored as a number, and you change the last digit of that number to encode your hidden message. Change red from 182 to 183. Nobody notices, but your data is in there.


This works brilliantly on PNG files. PNGs are lossless. The pixels you put in are the pixels you get back out, every time, guaranteed.
But do this on a JPEG and you’ve got a problem.
The moment that JPEG is re-saved, re-compressed, re-encoded, or even just opened and resaved in a slightly different quality setting, the quantisation step runs again. Your carefully placed pixel changes get divided, rounded, and in many cases completely wiped out. Your hidden data? Gone.
WhatsApp, Twitter, Instagram and many social media platforms do this. If you hid a message in the pixels and sent it to a friend, chances are slim that it survived the journey.
The smarter approach: Go where the data actually lives
Here’s what makes DCT-domain steganography work. Those quantised coefficients (the integers sitting in the JPEG file)? They are persistent data. They don’t change at all. That’s the secret sauce.
If you open the file, read those coefficients, change a few of them slightly, then write the file back, you’re not fighting the compression. You’re working inside it. The file is still a valid JPEG. The coefficients are still quantised integers. Nothing breaks.
And most crucially, the next time someone opens that file, the same coefficients come back out. Your message survives. And you haven’t touched pixels at all.
This is what tools like Jsteg and steghide do under the hood. They work in the coefficient domain, not the pixel domain. And it’s why steghide-embedded images are significantly harder to detect than images with pixel-level modifications. The changes exist in frequency space, which looks statistically much more normal to detection tools.
The pipeline for DCT-domain steganography looks like this:
[JPEG in] ──▶ [Decode] ──▶ [Coefficients] ──▶ [Modify] ──▶ [Re-encode] ──▶ [JPEG out]
photo file compressed frequency ±1 on a compress unchanged to
on disk integers numbers, few numbers back to JPEG the human eye
not pixels ↑
|
|
(your message lives here)
The image still looks identical to the human eye. The file is still a valid JPEG. Your data is tucked away inside the frequency description of those 8×8 blocks, practically invisible.

One thing worth knowing: not all coefficients are equal
Of those 64 coefficients per block, one of them has a special role.
The very first one, known as the DC coefficient, represents the average brightness of the entire 8×8 block. The other 63 are called AC coefficients and represent the finer variation within it.
Here’s the catch: DC coefficients aren’t stored as absolute values in the JPEG file. They’re stored as the difference from the previous block’s DC value. It’s a compression trick. The consecutive blocks tend to have similar average brightness, so storing the delta is more efficient.
The consequence of this for steganography is if you carelessly modify a DC coefficient without accounting for this chain of differences, you don’t just affect that one block. You set off a chain reaction that shifts the perceived brightness of every block that comes after it in the file. The result is a visible banding or brightness drift across the image. Of course, this is a dead giveaway that the image was modified in the first place.
AC coefficients don’t have this problem. Modify one and only that block changes. Which is exactly why every serious DCT steganography algorithm like F5, JSteg, and the approach in Stegcore works exclusively in the AC coefficients.

In Python, there’s a library called jpegio that does exactly this. It gives you access to the raw DCT coefficients, lets you modify them, writes the file back. In C, libjpeg exposes a coefficient API that tools like steghide wrap. In JavaScript, js-steg does it.
In Rust? Every single JPEG library decodes all the way to pixels and discards the coefficients entirely. If you wanted coefficient-level access in Rust, your only option was to call out to libjpeg through C FFI. This means dragging a C component into your project, dealing with unsafe blocks, and losing the guarantees that make Rust appealing in the first place.
That was a problem I thought was worth solving.
The Solution?
I built dct-io . It’s a pure Rust library that parses a JPEG file all the way down to the quantised DCT coefficients, exposes them as mutable integers, and writes a valid JPEG back when you're done. No C, FFI, or unsafe pixel wrangling. Just a clean Rust API sitting at the exact layer of the JPEG pipeline where the interesting work happens.
It’s a component I needed to build Stegcore properly. And as far as I can tell, it’s the first time this has existed in pure Rust.
(P.S. : Someone please prove me wrong before this article goes viral. If not, its going to backfire so hard 🤧).
Why does any of this matter?
If you’re building a steganography tool like me, the answer is obvious. You need coefficient access to do DCT-domain embedding. But the applications go further than that.
Digital watermarking (embedding invisible ownership information in an image) works on the same principle. Forensic analysis of JPEG files requires checking whether coefficients have been tampered with. Steganalysis tools that need to examine coefficient distributions to detect hidden data. All of these need access to the layer that, until now, Rust couldn’t really reach.
Beyond steganography, anyone building JPEG tooling in Rust for compression research, image forensics, and codec experimentation now has a way to work at the coefficient level without ever leaving the language.
Where to find it
The crate is here and Stegcore, the steganography toolkit it was built for, is (at the time of writing) still an upgrade in progress. But you can keep track of it below.
In the mean time, if you’re building something in Rust that needs JPEG coefficient access, or if you spot something that could be better, issues and pull requests are open.
And if you’ve ever wondered why hiding data in a JPEG is so much more involved than hiding it in a PNG, now you know.
메타데이터
- post_id
- 11a9ebedb69e
- slug
- why-hiding-data-in-a-jpeg-is-harder-than-you-think-daniel-iwugo-11a9ebedb69e
- url
- https://medium.com/@mercurysnotes/why-hiding-data-in-a-jpeg-is-harder-than-you-think-daniel-iwugo-11a9ebedb69e
- canonical_url
- https://medium.com/@mercurysnotes/why-hiding-data-in-a-jpeg-is-harder-than-you-think-daniel-iwugo-11a9ebedb69e
- author_url
- https://medium.com/@mercurysnotes
- status
- ok
- fetched_at
- 2026-06-14 13:58:26