Skip to content

Everything for WordPress, web development — and beyond

🔐 Image steganography in PHP: hiding text in pixels with LSB

🔐 Image steganography in PHP: hiding text in pixels with LSB

Click, and your encoded.png looks identical to container.jpg. No one will suspect there's a hidden message inside. Steganography doesn't make data unreadable like encryption does; it hides the very fact of transmission. For a PHP developer, this is one of those techniques worth understanding at the level of bits and pixels.

LSB steganography in PHP with the GD module is not a production tool but a training ground. A training ground that teaches you to work with binary representation, bit masks, and lossless formats better than a dozen CRUD tasks. You'll go from "how an image is structured inside" to the line php decrypt.php that extracts hello from pixels, and you'll understand why JPEG isn't suitable for this.

💡 Quick overview:

  • We break down the theory: how the least significant bit of the blue channel hides a message and why the eye doesn't see it
  • We write three PHP files: functions.php (utilities), encrypt.php (encoding), decrypt.php (extraction)
  • We run it on a real JPEG container and get a visually indistinguishable PNG result
  • We go through the limitations: compression, detectability, capacity, and the combination with AES encryption
  • We look at ready-made PHP libraries and decide when GD is enough and when it's time to use Python

How LSB steganography works

LSB (Least Significant Bit) is a method where the secret message is written into the least significant bits of pixels. The name is literal: we take the least significant bit of a color channel and replace it with a bit of the message.

A digital image is a grid of pixels. Each pixel is encoded with three bytes: red (R), green (G), and blue (B). The combination of three values from 0 to 255 determines the final color. Changing the least significant bit shifts the channel value by no more than 1; for example, RGB(100, 50, 200) becomes RGB(100, 50, 201). The human eye cannot distinguish the difference.

Let's take an orange pixel:

  • Red: 11001001 (201)
  • Green: 11111000 (248)
  • Blue: 00000011 (3)

The least significant bit of the blue channel is one. Replace it with a bit of the secret message, and the color will visually remain the same. By processing pixels sequentially, we write the entire message bit by bit.

Suppose we need to hide the sequence 1101. We take 4 pixels and change the least significant bit of the blue channel:

Pixel

R

G

Original B

LSB replaced

New B

1

11001100

10010001

00101011

1

00101011

2

00011000

11110000

11111110

1

11111111

3

11100010

00100101

01010101

0

01010100

4

11111101

00001010

01000011

1

01000011

Decoding is the reverse process: we collect the least significant bits of the blue channel from the same four pixels and get 1101. No magic, just bitwise operations.

In practice, you're not limited to the blue channel alone: you can use all three (R, G, B), tripling the volume of hidden data. In this tutorial, we work only with blue for simplicity, so it's easier to see what happens with each bit.

Step-by-step implementation in PHP

You'll need PHP with the GD module, which is preinstalled on virtually all hosting providers. The code is written for PHP 8.0+ and tested up to 8.4. Create three files in one folder.

Step 1: Helper functions, functions.php

Two utilities: converting a string to binary representation and back.

1<?php
2
3function toBin(string $str): string
4{
5 $result = '';
6 $len = strlen($str);
7
8 while ($len--) {
9 $result = str_pad(
10 decbin(ord($str[$len])),
11 8,
12 '0',
13 STR_PAD_LEFT
14 ) . $result;
15 }
16
17 return $result;
18}
19
20function toString(string $binary): string
21{
22 $result = '';
23 foreach (str_split($binary, 8) as $byte) {
24 $result .= chr((int) base_convert($byte, 2, 10));
25 }
26
27 return $result;
28}

toBin() converts each character of the string to an 8-bit representation via decbin(ord(...)) and pads with zeros on the left to make a full byte. toString() does the opposite: it slices the binary string into 8-bit chunks, converts each chunk to a decimal number, and gets the character via chr().

Block-by-block processing in toString() is not a whim. The popular approach in tutorials using pack('H*', base_convert($binary, 2, 16)) breaks on messages longer than 31 bits: the base_convert function operates on integers, and when exceeding PHP_INT_MAX, it returns garbage. Splitting by bytes solves the problem for messages of any length.

Step 2: Encoding, encrypt.php

Reads a JPEG container, embeds the message in the least significant bits of the blue channel, and saves the result as PNG. Why not JPEG? A lossless format is mandatory: JPEG compression will destroy the hidden bits, and the message will turn into noise.

1<?php
2
3require_once 'functions.php';
4
5$message = 'hello';
6$binary = toBin($message);
7$msgLen = strlen($binary);
8
9$src = 'container.jpg';
10$img = imagecreatefromjpeg($src);
11
12for ($x = 0; $x < $msgLen; $x++) {
13 $y = $x;
14 $rgb = imagecolorat($img, $x, $y);
15
16 $r = ($rgb >> 16) & 0xFF;
17 $g = ($rgb >> 8) & 0xFF;
18 $b = $rgb & 0xFF;
19
20 $blueBits = str_pad(decbin($b), 8, '0', STR_PAD_LEFT);
21 $blueBits[7] = $binary[$x];
22 $newB = bindec($blueBits);
23
24 $newColor = imagecolorallocate($img, $r, $g, $newB);
25 imagesetpixel($img, $x, $y, $newColor);
26}
27
28imagepng($img, 'encoded.png');
29
30echo "Закодировано {$msgLen} бит — результат в encoded.png\n";

imagecolorat() gets the pixel color at coordinates (x, y). Bit shifts >>16 and >>8 extract R, G, B. The blue channel is expanded into an 8-bit string, the least significant bit (index 7) is replaced with the next bit of the message, and the new color is written via imagesetpixel().

