Hiding Secrets in Plain Sight
4 min read
What if you could send a secret message inside a perfectly ordinary photo of
your cat? No encryption warnings, no suspicious-looking ciphertext — just a
JPEG that also happens to contain hidden data. That's steganography, and it's
what I've been building with
@pinta365/steganography and its
companion web app UnderByte.
What Is Steganography?
Cryptography hides the meaning of a message. Steganography hides the fact that a message exists at all. Instead of encrypting "meet me at noon" into an unreadable blob, you embed it invisibly into a cover medium — an image, a document, or even plain text — and the result looks completely normal to anyone who doesn't know to look.
The word comes from Greek: steganos (covered) + graphia (writing). The practice goes back centuries, from invisible ink to microdots in wartime espionage. Today it shows up in digital watermarking, copyright protection, and — for our purposes — hiding messages inside image files.
Hiding Data in Images: LSB Encoding
The most common technique for image steganography is Least Significant Bit (LSB) encoding. To understand why it works, consider how a PNG stores color.
Each pixel has red, green, blue, and alpha channels, each an 8-bit value from 0 to 255. The most significant bits carry most of the visual information. The least significant bit contributes almost nothing — flipping it changes a channel value by 1 out of 255, a difference no human eye can detect.
That means you can overwrite the LSB of each channel with your own data without visibly changing the image. A 1000×1000 PNG has 1,000,000 pixels × 4 channels = 4,000,000 bits available — enough to hide roughly 500 KB of data.
import { embedTextInImage, extractTextFromImage } from "@pinta365/steganography";
// Hide a message in a PNG
const encoded = await embedTextInImage(imageBytes, "Hello, world!");
// Retrieve it later
const message = await extractTextFromImage(encoded);
// → "Hello, world!"
The library also supports adjustable bit depth: instead of using just 1 bit per channel, you can use 2, 3, or 4. More bits means more capacity, but also more visible distortion — a trade-off you tune depending on how sensitive the image is and how much data you need to hide.
LSB works great for lossless formats: PNG, WebP, BMP, GIF. But send that image through JPEG compression and the hidden data is destroyed — because JPEG doesn't preserve exact pixel values.
The JPEG Problem: DCT Encoding
JPEG compression works by dividing an image into 8×8 pixel blocks, applying the Discrete Cosine Transform (DCT) to convert each block into frequency components, and then quantizing (rounding) those components to discard detail the eye won't miss. That rounding step is exactly what kills LSB-encoded data.
To survive JPEG compression, you need to embed data in the DCT coefficients themselves — before quantization is applied. The library does this by making small, deliberate modifications to the DCT coefficients in a way that survives a re-encode at the target quality level. The result is a JPEG that retains your hidden data even after it's been re-saved.
This is significantly more robust than pixel-domain embedding, which is why it's used in real-world watermarking systems.
Hiding Data in Text: Zero-Width Characters
Here's the one that genuinely surprises people: you can hide data in plain text, with no images involved.
Unicode includes a set of zero-width characters — code points that render as
nothing visible. Characters like U+200B (zero-width space), U+200C
(zero-width non-joiner), and U+200D (zero-width joiner) are completely
invisible in any rendered text. But they're still there in the raw string.
The library uses sequences of these characters to encode binary data and distributes them throughout normal text. Copy-paste the result anywhere text is accepted — a tweet, a comment, a document — and the hidden message travels with it, invisible to readers but extractable by anyone with the right tool.
import { encodeText, decodeText } from "@pinta365/steganography";
const cover = "Nothing to see here.";
const stego = await encodeText(cover, "secret payload", { encrypt: true, password: "hunter2" });
// stego looks identical to cover when rendered
const decoded = await decodeText(stego, "hunter2");
// → "secret payload"
Text steganography optionally layers AES-256-CTR encryption on top, so even if someone knows to look for hidden data, they can't read it without the password.
UnderByte: A Web UI for All of This
All of the above is available through a browser at underbyte.pinta.land. Upload an image, type your message, optionally set a password, download the result. The app shows you real-time capacity statistics and lets you tune the bit depth — useful for getting a feel for the LSB trade-offs without writing any code.
It's built on Deno with the Fresh framework, using @pinta365/steganography as
the underlying engine.
Using the Library
The steganography library is runtime-agnostic and available on JSR and npm:
The full source for both projects is on GitHub: steganography and UnderByte.
When Would You Actually Use This?
Steganography has legitimate practical uses beyond the obvious "secret messages" scenario:
- Digital watermarking — embed an invisible ownership mark in images you distribute.
- Covert channels in research — testing whether a system leaks data through unexpected means.
- Privacy in hostile environments — hiding the existence of a message can matter as much as hiding its content.
It's also just a genuinely interesting space to build in. The gap between "this looks like any other image" and "this image contains a hidden document" is surprisingly narrow — a few bit flips per pixel is all it takes.