What is AES and how it works?

In my day‑to‑day work, AES occasionally shows up as a requirement or as a behind‑the‑scenes feature of a technology. Believe it or not, we use it many times each day without realizing it: when you connect to secure Wi‑Fi, visit an HTTPS site, message someone via a mobile app, or interact with your banking app, AES is encrypting the data.
Until recently, I treated AES as a black box - put input data, get scrambled output. I’ve always wondered what happens inside that box, and this article answers that curiosity while giving me a chance to share what I’ve learned.
This article explains how AES works and describes in detail what each operation does. It does not cover the underlying mathematics or the reasoning behind each step. If you’re interested in those details, you’ll need to consult additional resources.
Please do not use the information in this article to build your own production-grade AES implementation. Instead, rely on established libraries that leverage hardware acceleration where possible. The accompanying repository contains sample code that implements AES purely for educational purposes.
AES (Advanced Encryption Standard) is a symmetric encryption algorithm, meaning one key is used for both encrypting and decrypting data. Given plaintext and a key, AES transforms the data into ciphertext that cannot be read without the key, allowing it to be stored or transmitted securely over networks.
The standard replaced DES (Data Encryption Standard) in the early 2000s.
The AES algorithm relies heavily on the exclusive‑OR (XOR, ADD, ⊕) operation because it is fast and computationally inexpensive. Below is a quick reference truth table to refresh your memory.
| x | y | x ⊕ y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
Before we dive in, let’s define the terms that appear throughout this article. I’ll introduce additional concepts as they arise later on.
- Nibble - Four bits (half a byte).
- Byte - Eight bits.
- Word - 32 bits, or four bytes.
- State - A 4×4 matrix of 16 bytes (128‑bit block) that AES manipulates during encryption.
- Cipher() - The core AES routine: it encrypts a 128‑bit plaintext block with provided round keys.
- Plaintext / Plain block - The data segment that is fed into AES for encryption.
- Ciphertext / Cipher block - The scrambled output produced by AES.
- Message encryption - Encrypting an entire message, which may be any length—shorter or longer than 128 bits. I’ll discuss techniques for handling multi‑block messages toward the end of this article.
AES accepts keys of 128, 192, or 256 bits. To encrypt plaintext, it processes the data through a series of rounds. Each round uses a unique key derived from the original key - a process called Key Expansion (or Round Key Generation). We’ll begin by exploring this step first.
Key Expansion Operation
When AES starts an encryption or decryption session, it first expands the user‑supplied key into a sequence of round keys.
The number of rounds, and therefore the number of round keys, depends on the key size:
| Key size | Rounds |
|---|---|
| 128 bits | 10 |
| 192 bits | 12 |
| 256 bits | 14 |
Each round key, is a 128‑bit block. A longer key yields more rounds and stronger encryption.
The Round Key is the 128‑bit word that Cipher() consumes in each round.
The Generated Key is the intermediate block of words produced during the expansion; it has the same number of words as the original key.
Both keys and state matrices are organized into columns of four bytes (one word per column). The first four bytes form the first word, the next four bytes form the second word, and so on.

Figure 1. Organizing bytes in a 4x4 matrix, b denotes a byte, w specifies a word.
The key‑expansion routine builds each round key from the previous one by applying a small set of deterministic transformations.
Below is the process for producing the first word in a new 16-byte block .
- Rotate the last word of the previous block (
w3) left by one byte, this operation is called Rotate Word. - Substitute each byte of the rotated word using the AES S‑box (SubBytes). This masks the original values, more details below.
- Add a round constant. Every round key has its own constant; it is XORed with the substituted word.
- Add that result with the first word of the previous block (
w0).The outcome is the new first word (w4) of the current block.

Figure 2. Deriving first word of the next generated key
In the diagram above we use the Sample128-bitkey string as the 128‑bit input key. Its hexadecimal representation is[53 61 6d 70 6c 65 31 32 38 2d 62 69 74 6b 65 79 ] .
To derive next word, we simply XOR the preceding word(w4) of the current block with the word in the same position from the previous key block (w1).

