Most developers know cryptography. You encrypt a message, and anyone who intercepts it sees gibberish. But gibberish itself is a signal: it tells an observer that something secret is going on.
Steganography takes the opposite approach. Instead of making a message unreadable, it makes the message invisible. The word comes from the Greek steganos ("covered") and graphein ("writing"). The goal is that an observer never suspects a hidden message exists at all.
In this article we'll look at how it works, build a working image steganography tool in Python, and discuss where it is (and isn't) useful.
Steganography vs. Cryptography
| Cryptography | Steganography | |
|---|---|---|
| Hides | The content of a message | The existence of a message |
| Visible to an observer? | Yes, as ciphertext | No, looks like a normal file |
| Failure mode | Message stays unreadable | Message is discovered |
They are not competitors. The strongest setups combine them: encrypt first, then hide the ciphertext. If the stego layer is ever detected, the attacker still faces encryption.
A Short History
Steganography is much older than computers. Herodotus describes messages tattooed on a shaved head and covered by regrown hair. Invisible inks made from lemon juice or milk were used for centuries. In WWII, spies used microdots, entire pages photographed down to the size of a period.
Digital steganography follows the same principle: find a place where a small change goes unnoticed.
The Core Idea: Redundancy
Digital media is full of redundancy. A photo contains far more detail than the human eye can perceive, and an audio file contains frequencies we can't hear. Steganography replaces some of this "unimportant" data with secret data.
The classic technique is LSB (Least Significant Bit) substitution.
Each pixel in an RGB image has three channels, each stored as a byte (0–255). Consider a red value of 200:
200 = 11001000 If we flip the last bit, we get 11001001, which is 201. Nobody can see the difference between red 200 and red 201. So every channel can carry 1 hidden bit, and every pixel carries 3 bits.
A 1920×1080 image has about 2 million pixels, which gives roughly 6.2 million bits, or about 777 KB of hidden capacity. That's plenty for text.
Building It in Python
Let's implement an LSB encoder and decoder with Pillow.
pip install pillow Step 1: Convert text to bits
We need to know where the message ends. A simple approach is to prefix the payload with its length (32 bits).
def to_bits(data: bytes) -> list[int]: length = len(data) header = [(length >> i) & 1 for i in range(31, -1, -1)] body = [(byte >> i) & 1 for byte in data for i in range(7, -1, -1)] return header + body def from_bits(bits: list[int]) -> bytes: length = 0 for bit in bits[:32]: length = (length << 1) | bit payload = bits[32:32 + length * 8] out = bytearray() for i in range(0, len(payload), 8): byte = 0 for bit in payload[i:i + 8]: byte = (byte << 1) | bit out.append(byte) return bytes(out) Step 2: Embed the bits
from PIL import Image def embed(cover_path: str, out_path: str, message: str) -> None: img = Image.open(cover_path).convert("RGB") pixels = list(img.getdata()) bits = to_bits(message.encode("utf-8")) capacity = len(pixels) * 3 if len(bits) > capacity: raise ValueError( f"Message too large: need {len(bits)} bits, have {capacity}" ) new_pixels = [] bit_index = 0 for r, g, b in pixels: channels = [r, g, b] for c in range(3): if bit_index < len(bits): channels[c] = (channels[c] & ~1) | bits[bit_index] bit_index += 1 new_pixels.append(tuple(channels)) stego = Image.new("RGB", img.size) stego.putdata(new_pixels) stego.save(out_path, format="PNG") # must be lossless! Note the output format: PNG. This is critical, and we'll come back to it below.
Step 3: Extract the bits
def extract(stego_path: str) -> str: img = Image.open(stego_path).convert("RGB") bits = [channel & 1 for pixel in img.getdata() for channel in pixel] return from_bits(bits).decode("utf-8") Try it
embed("cat.png", "cat_secret.png", "Meet me at the usual place at 9pm.") print(extract("cat_secret.png")) # Meet me at the usual place at 9pm. Open both images side by side. You won't see a difference, but the second one carries a message.
Why JPEG Breaks Everything
If you save the stego image as JPEG, your message will almost certainly be destroyed. JPEG is lossy: it re-quantizes pixel data, which scrambles the least significant bits. Likewise, many social networks and messengers recompress uploaded images, wiping out LSB payloads.
Rules of thumb:
- Use lossless formats (PNG, BMP, WAV, FLAC) for LSB techniques.
- For JPEG, use techniques that operate on DCT coefficients (tools like
steghideorF5do this). - Test that your payload survives the channel you plan to send it through.
Improving the Basic Approach
Our toy implementation has weaknesses. Here is how real tools improve on it:
- Encrypt the payload first. Use a vetted library (e.g. AES-GCM from
cryptography). Encrypted data looks like random bits, which also makes the embedded data statistically more uniform. - Scatter the bits. Instead of filling pixels sequentially, use a key-seeded PRNG to choose positions. This spreads changes across the image and makes the order of bits unknowable without the key.
- Add error correction. Reed–Solomon or similar codes help survive minor corruption.
- Match the cover's statistics. Naive LSB writing changes the histogram in detectable ways. Adaptive methods (like HUGO, WOW, S-UNIWARD) embed preferentially in noisy, textured regions where changes are harder to spot.
Beyond Images
LSB in images is just the most visible example. Steganography works anywhere there's redundancy:
- Audio: hide bits in the least significant bits of samples, or in phase and echo patterns.
- Text: zero-width Unicode characters (U+200B, U+200C, U+200D) can encode bits inside ordinary-looking text. Whitespace patterns work too.
- Network protocols: unused header fields, packet timing, or padding can form covert channels.
- File systems and formats: slack space, metadata fields, or appended data after a file's logical end.
Here's a tiny zero-width text example:
ZERO, ONE = "\u200b", "\u200c" def hide(cover: str, secret: str) -> str: bits = "".join(f"{b:08b}" for b in secret.encode()) hidden = "".join(ONE if bit == "1" else ZERO for bit in bits) return cover[:1] + hidden + cover[1:] def reveal(text: str) -> str: bits = "".join("1" if ch == ONE else "0" for ch in text if ch in (ZERO, ONE)) data = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8)) return data.decode() msg = hide("Hello, world!", "hi") print(msg) # looks like: Hello, world! print(reveal(msg)) # hi The output looks identical to the cover text when printed, but is longer than it appears.
Steganalysis: The Other Side
Where there's hiding, there's detection. Steganalysis is the study of finding hidden data. Common approaches include:
- Statistical tests such as chi-square analysis on pixel value pairs, which reveal the telltale evenness LSB embedding creates.
- RS analysis and sample pair analysis, which estimate the embedding rate.
- Machine learning: modern detectors use CNNs trained on thousands of clean and stego images.
The lesson: steganography is an arms race, and a naive scheme should never be trusted against a motivated adversary.
Legitimate Uses
Steganography has a reputation problem because it appears in malware discussions, but its legitimate uses are broad:
- Digital watermarking: embedding ownership or licensing info in media, often designed to survive edits.
- Leak tracing: invisibly tagging documents per recipient so a leak can be traced.
- Censorship resistance: communicating where encrypted traffic itself draws attention.
- Printer tracking dots, document fingerprinting and provenance metadata for authenticating media.
- Education and CTFs: stego challenges are a staple of capture-the-flag competitions and a great way to learn about file formats.
Practical Takeaways
- Steganography hides that a message exists; cryptography hides what it says. Use both.
- LSB substitution is simple and great for learning, but fragile and detectable.
- Always use lossless formats for spatial-domain techniques.
- Never rely on obscurity alone. Assume your method is public (Kerckhoffs's principle) and let the key carry the security.
- If you're building anything real, use established, audited tools rather than rolling your own.
Where to Go Next
- Experiment with
steghide,zsteg, andstegsolveon sample images. - Try a CTF with stego challenges (picoCTF is beginner-friendly).
- Extend the Python code above: add a password-seeded PRNG for bit placement and AES-GCM encryption of the payload.
Have you used steganography in a project, or hit a fun stego challenge in a CTF? Share it in the comments.


