
Introduction
In this article, weāll explore a secure way to encrypt images using Advanced Encryption Standard (AES). AES is a widely trusted and globally recognized symmetric key encryption standard. It works by scrambling the visual data of an image and makes it unreadable for anyone without the correct decryption key.
We will achieve this AES encryption process in Python to encrypt and decrypt an image. After encryption, weāll build another program for the decryption process using the same logic.
How does the AES Algorithm Work?
AES works by performing a series of operations on blocks of data (128 bits at a time) using a specific key (128, 192, or 256 bits). Hereās a simplified explanation of the process:
- Preparation:
- The plain text (original data) is divided into fixed-size blocks (16 bytes each).
- A key schedule is generated, expanding the main key into multiple round keys used throughout the encryption process.
- Encryption rounds:
- Each data block goes through a series of rounds (10, 12, or 14 depending on key length). Each round consists of four basic transformations:
- SubBytes: Substitutes each byte in the block with a different byte according to a predefined lookup table. This shuffles the data and introduces āconfusion.ā
- ShiftRows: Shifts the rows of the byte matrix cyclically by different offsets. This disrupts any patterns in the data.
- MixColumns: Performs a complex mixing operation on the data columns, ensuring diffusion of any remaining data patterns.
- AddRoundKey: XORs the current block state with a round key derived from the key schedule. This incorporates the key material into each round.
- Each data block goes through a series of rounds (10, 12, or 14 depending on key length). Each round consists of four basic transformations:
- Finalization:
- After all rounds, the final block becomes the encrypted ciphertext (unreadable data).
The key strength and multiple rounds make AES highly secure. Even a small change in the plain text or key will result in a completely different ciphertext, making it difficult to crack the code.
Why use the AES Algorithm?
There are several advantages to using the Advanced Encryption Standard (AES) algorithm for encryption:
- High Security: AES is considered one of the most secure encryption algorithms currently available. It uses a combination of factors, including long key lengths (128, 192, or 256 bits) and a robust cipher design, to make it extremely difficult to crack. With a sufficiently long key, brute-force attacks (trying every single possible key) become practically impossible.
- Efficiency: AES is relatively efficient to implement in both hardware and software. This makes it suitable for a wide range of applications, from resource-constrained devices to high-performance computing environments.
- Flexibility: AES is a symmetric block cipher, meaning the same key is used for both encryption and decryption. This can be simpler to manage than some other encryption schemes. Additionally, AES can be used in various modes of operation, allowing it to be adapted to different security requirements.
- Widespread Adoption: AES is the encryption standard endorsed by the National Institute of Standards and Technology (NIST) and many other government agencies around the world. This widespread adoption means there is a large pool of expertise and a strong track record for security.
Requirements and Installations
Before we get started, ensure you have Python installed on your system. You can download it from their official website (https://www.python.org/downloads/).
Now install the pycryptodome library to perform encryption and decryption on an image using the AES algorithm.
pip install pycryptodome
Additionally, install the Pillow library using the following command. This library will allow us to work with images using Python programming.
pip install Pillow
Original Image
In this program, we will use the following image (āoriginal.jpgā) to encrypt and decrypt using the AES algorithm. You can download it through the Download button below.

encrypt.py
Letās create a Python file named āencrypt.pyā and copy the code below.
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Random import get_random_bytes from Crypto.Util import Counter from PIL import Image import io import os from Crypto.Hash import SHA256, HMAC def encrypt_image(input_image_path, output_image_path, key): # Load the image image = Image.open(input_image_path) # Generate a random IV iv = get_random_bytes(AES.block_size) # Get a unique identifier from the filename image_hash = SHA256.new(os.path.basename(input_image_path).encode("utf-8")).hexdigest() # Combine key-specific value with random string (nonce) using HMAC key_specific = HMAC.new(key, msg=image_hash.encode("utf-8"), digestmod=SHA256).digest() unique_iv = HMAC.new(key_specific, msg=os.urandom(16), digestmod=SHA256).digest()[:16] if len(unique_iv) != 16: raise ValueError("Unexpected IV length. Encryption aborted.") # Convert the image to bytes img_byte_array = io.BytesIO() image.save(img_byte_array, format=image.format) img_bytes = img_byte_array.getvalue() # Initialize AES cipher cipher = AES.new(key, AES.MODE_CBC, unique_iv) # Encrypt the image data with padding padded_data = pad(img_bytes, AES.block_size) encrypted_data = iv + cipher.encrypt(padded_data) # Write the encrypted data to the output image file with open(output_image_path, 'wb') as f: f.write(encrypted_data) # Save the IV to a separate file with a filename related to the image iv_path = os.path.join(os.path.dirname(output_image_path), f"{os.path.basename(input_image_path)}.iv") with open(iv_path, 'wb') as f: f.write(unique_iv) print(f"Encryption successful. Encrypted image saved to '{output_image_path}'.") print(f"IV file saved to '{iv_path}'.") if __name__ == "__main__": input_image_path = 'original.jpg' output_image_path = 'encrypted_image.jpg' key = 'UZ4i59vPgLRT16s8FZ4i81vPgLRT16qk' key = bytes(key, encoding="utf-8") # Encrypt the image encrypt_image(input_image_path, output_image_path, key)
Explanation
The above program takes an input image named āoriginal.jpgā and outputs an image file ā āencrypted_image.jpgā and an iv file ā āoriginal.jpg.ivā. Letās understand encrypt_image
function line-by-line.
The encrypt_image
function takes three arguments: āinput_image_pathā, āoutput_image_pathā, and ākeyā. The ākeyā is a byte array (generated from a string using bytes.encode("utf-8"))
containing the secret key used for encryption.
The function encrypts the image at the āinput_image_pathā and saves the encrypted version at the āoutput_image_pathā.
Important Breakdown
image = Image.open(input_image_path)
: This line uses the Pillow (PIL) library to open the image file specified by āinput_image_pathā.iv = get_random_bytes(AES.block_size)
: This line generates a random Initialization Vector (IV) using the āCrypto.Randomā library. The IV is a crucial part of the encryption process and should be unique for each encryption. The AES encryption algorithm used here requires the IV size to be equal to its block size.image_hash = SHA256.new(os.path.basename(input_image_path).encode("utf-8")).hexdigest()
: This line calculates a unique identifier for the image based on its filename.os.path.basename(input_image_path)
extracts the filename from the provided path..encode("utf-8")
converts the filename to bytes for hashing.SHA256.new
creates a SHA-256 hash object..hexdigest()
gets the hexadecimal digest of the hash, providing a unique fingerprint for the filename.
key_specific = HMAC.new(key, msg=image_hash.encode("utf-8"), digestmod=SHA256).digest()
: This creates a key-specific value by applying the HMAC (Hash-based Message Authentication Code) algorithm with SHA-256 as the digest method. It uses the ākeyā and the image hash as inputs.unique_iv = HMAC.new(key_specific, msg=os.urandom(16), digestmod=SHA256).digest()[:16]
: This line creates a unique IV by applying HMAC again. Here, the ākey_specificā value acts as a key, and a random 16-byte string (generated byos.urandom(16))
is the message. The result is truncated to 16 bytes (AES block size) to form the āunique_ivā.img_byte_array = io.BytesIO()
: This line creates a virtual in-memory file-like object usingio.BytesIO
.image.save(img_byte_array, format=image.format)
: This line saves the loaded image to the in-memory byte array, preserving its original format.img_bytes = img_byte_array.getvalue()
: This line retrieves the actual byte data of the image from the in-memory buffer.cipher = AES.new(key, AES.MODE_CBC, unique_iv)
: This line creates an AES cipher object using theCrypto.Cipher
library.- ākeyā: The secret key for encryption (assumed to be already converted to bytes).
AES.MODE_CBC
: This specifies Cipher Block Chaining (CBC) mode for AES, a common and secure mode of operation.- āunique_ivā: The unique initialization vector generated earlier.
padded_data = pad(img_bytes, AES.block_size)
: AES encryption works on blocks of data. If the image data size isnāt a perfect multiple of the block size, it needs padding. The pad function adds extra bytes to make the data fit the block size.encrypted_data = iv + cipher.encrypt(padded_data)
: Finally, the encrypt method of the cipher object encrypts the padded image data.with open(output_image_path, 'wb') as f
: The encrypted data (including the IV at the beginning) is written to a new image file specified by output_image_path.with open(iv_path, 'wb') as f
: The unique IV is also saved in a separate file with a filename based on the original image file for decryption purposes.
Output
Normally, the encrypted image (āencrypted_image.jpgā) is not visible.
decrypt.py
Now, letās define the ādecrypt.pyā script, which reverses the encryption process to recover the original image.
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Random import get_random_bytes from Crypto.Util import Counter from PIL import Image import io import os from Crypto.Hash import SHA256, HMAC, SHA1 def decrypt_image(input_image_path, output_image_path, key, iv_path): # Read the IV from the separate file with open(iv_path, 'rb') as f: unique_iv = f.read() # Read the encrypted data with open(input_image_path, 'rb') as f: encrypted_data = f.read() # Separate the IV from the encrypted data iv = encrypted_data[:AES.block_size] encrypted_data = encrypted_data[AES.block_size:] # Initialize AES cipher cipher = AES.new(key, AES.MODE_CBC, unique_iv) # Decrypt the image data decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size) # Convert decrypted bytes to image decrypted_image = Image.open(io.BytesIO(decrypted_data)) # Save the decrypted image decrypted_image.save(output_image_path, format=decrypted_image.format) print(f"Decryption successful. Decrypted image saved to '{output_image_path}'.") # Example usage if __name__ == "__main__": input_image_path = "encrypted_image.jpg" iv_file = 'original.jpg.iv' key = 'UZ4i59vPgLRT16s8FZ4i81vPgLRT16qk' key = bytes(key, encoding="utf-8") decrypt_image(input_image_path, 'decrypted_image.jpg', key, iv_file)
Explanation
The decrypt_image
function takes an encrypted image, its corresponding IV file, and a decryption key, and reverses the encryption process to retrieve the original image. Hereās a breakdown of the key lines:
with open(iv_path, 'rb') as f: unique_iv = f.read()
- This line reads the Initialization Vector (IV) from a separate file specified by āiv_pathā. Remember, the IV was saved alongside the encrypted image during encryption.
- It opens the file in binary reading mode (ārbā) and reads the entire content into the āunique_ivā variable.
with open(input_image_path, 'rb') as f: encrypted_data = f.read()
- Similar to the IV retrieval, this line reads the encrypted image data from the file specified by āinput_image_pathā.
- It opens the file in binary reading mode (ārbā) and stores the entire content in the āencrypted_dataā variable.
iv = encrypted_data[:AES.block_size] encrypted_data = encrypted_data[AES.block_size:]
- Since the encrypted data includes the IV prepended at the beginning, this section carefully separates them.
- It extracts the first
AES.block_size
bytes (typically 16 bytes) as the iv and assigns the remaining bytes to the encrypted_data variable.
cipher = AES.new(key, AES.MODE_CBC, unique_iv)
Here, the decryption process is set up. A new AES cipher object is created using the ākeyā, the encryption mode (CBC ā Cipher Block Chaining, which must match the encryption mode used), and the retrieved āunique_ivā.
decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size)
- The core decryption happens here. The decrypt method of the cipher object is used with the āencrypted_dataā to retrieve the original image data in its encrypted format.
- Remember the padding added during encryption to ensure a perfect block size? This part removes those extra bytes using the
unpad
function with the AES block size.
decrypted_image = Image.open(io.BytesIO(decrypted_data))
- The decrypted data is now a byte stream. To view the image, it needs to be converted back to an image format.
- This section uses
io.BytesIO
to create a temporary in-memory file-like object from the ādecrypted_dataā bytes. - Finally, the
Image.open
function from PIL opens this temporary buffer as the decrypted image (ādecrypted_imageā).
decrypted_image.save(output_image_path, format=decrypted_image.format)
The decrypted image is saved as a new image file specified by āoutput_image_pathā. The decrypted_image
object itself automatically determines the format of the image.
Output

Summary
In this article, we learned image encryption in Python using the AES-256 encryption. We explored how the AES algorithm scrambles the data within an image and makes it unreadable without the secret key (256-bit key) along with the Initialization Vector (IV) file. This extra layer of security ensures the confidentiality of your private images even if they are shared or stored electronically.
Remember, itās not possible to decrypt the encrypted image using the AES algorithm in the provided program without having the secret key and the IV file. So, always keep your secret key and iv file safe.
Recommended Article: Create an Image Encryption App in Python with AES-256 Encryption