Figure 3: Deriving second word of a generated key
Below is pseudocode illustrating how the remaining words are derived.
w4 = SubBytes(RotateWord(w3)) ⊕ round_constant[1] ⊕ w0
w5 = w4 ⊕ w1
w6 = w5 ⊕ w2
w7 = w6 ⊕ w3
w8 = SubBytes(RotateWord(w7)) ⊕ round_constant[2] ⊕ w4
w9 = w8 ⊕ w5
w10 = w9 ⊕ w6
w11 = w10 ⊕ w7
w12 = SubBytes(RotateWord(w11)) ⊕ round_constant[3] ⊕ w8
w13 = w12 ⊕ w9
w14 = w13 ⊕ w10
w15 = w14 ⊕ w11
. . .
The RotateWord and XOR operations are straightforward; let’s examine the SubBytes and AddRoundConstant steps in a bit more detail—trust me, they’re not complicated.
Substitute Bytes
The Substitute Bytes (or SubBytes) operation substitutes each byte with a value from the AES S‑Box. The S‑Box is a 16 × 16 lookup table (256 entries), where each cell holds a substitution value.
To find the replacement for a given byte, split it into two nibbles. For example, 0x3E becomes high nibble 0x3 and low nibble 0xE.
The high nibble selects the row index (row = 3, or the fourth row), and the low nibble selects the column index (column = 14, or the 15th column). Looking up that cell returns the substitution value 0xB2.

Figure 4: Substitute-Byte Operation Example
Below is a view of the complete S‑Box table. For additional details, see the Wikipedia entry on the Rijndael S‑Box
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | a | b | c | d | e | f | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 63 | 7c | 77 | 7b | f2 | 6b | 6f | c5 | 30 | 01 | 67 | 2b | fe | d7 | ab |
| 1 | ca | 82 | c9 | 7d | fa | 59 | 47 | f0 | ad | d4 | a2 | af | 9c | a4 | 72 |
| 2 | b7 | fd | 93 | 26 | 36 | 3f | f7 | cc | 34 | a5 | e5 | f1 | 71 | d8 | 31 |
| 3 | 04 | c7 | 23 | c3 | 18 | 96 | 05 | 9a | 07 | 12 | 80 | e2 | eb | 27 | b2 |
| 4 | 09 | 83 | 2c | 1a | 1b | 6e | 5a | a0 | 52 | 3b | d6 | b3 | 29 | e3 | 2f |
| 5 | 53 | d1 | 00 | ed | 20 | fc | b1 | 5b | 6a | cb | be | 39 | 4a | 4c | 58 |
| 6 | d0 | ef | aa | fb | 43 | 4d | 33 | 85 | 45 | f9 | 02 | 7f | 50 | 3c | 9f |
| 7 | 51 | a3 | 40 | 8f | 92 | 9d | 38 | f5 | bc | b6 | da | 21 | 10 | ff | f3 |
| 8 | cd | 0c | 13 | ec | 5f | 97 | 44 | 17 | c4 | a7 | 7e | 3d | 64 | 5d | 19 |
| 9 | 60 | 81 | 4f | dc | 22 | 2a | 90 | 88 | 46 | ee | b8 | 14 | de | 5e | 0b |
| a | e0 | 32 | 3a | 0a | 49 | 06 | 24 | 5c | c2 | d3 | ac | 62 | 91 | 95 | e4 |
| b | e7 | c8 | 37 | 6d | 8d | d5 | 4e | a9 | 6c | 56 | f4 | ea | 65 | 7a | ae |
| c | ba | 78 | 25 | 2e | 1c | a6 | b4 | c6 | e8 | dd | 74 | 1f | 4b | bd | 8b |
| d | 70 | 3e | b5 | 66 | 48 | 03 | f6 | 0e | 61 | 35 | 57 | b9 | 86 | c1 | 1d |
| e | e1 | f8 | 98 | 11 | 69 | d9 | 8e | 94 | 9b | 1e | 87 | e9 | ce | 55 | 28 |
| f | 8c | a1 | 89 | 0d | bf | e6 | 42 | 68 | 41 | 99 | 2d | 0f | b0 | 54 | bb |
Round Constants
A list of 10 round constants is built into AES. Each constant contains one byte that isn’t zero; all other bytes are 00. When a round constant is added to the word, only the first byte in the word changes.
| Round | Constant |
|---|---|
| 1 | 01 00 00 00 |
| 2 | 02 00 00 00 |
| 3 | 04 00 00 00 |
| 4 | 08 00 00 00 |
| 5 | 10 00 00 00 |
| 6 | 20 00 00 00 |
| 7 | 40 00 00 00 |
| 8 | 80 00 00 00 |
| 9 | 1b 00 00 00 |
| 10 | 36 00 00 00 |
When you expand a 128‑bit key, all ten round constants are used, one for each round.
For a 192‑bit key we only need the first eight constants. Eight more keys (each six words long) are generated from the original key, but the round key is 4 word each, so we end up with 12 round keys.
With a 256‑bit key, the first seven constants suffice. Here we generated seven keys (each 8 words long), resulting in 14 round keys.
The diagram below summarizes how key expansion works for 128‑, 192‑, and 256‑bit keys, hopefully it makes the process clearer.

