How to encode hidden text into a png / bmp carrier image
- Step 1Drop a lossless carrier image — Upload a PNG, BMP, GIF, or WebP via the dropzone. The file picker accepts
.jpg,.jpeg,.png,.gif,.bmp,.webp, so a JPEG can be uploaded — it is decoded to raw pixels on the Canvas — but prefer a PNG/BMP source so you start from clean, un-recompressed pixels. - Step 2Type the message into 'Message to hide' — The only control is a single textarea labelled
Message to hide(placeholderHidden text payload…). There is no passphrase field, no channel selector, no bit-depth option — one text payload is the entire input. An empty message throwsEnter a message to encode. - Step 3Keep the payload in the Latin-1 range — Each character is encoded as
charCodeAt(0)padded to 8 bits. ASCII and Latin-1 (accented Western European letters likeé,ü,ñ) fit in one byte and round-trip perfectly. Emoji and CJK characters have code points above 255 and will corrupt the bit stream — encrypt or transliterate them first. - Step 4Encode — The tool reads the carrier with
getImageData, packsmessage + "\0"into the R/G/B least-significant bits one channel at a time, then callsputImageDataandtoBlob. If the message is too big it throwsMessage too large for this carrier image. Use a larger image.before producing any output. - Step 5Download the stego PNG — Click download to save the
<original-name>-stego.pngblob. It is visually identical to the carrier but carries your hidden text. Treat this PNG as the canonical artifact — re-saving it through any lossy step will destroy the message. - Step 6Verify the round-trip with the decoder — Feed the downloaded PNG into the Steganography Decoder. It walks the same RGB LSBs, stops at the first NUL byte, and returns the recovered text — a quick way to confirm the encode succeeded before you share the image.
Real carrier capacity (3 bits per pixel)
Capacity is computed from the actual encoder check: the bit stream (chars + 1) × 8 must not exceed pixels × 3, because one bit is written to each of the R, G, B channels and alpha is skipped. Values below are the maximum Latin-1 characters that fit, with the +1 NUL terminator already accounted for.
| Carrier dimensions | Pixels | Bit budget (px × 3) | Max message (chars) | Approx. text size |
|---|---|---|---|---|
| 256 × 256 | 65,536 | 196,608 bits | 24,575 | ~24 KB |
| 512 × 512 | 262,144 | 786,432 bits | 98,303 | ~96 KB |
| 1024 × 1024 | 1,048,576 | 3,145,728 bits | 393,215 | ~384 KB |
| 1920 × 1080 (1080p) | 2,073,600 | 6,220,800 bits | 777,599 | ~759 KB |
| 3840 × 2160 (4K) | 8,294,400 | 24,883,200 bits | 3,110,399 | ~3 MB |
What the encoder does vs. what it does not
The encoder exposes exactly one option (message). Everything else in this table is fixed behaviour baked into the Canvas LSB routine — there are no presets, channels, or bit-depth controls to configure.
| Aspect | Actual behaviour | Configurable? |
|---|---|---|
| Channels used | Red, Green, Blue least-significant bits (1 bit each) | No — fixed RGB, 3 bits/pixel |
| Alpha channel | Left completely untouched (skipped in the i += 4 loop) | No |
| Character encoding | charCodeAt(0) per character, padded to 8 bits — single-byte, Latin-1 safe | No |
| End marker | A NUL byte (\0) appended after the message; decoder stops at the first 0 | No |
| Output format | Always PNG via canvas.toBlob(..., "image/png") | No |
| Output filename | <original>-stego.png (extension replaced) | No |
| Passphrase / encryption | None — the message is stored in plaintext bits | No (encrypt separately) |
| Where it runs | Browser only (Canvas); no server API path | No |
Carrier formats and tier limits
Accepted input is anything the file picker and Canvas can decode; output is always PNG. File-size caps come from the security family tier limits, and the tool itself is gated to the Developer tier in the registry.
| Setting | Value | Source |
|---|---|---|
| Accepted input | .jpg, .jpeg, .png, .gif, .bmp, .webp | ACCEPT_MAP image entry |
| Recommended carriers | PNG, BMP (lossless source pixels) | Avoids pre-existing JPEG compression noise |
| Output | PNG only | toBlob(..., "image/png") |
| Files per run | 1 (single carrier) | acceptsMultiple: false |
| Minimum tier | Developer | minTier: "developer" in registry |
| Developer file cap | 2 GB | security family tier limit |
| Free / Pro / Pro-media caps | 10 MB / 100 MB / 500 MB | security family tier limits (below the Developer gate) |
Cookbook
Concrete encode workflows for covert-communication and CTF scenarios. Every example reflects the real algorithm: RGB LSBs, charCodeAt byte packing, NUL terminator, PNG output.
Hide a short note in a screenshot
The simplest case: a small ASCII message in an ordinary PNG screenshot. Well under capacity, perfect round-trip.
Carrier: screenshot.png (1280 × 720 → 921,600 px → ~345,599 char capacity) Message: Meet at the library, 4pm. Bring the key. Encode → screenshot-stego.png (visually identical PNG) Decoder check: Input: screenshot-stego.png Output: Meet at the library, 4pm. Bring the key.
Encrypt first, then hide the ciphertext
LSB alone hides a message but does not protect it — anyone who reads the same RGB LSBs recovers plaintext. Encrypt with aes-256-encryptor, paste the base64 ciphertext as the message, and the payload is both concealed and unreadable. Note the ciphertext is longer than the plaintext, so size the carrier accordingly.
Step 1 — aes-256-encryptor (AES-GCM 256 + PBKDF2): plaintext: "launch code 7731" passphrase: correct-horse-battery-staple → ciphertext (base64): U2FsdGVkX1+9z...Aa== (≈ 88 chars) Step 2 — steganography-encoder: Message to hide: U2FsdGVkX1+9z...Aa== Carrier: cover.png → cover-stego.png Finding the LSB channel now yields only base64 ciphertext.
CTF flag inside a wallpaper
A common CTF pattern: drop a flag into a high-resolution carrier where the capacity is enormous relative to the payload. Solvers run an LSB decoder to extract it.
Carrier: wallpaper-4k.png (3840 × 2160 → ~3,110,399 char capacity)
Message: CTF{lsb_h1d3s_1n_pl41n_s1ght}
Encode → wallpaper-4k-stego.png
Solver runs steganography-decoder:
→ CTF{lsb_h1d3s_1n_pl41n_s1ght}
(99.999% of the carrier's LSB budget is unused — undetectable to the eye)Message too large — pick a bigger carrier
The encoder validates capacity before writing anything. A 256×256 icon holds only ~24,575 characters; a longer note throws and produces no file. Switch to a larger carrier and re-run.
Carrier: icon.png (256 × 256 → 24,575 char capacity) Message: <a 40,000-character document> Result: Error: Message too large for this carrier image. Use a larger image. (no PNG produced) Fix: re-run with a 1024×1024 carrier (~393,215 char capacity).
Why a JPEG carrier source is risky
You can upload a JPEG — Canvas decodes it to pixels — but the JPEG's own compression artifacts already perturbed the LSBs before you ever touched them. The encode still succeeds (output is PNG), but if anyone re-saves your stego PNG back to JPEG, the message is destroyed. Always keep and share the PNG.
Upload: photo.jpg (decodes fine on Canvas) Message: rendezvous confirmed Encode → photo-stego.png ✅ message survives (PNG is lossless) Danger: photo-stego.png → re-saved as photo-stego.jpg Decoder output: (no LSB-encoded message detected) → JPEG re-compression rewrote every LSB.
Edge cases and what actually happens
Empty message submitted
RejectedIf the Message to hide textarea is blank, the processor throws Enter a message to encode. before doing any Canvas work. There is nothing to embed, so no PNG is produced. Type at least one character.
Message exceeds carrier capacity
RejectedBefore writing pixels the encoder checks that (chars + 1) × 8 ≤ pixels × 3. If the payload (plus its NUL terminator) overflows the carrier, it throws Message too large for this carrier image. Use a larger image. and produces no file. Pick a carrier with more pixels — see the capacity table.
Emoji or CJK characters in the message
Corruption riskCharacters are packed with charCodeAt(0) and assumed to fit in 8 bits. Code points above 255 (emoji, CJK, many symbols) overflow a single byte, so the bit stream desynchronises and the decoder returns garbage. Keep the payload in the 0–255 Latin-1 range, or encrypt/base64-encode non-Latin text first so the carried characters are all ASCII.
Output re-saved as JPEG
Bits destroyedThe stego image is delivered as a lossless PNG so the LSBs survive. The moment it is re-encoded through any lossy step — saving as JPEG, a chat app that re-compresses uploads, a CMS that converts to WebP — every least-significant bit is rewritten and the message is lost. Distribute and store the original PNG only.
Carrier is an animated GIF or multi-frame WebP
First frame onlyCanvas drawImage paints a single still frame. An animated GIF or WebP is flattened to its first frame, and only that frame's RGB LSBs carry the message. The output PNG is a single static image — animation is dropped. Use a still PNG/BMP to avoid surprises.
Image fails to load
FailedIf the browser cannot decode the uploaded file (corrupt header, unsupported codec, mislabelled extension), loadImage rejects with Failed to load image and no encode runs. Confirm the file opens in an image viewer, and prefer the standard formats in the ACCEPT_MAP list.
Message contains a NUL byte
Truncated on decodeA literal NUL (\0, char code 0) inside your text is indistinguishable from the appended end-of-message terminator. The decoder stops at the first 0 byte, so anything after an embedded NUL is silently dropped. Strip NULs from the payload before encoding.
Carrier already has no clean LSBs (heavily compressed source)
By designThe encoder overwrites the LSBs unconditionally, so encoding always succeeds on a loadable image regardless of prior noise. The risk is only on the source side: a JPEG carrier's LSBs are already dithered by compression. The encode is fine; just keep the output PNG lossless from that point on.
Alpha-channel content expected to carry data
PreservedThe encode loop steps i += 4 and writes only channels 0,1,2 (R,G,B), never index 3 (alpha). Transparency is preserved exactly and carries no payload. Capacity is therefore based on 3 channels per pixel, not 4 — don't expect a transparent PNG to give you extra room.
Very long message vs. decoder read cap
ExpectedThe encoder can pack hundreds of thousands of characters into a large carrier, but the Steganography Decoder stops reading at 100,000 characters. If you embed more than that, the encode is intact but a round-trip via the decoder returns only the first 100,000 characters. Split very large payloads across carriers or keep them under that ceiling.
Frequently asked questions
How much text can I actually hide?
Three bits per pixel — one in each of the R, G, B channels (alpha is skipped). So capacity in characters is roughly pixels × 3 ÷ 8 minus one byte for the NUL terminator. A 1024×1024 image holds about 393,215 Latin-1 characters (~384 KB), not the ~128 KB that a 1-bit-per-pixel rule of thumb suggests. See the capacity table for common sizes.
Will JPEG compression break it?
Yes, completely. LSB steganography depends on every byte surviving intact, and JPEG is lossy — it rewrites the low-order bits during compression. The tool always outputs a lossless PNG so the encode survives, but if that PNG is later re-saved as JPEG (or run through any service that re-compresses images), the message is destroyed. Keep and share the PNG only.
What output format do I get?
Always a PNG, named <original>-stego.png. The encoder calls canvas.toBlob(..., "image/png") regardless of what you uploaded, because PNG is lossless and preserves the LSBs. You can upload a JPEG, BMP, GIF, or WebP carrier, but the download is PNG.
Can I use a JPEG as the carrier?
You can — the file picker accepts .jpg/.jpeg and Canvas decodes it to pixels. But a JPEG's LSBs are already perturbed by its own compression, so it's a noisier starting point than a PNG or BMP. The bigger danger is downstream: never let the resulting stego PNG get re-saved as JPEG. Prefer a lossless carrier when you have the choice.
Is the hidden message encrypted?
No. LSB steganography hides the message but stores it as plaintext bits, so anyone reading the same RGB LSBs recovers it. For confidentiality, encrypt the text first with aes-256-encryptor (AES-GCM 256 with PBKDF2) and paste the ciphertext as the message — then the payload is both concealed and unreadable without the passphrase.
Which characters are safe to encode?
ASCII and Latin-1 (0–255), including accented Western European letters like é, ü, and ñ. Each character is packed via charCodeAt(0) as a single byte, so anything with a code point above 255 — emoji, CJK, many symbols — overflows and corrupts the stream. Encrypt or base64-encode non-Latin text first so the carried characters are all ASCII.
How does the decoder know where the message ends?
The encoder appends a NUL byte (\0, code 0) after your text. The Steganography Decoder walks the RGB LSBs, reassembles bytes, and stops at the first 0. That's why a literal NUL inside your message would truncate it — strip NULs before encoding.
Can the hidden message be detected?
Statistical steganalysis (chi-squared, RS analysis, sample-pair analysis) can flag LSB embedding, especially when a large fraction of the carrier's LSB budget is used. Using a tiny payload in a large carrier (as in the 4K CTF example) makes detection far harder. For genuinely covert communication, combine encryption with sparse embedding — but understand that no LSB scheme is provably undetectable.
Does my image or message get uploaded to a server?
No. The encode is browser-only — Canvas reads the pixels with getImageData, packs the bits, and toBlob builds the download, all in the page. The carrier and your secret never leave your machine. This tool has no server API path; it's in the BROWSER_ONLY set.
Why is this tool gated to the Developer tier?
The registry sets minTier: "developer" for the encoder. The Developer security tier allows files up to 2 GB with unlimited file count, which comfortably covers any raster carrier. Lower tiers cap security files at 10 MB (Free), 100 MB (Pro), and 500 MB (Pro-media), but the tool itself is gated above those.
Will encoding change how my image looks?
Effectively no. Each modified channel changes by at most 1 out of 255, which is imperceptible on screen and to casual inspection. The alpha channel is untouched, so transparency is preserved exactly. The output is the same dimensions as the carrier, just re-saved as PNG.
How do I get my message back out?
Feed the -stego.png into the Steganography Decoder, which reads the same RGB LSBs and returns the text up to the NUL terminator (capped at 100,000 characters per read). If you encrypted the payload first, run the recovered ciphertext back through aes-256-encryptor in decrypt mode. For unrelated obfuscation needs — scrambling PII in spreadsheets rather than hiding text in images — see csv-json-data-scrambler.
Privacy first
Every JAD Security operation runs entirely in your browser. Files, passwords, and PGP private keys never leave your device — verified by zero outbound network requests during processing.