The message is encoded diagonally (y = x), which makes it easier to follow the logic in a small example. In a real project, iterate through all pixels row by row and add a 32-bit header with the message length: without it, the decoder doesn't know where to stop.

Step 3: Decoding, decrypt.php

Extracts the hidden message: goes through the same pixels and collects the least significant bits of the blue channel.

1<?php
2
3require_once 'functions.php';
4
5$src = 'encoded.png';
6$img = imagecreatefrompng($src);
7
8$bits = '';
9
10for ($x = 0; $x < 40; $x++) {
11 $y = $x;
12 $rgb = imagecolorat($img, $x, $y);
13
14 $b = $rgb & 0xFF;
15 $blueBits = str_pad(decbin($b), 8, '0', STR_PAD_LEFT);
16 $bits .= $blueBits[7];
17}
18
19echo toString($bits) . "\n";

Here 40 is a hardcoded number of bits, exactly 5 bytes for the word hello. In a production version, replace this number with the length from the message header that you wrote in the first 32 bits during encoding.

Step 4: Running and verification

Place container.jpg in the folder with the scripts. The procedure:

  • php encrypt.php outputs in the console: "Encoded 40 bits, result in encoded.png".
  • php decrypt.php outputs in the console: hello.
  • Open container.jpg and encoded.png side by side; there's no difference. They are visually indistinguishable.

You can compare byte by byte using ImageMagick: magick compare container.jpg encoded.png diff.png will highlight differences in the least significant bits, practically invisible to the eye. But the most reliable way to verify is to run decrypt.php and read the message.

Limitations of the method

LSB steganography is a learning tool, not cryptographic protection. Here's what you need to keep in mind before using this approach in a real project:

  • Compression destroys data. JPEG recalculates 8×8 pixel blocks with quantization, and the least significant bits don't survive. That's why encrypt.php saves the result strictly as PNG (lossless). One pass through JPEG compression, and the hidden bits will turn into noise. Alternative formats: BMP, TIFF without compression.

  • Detectability. LSB filling leaves statistical artifacts: the histogram of the blue channel changes in a predictable way. RS analysis and the χ² test detect a hidden message in seconds. Without encryption, LSB is concealment, not protection: the presence of a message can be detected even if the content cannot be read.

  • Data volume. Each pixel gives up one bit in the blue channel. A 1000×1000 pixel image can hold up to 125 KB of hidden text; this is the mathematical limit following from the definition of the LSB method. In practice, after PNG compression, the useful volume is smaller, plus some bits go to the header with the length.

  • Not cryptography. Steganography doesn't replace encryption; it complements it. The correct pipeline: encrypt the message (AES-256), then hide the ciphertext in the image. Then even detecting the hidden channel won't reveal the content.

  • PHP GD is a basic tool. GD provides low-level access to pixels, and for learning, this is more than enough. But the Python ecosystem for steganography is noticeably richer: the stegano library (LSB + encryption), Stegano (a set of methods), and steghide (C++, but there are Python bindings). In pure PHP, there are several libraries: kzykhys/Steganography (a minimal LSB implementation for PNG), seba/steganography (a modern library with LSB manipulation), and pateon/steganography (PHP 8.5+ with GD and Imagick support). But there are no production-grade solutions at the level of steghide in PHP as of 2026.

⁉️🤔 Frequently asked questions

Can you hide text directly in JPEG?

No. JPEG compression divides the image into 8×8 blocks and applies quantization; the least significant bits don't survive. To hide data in JPEG, a different approach is used: data is hidden in discrete cosine transform (DCT) coefficients, not in pixels. Algorithms: F5, JSteg, OutGuess. There are practically no ready-made PHP libraries for this; it's easier to use Python.

How does LSB differ from steganography in metadata?

LSB changes pixel data; the message is hidden "inside" the image. Metadata (EXIF, XMP, PNG tEXt chunks) is located in file headers and can be read by any properties viewer. Metadata is easier to extract, but it survives JPEG compression, while LSB cannot.

How can I verify that encoded.png actually contains a hidden message?

Visually, you can't. Compare the files byte by byte: magick compare container.jpg encoded.png diff.png (ImageMagick) will show differences in the least significant bits. They are practically invisible to the eye, but the utility will highlight the changed pixels. The most reliable way is to run php decrypt.php and read the message.

How secure is this overall?

Without encryption, it's not secure. LSB without prior AES encryption is concealment, not protection. The fact of hidden data transmission is detected by statistical tests in seconds. The combination of "AES encryption + LSB concealment" provides an acceptable level for pet projects, but not for sensitive data.

Are there ready-made PHP libraries for steganography?

Yes, but they are all educational or lightweight. kzykhys/Steganography is a minimal LSB implementation that works only with PNG. seba/steganography is more recent, with a clean API for encoding/decoding text in images. pateon/steganography is PHP 8.5+ and supports both GD and Imagick. For serious tasks, look toward Python: stegano (LSB + encryption) and Stegano (a set of methods, including a stego-image generator).

Steganography in PHP: a learning base or a ready-made tool?

LSB steganography in PHP with GD is an excellent training ground for understanding how data is hidden at the pixel level. You wrote a working encode/decode cycle, figured out bitwise operations, and saw in practice why an "invisible" change leaves traces in image statistics.

For a learning demonstration, a pet project, or a CTF challenge, this toolkit is more than enough. For real data protection, add encryption before concealment and look into Python libraries, which are head and shoulders above PHP counterparts in capabilities and resistance to detection.

If you want to dig deeper, implement encoding in all three color channels at once and add a 32-bit header with the message length. You'll get a mini-tool that is 3 times more efficient than the basic version and already qualifies as a course project. Post the source code on GitHub so others can learn from your example.