Figure 5 – Key Expansion for 128‑bit, 192‑bit, and 256‑bit Keys
- Gray rectangles show the original key words.
- The orange rectangle marks the first word of a newly generated key. It is produced by rotating the previous word, substituting its bytes, adding a round constant, and adding the corresponding word from the preceding key.
- The teal‑ish rectangles illustrate the simple XOR between a word in the current block and the same‑position word from the prior block.
- For 256‑bit keys an extra step is applied: every fifth word of each generated key undergoes a byte substitution (highlighted in green).
Note: A round key (rk) consists of four words. These four words are supplied to each round of the Cipher() operation, which we will discuss next.
Cipher Operation
The Cipher() operation encrypts data one 128‑bit block (16 bytes) at a time; this block is called the state. It applies a sequence of transformation functions to the state, using the round keys produced by KeyExpansion.
In most cipher modes, the Cipher’s input is simply a 128‑bit chunk extracted from the plaintext. The first step is to add round key 0 (rk₀) to the state, i.e. XOR the state with the original key.

Figure 6 – AES Cipher Algorithm
In every round, except the final one, a fixed sequence of transformations is applied to the state:
- Substitute Bytes
- Shift Rows
- Mix Columns
- Add Round Key
For the last round we drop Mix Columns and perform only:
- Substitute Bytes
- Shift Rows
- Add Round Key (XOR with the final round key)
The Substitute Bytes operation was described earlier; below I’ll briefly explain the remaining functions.
Shift Rows
The ShiftRows() function circularly rotates each of the state’s rows to the left:
- Row 0 remains unchanged.
- Row 1 shifts one byte to the left.
- Row 2 shifts two bytes to the left.
- Row 3 shifts three bytes to the left.

Figure 7: Shift Rows
Mix Columns
The MixColumns() operation processes each column of the state by multiplying it with a fixed 4×4 matrix (in Galois‑field arithmetic).

