How to how lsb steganography works: a technical implementation guide
- Step 1Get the pixel array — Draw the image to a canvas and call
getImageData(0, 0, w, h). The.datafield is a flatUint8ClampedArrayof[R,G,B,A, R,G,B,A, ...]— four bytes per pixel. - Step 2Iterate channels, skip alpha — Loop with
i += 4. Read or writedata[i](R),data[i+1](G),data[i+2](B). Never touchdata[i+3](A) — the JAD tools leave alpha untouched. - Step 3Pack bits MSB-first (encode) — For each character,
charCodeAt(0).toString(2).padStart(8,'0')gives 8 bits high-to-low. Write each bit into a channel low bit:data[i+c] = (v & 0xFE) | bit. - Step 4Terminate with a null — Append
\0after the message so the decoder knows where to stop. The encoder does exactly this: it encodesmessage + "\0". - Step 5Unpack bits (decode) — Read
data[i] & 1,data[i+1] & 1,data[i+2] & 1into a bit string, slice in groups of 8,parseInt(group, 2)for the byte,String.fromCharCodefor the character. - Step 6Stop at the terminator and cap — Break when a byte equals
0. The JAD decoder also caps output at 100,000 characters to bound a runaway read on a non-steg image.
Encode vs decode — exact operations
Mirrors encodeLsb / decodeLsb in lib/security/security-processor.ts. Both operate on R/G/B only, MSB-first, raster order.
| Stage | Encode | Decode |
|---|---|---|
| Pixel access | getImageData → modify → putImageData | getImageData → read only |
| Channels | Write low bit of R, G, B (i+0/1/2) | Read low bit of R, G, B (i+0/1/2) |
| Alpha | Untouched (i+3 skipped) | Untouched (i+3 skipped) |
| Bit order | MSB-first: padStart(8,'0') | MSB-first: parseInt(8-bit slice, 2) |
| Char mapping | charCodeAt(0) (per-byte) | String.fromCharCode (per-byte / Latin-1) |
| Terminator | Appends \0 after the message | Stops at first byte === 0 |
| Bounds | Rejects if bits > w×h×3 | Caps output at 100,000 chars |
| Output | canvas.toBlob(..., 'image/png') | Returns text → extracted-message.txt |
Capacity by carrier size
Capacity = width × height × 3 ÷ 8 bytes (3 bits per pixel, alpha excluded). Embedding near full capacity is more detectable; leave headroom.
| Carrier (W×H) | Pixels | Bits (×3) | Capacity (bytes) |
|---|---|---|---|
| 256×256 | 65,536 | 196,608 | ~24 KB (24,576 B) |
| 512×512 | 262,144 | 786,432 | ~96 KB (98,304 B) |
| 1024×1024 | 1,048,576 | 3,145,728 | ~384 KB (393,216 B) |
| 1920×1080 | 2,073,600 | 6,220,800 | ~760 KB (777,600 B) |
| 4000×3000 | 12,000,000 | 36,000,000 | ~4.29 MB (4,500,000 B) |
Cookbook
Concrete bit-level traces so you can verify your own implementation matches the JAD tools. Values are computed exactly as the code does.
Encoding a single character
Encode 'A' (ASCII 65 = 01000001) into three pixels, three bits at a time, MSB-first.
'A' = 65 = 01000001 (then '\0' = 00000000 terminator) Pixel 0 R,G,B low bits <- 0,1,0 Pixel 1 R,G,B low bits <- 0,0,0 Pixel 2 R,G,B low bits <- 0,1,_ (8th bit '1' lands here) Each written channel: data[i+c] = (value & 0xFE) | bit
Decoding the same character
Read the low bits back in the same order and regroup into a byte.
Read low bits raster order: 0 1 0 0 0 0 0 1 ...
Group 8: 01000001
parseInt('01000001', 2) = 65
String.fromCharCode(65) = 'A'
Next 8 bits = 00000000 -> code 0 -> STOPCapacity check before encoding
The encoder refuses a payload that won't fit. Capacity in bits is data.length / 4 * 3.
Carrier 200×200 = 40,000 px -> capacity 40000*3 = 120,000 bits = 15,000 bytes Message 16,000 bytes -> 128,000 bits + 8 (null) > 120,000 Error: 'Message too large for this carrier image. Use a larger image.'
Why a stray null truncates
Because decode stops at the first 0x00, a payload byte that happens to be null ends the read early.
Payload bytes: 48 49 00 50 51 ('HI' then NUL then 'PQ')
Decode: 'H' 'I' -> next byte 0 -> STOP
Result: 'HI' (the '\x00PQ' tail is never read)
Frame binary payloads with length, or base64-encode, to avoid embedded nulls.Non-ASCII is byte-level, not UTF-8
charCodeAt/fromCharCode operate per code unit. Multi-byte UTF-8 is not reconstructed by the decoder.
Encoding 'é' via charCodeAt(0) = 233 -> single byte 11101001 Decode -> String.fromCharCode(233) = 'é' (Latin-1) But a UTF-8 *byte stream* (0xC3 0xA9 for 'é') decodes as two separate Latin-1 chars 'é'. Treat output as bytes and re-decode as UTF-8 if needed.
Edge cases and what actually happens
Full-capacity embedding
DetectableFilling all 3 bits/pixel maximises payload but flattens the low-bit plane toward random, which steganalysis (and the Entropy Analyzer) flags. Use a fraction of capacity for stealth.
Alpha-channel payload
Not supportedBoth encode and decode skip data[i+3]. You cannot hide or recover data in alpha with these tools; only R, G, B are used.
Embedded null byte in payload
TruncatedDecode stops at the first 0x00. A binary payload containing a null ends the read early. Base64-encode or length-frame binary data before embedding.
UTF-8 multi-byte text
MojibakeMapping is per-byte via String.fromCharCode, not UTF-8 decoding. Multi-byte characters split into separate Latin-1 glyphs. Capture raw output and decode as UTF-8 separately.
Payload exceeds capacity
RejectedThe encoder throws 'Message too large for this carrier image. Use a larger image.' when bits exceed data.length / 4 * 3. Pick a larger carrier or a shorter message.
Output reaches 100,000 chars
CappedDecode breaks at 100,000 characters even without a terminator, bounding reads on non-steg images. Legitimate messages are normally far shorter.
Encoder always exports PNG
By designRegardless of input format, the encoder calls canvas.toBlob(..., 'image/png'). Even a BMP carrier comes back as a lossless PNG — which is correct, since the payload must stay lossless.
JPEG as carrier
Invalid carrierRe-encoding to JPEG applies DCT quantisation that rewrites pixel values and wipes the LSB plane. The math assumes lossless storage; JPEG breaks it entirely.
Bit order or channel order mismatch
GarbledA foreign implementation using LSB-first bit packing or BGR channel order won't interoperate. The JAD tools are MSB-first, R→G→B; round-trip within them to stay consistent. To inspect the raw carrier bytes while debugging, use the Hex Header Inspector.
Two-pixel image
SupportedEven a tiny carrier works mechanically — 2 pixels give 6 bits, under one byte of capacity, so only sub-byte payloads fit. The capacity formula still applies: w×h×3÷8.
Frequently asked questions
Exactly how many bits per pixel are used?
Three — the low bit of R, the low bit of G, and the low bit of B. Alpha is skipped. So capacity is width × height × 3 ÷ 8 bytes.
What's the bit order?
MSB-first. Encoding uses charCodeAt(0).toString(2).padStart(8,'0'); decoding regroups with parseInt(8-bit slice, 2). Both read high bit to low bit within each byte.
Why is there a null terminator?
The encoder appends \0 after the message so the decoder knows where the payload ends. Decoding stops at the first byte equal to 0.
Does it handle Unicode correctly?
Not as UTF-8. Characters map per-byte via charCodeAt/fromCharCode (Latin-1 style). A UTF-8 byte stream comes back as separate Latin-1 glyphs — capture the raw bytes and re-decode if you need UTF-8.
Why must I use PNG or BMP, never JPEG?
LSB lives in exact pixel values. JPEG's lossy DCT compression changes those values, destroying the low-bit plane. Only lossless formats preserve the payload byte-for-byte.
What format does the encoder output?
Always PNG. canvas.toBlob(..., 'image/png') is hard-coded, so even a BMP input returns a lossless PNG carrier — which is what keeps the payload intact.
How do I compute capacity for my image?
Multiply width by height, multiply by 3 (channels), divide by 8 for bytes. A 1024×1024 image holds 393,216 bytes (~384 KB). Leave headroom for stealth.
What if my payload has a null byte in it?
Decoding stops there. Base64-encode or length-frame binary payloads so no 0x00 appears mid-stream and truncates the read.
Why cap output at 100,000 characters?
A non-steg image with no terminator would otherwise stream the entire pixel count as garbage. The 100,000-char cap bounds the read. Real messages are normally much shorter.
Can I implement this myself and stay compatible?
Yes — match the contract exactly: R/G/B low bits, stride 4, raster order, MSB-first bytes, \0 terminator, PNG output. Then a JAD-encoded image decodes in your code and vice versa.
Does it touch the alpha channel at all?
No. The loop reads/writes data[i], data[i+1], data[i+2] and leaves data[i+3] untouched, so the image's transparency is preserved.
How do I verify my understanding quickly?
Round-trip through the JAD tools: hide a known string with the Steganography Encoder, then decode it back. An exact match confirms bit order, channel order, and termination.
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.