DEV Community

Cover image for Steganography for Developers: Hiding Data in Plain Sight
GhostRunner
GhostRunner

Posted on AI-assisted

Steganography for Developers: Hiding Data in Plain Sight

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 steghide or F5 do 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:

  1. 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.
  2. 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.
  3. Add error correction. Reed–Solomon or similar codes help survive minor corruption.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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, and stegsolve on 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.

Top comments (0)