Figure 8: Column Multiplication with a Fixed Matrix
The multiplication used in MixColumns() differs from the ordinary dot product you learn in linear algebra. For each output byte, you take each byte in the input column and multiply it by a corresponding byte in the corresponding row of the 4×4 matrix. Then you XOR all four products to obtain that output byte. I hope the pseudocode below makes this clearer:
s'[0] = gfMul(s[0], 0x02) ⊕ gfMul(s[1], 0x03) ⊕ gfMul(s[2], 0x01) ⊕ gfMul(s[3], 0x01)
s'[1] = gfMul(s[0], 0x01) ⊕ gfMul(s[1], 0x02) ⊕ gfMul(s[2], 0x03) ⊕ gfMul(s[3], 0x01)
s'[2] = gfMul(s[0], 0x01) ⊕ gfMul(s[1], 0x01) ⊕ gfMul(s[2], 0x02) ⊕ gfMul(s[3], 0x03)
s'[3] = gfMul(s[0], 0x03) ⊕ gfMul(s[1], 0x01) ⊕ gfMul(s[2], 0x01) ⊕ gfMul(s[3], 0x02)
Both the multiplication and addition are GF(2⁸) operations, not ordinary integer arithmetic.
Addition in this field is just XOR, exactly what we’ve been using so far.
Multiplication (gfMul()) involves polynomial arithmetic; for AES the product of any two bytes is always reduced modulo the irreducible polynomial x⁸+x⁴+x³+x+1, which corresponds to the hexadecimal value 0x11B.
The MixColumns step is, by far, the most intricate part of the algorithm.
Add Round Key
The AddRoundKey() operation simply XORs each byte of the state with the corresponding byte of the round key, producing a new state.

Figure 9: Add Round Key Operation
That’s essentially everything involved in the Cipher() operation.
With the ciphertext in hand, decryption is performed by the complementary InvCipher() operation using the same key that was used for encryption.
The Inverse Cipher Operation
The InvCipher() function is the inverse of Cipher(); it applies the same round keys but processes them in reverse order.

Figure 10: AES InvCipher Algorithm
As the diagram shows, every step of InvCipher() is performed in the reverse order of Cipher(). The AddRoundKey operation remains unchanged; we simply XOR with the round key as before. For the other three transformations we use their inverses:
| Operation | Inverse |
|---|---|
| SubBytes | InvSubBytes (uses an inverse S‑Box) |
| ShiftRows | InvShiftRows (shifts in the opposite direction) |
| MixColumns | InvMixColumns (uses a different matrix) |
The inverse S‑Box table can be found here. Similarly, the inverse MixColumns matrix is shown below.

Figure 11: Inverse MixColumns Matrix Multiplication
The InvShiftRows() transformation simply reverses what ShiftRows() does: it shifts each row to the right.
- Row 0 stays unchanged.
- Row 1 shifts one byte to the right.
- Row 2 shifts two bytes to the right.
- Row 3 shifts three bytes to the right.

Figure 12: The Inverse ShiftRows Operation
In Figure 13 I try to consolidate everything we’ve covered so far, giving you an overview of what happens inside AES. Each colored rectangle represents a distinct transformation. In the inverse cipher diagram, notice that all rectangles are mirrored horizontally, reflecting the reversed order of operations in InvCipher().

Figure 13: Rounds for Cipher() and InvCipher()
What remains to be explained is how larger messages are encrypted. I’ll touch on this in the next section, but first note that the material below deals with cipher modes, extensions that use AES as their building block and are not part of the original AES standard itself.
Encrypting long messages
A long message can be split into 16‑byte (128‑bit) blocks; each block can be encrypted independently, and the resulting ciphertext blocks are concatenated, this is how ECB mode works (we’ll discuss it later).
When the plaintext length isn’t a multiple of 16 bytes, two common solutions are:
- Pad the final partial block so that its length becomes exactly 16 bytes.
- Use a cipher mode that does not require padding.
The most widely used padding scheme is PKCS#7, which consist of the following steps:
- Determine how many bytes are needed to fill the last block.
- Append that number of bytes, each byte having the value equal to the number of padding bytes.
For example, if four bytes are required to complete a block, you append 0x04, 0x04, 0x04 , 0x04. If the message already ends on a full block (no padding needed), PKCS#7 still appends an extra block containing sixteen bytes of value 0x10.

Figure 14: PKCS#7 Padding Example
Other padding schemes are available, such as:
- Zero padding – fill the remaining bytes with
0x00. The downside is that if the original data ends in0x00, it becomes ambiguous. For pure ASCII text this usually isn’t a problem. - ISO 10126 / ANSI X9.23 – pad with random bytes, leaving only the last byte to indicate the total number of padding bytes (including itself).
Once the plaintext has been padded, you can encrypt the whole message using a cipher mode of your choice. In the next section I’ll give a quick overview of three common modes to illustrate how they work; many more exist (see this page for more details).
NOTE: Cipher modes are not part of the AES standard itself; they merely employ AES as their underlying block cipher.
ECB - Electronic Codebook is the simplest mode. Encryption consist in:
- Pad the plaintext and split it into 16‑byte blocks.
- Encrypt each block independently with the same key using Cipher().
- Concatenate the ciphertext blocks to form the final message.
During decryption, the process reverses: split the ciphertext into 16‑byte blocks, apply InvCipher() with the same key to each block, concatenate the results, and finally strip off the padding.

Figure 15: ECB Cipher Mode (Encryption & Decryption)
ECB is considered insecure because identical plaintext blocks generate identical ciphertext blocks, exposing statistical patterns. The linked Wikipedia article displays a classic example: an image encrypted with ECB still reveals visible structures from the original.
CBC - Cipher Block Chaining. In this mode every plaintext block is XORed with the preceding ciphertext block before it is fed to Cipher() operation.
For the very first block there is no previous ciphertext, so an IV (Initialization Vector) is used instead. The IV is a 16‑byte block of random or pseudorandom data; it does not have to be secret like the key.
Because each ciphertext block depends on the one that came before it, CBC encryption cannot be parallelized. As with ECB, the message must be padded to a multiple of 16 bytes.

Figure 16: CBC Cipher Mode (Encryption & Decryption)
Because each ciphertext block contains all the data needed to recover its plaintext, CBC decryption can be performed in parallel.
If the IV is missing or corrupted, only the first plaintext block will be wrong; the remaining blocks decrypt correctly.
Since the IV is not secret, it is usually prefixed to the ciphertext. During decryption the routine reads the first 16 bytes as the IV and then processes the rest of the data.
CTR - Counter Mode. In CTR mode each plaintext block is processed with a different input to the block cipher: The input can simply be an IV that’s incremented for every block, or it can consist of two parts:a random nonce (the first half) and an incrementing counter (the second half). Then encrypt the input with the Cipher() operation. Lastly, XOR the resulting keystream block with the plaintext block to produce ciphertext.
Because every counter value is independent, CTR encryption and decryption can be fully parallelized.

Figure 17: CTR Cipher Mode (Encryption & Decryption)
A key point: CTR decryption does not require InvCipher(). To recover plaintext you reuse the same nonce/counter values, encrypt them in exactly the same way as during encryption, and XOR the result with the ciphertext block.
As noted earlier, many other modes exist. For instance, GCM (Galois/Counter Mode) extends CTR by adding authentication tags and extra processing to provide both confidentiality and integrity.
A few words about key generation, you can come up with your own key, or use a Key Derivation Function (KDF).
The most common KDF for passwords is PBKDF2. It takes a human‑readable password or passphrase, optionally coupled with a salt (a random byte string), and applies many iterations of a hash function to produce a cryptographically strong key.
Because the same password and salt always produce the same key, both the password/salt pair and the derived key must be treated as secrets and stored securely.
Summary
AES is essentially a black‑box block cipher: you feed it a key and a 128‑bit plaintext block, and it returns an unintelligible 128‑bit ciphertext block.
Inside that box, the key is first expanded into several round keys; the Cipher() routine then applies a sequence of transformations using each round key to produce the ciphertext. The inverse process, InvCipher(), reverses those steps to recover the original plaintext.
That’s all there is to AES, encrypting and decrypting fixed‑size blocks of 128 bits.
To handle longer messages we discussed padding schemes, three block‑cipher modes (ECB, CBC, CTR), and that you can generate a secure key with PBKDF2.
I hope you found this article as enjoyable to read as it was for me to write. As always, use approved standard libraries for encryption and store your keys securely.

