Jake's blog

Symmetric Encryption in Javascript

private keyprivate key
Author: Jake Avery

The purpose of encryption is to modify data so that it becomes unintelligible, while preserving a way to reverse (decrypt) the modification. We use this every day to protect personal records, Wi-Fi networks, biometric data, private messages, and password managers.

Symmetric encryption gets its name because it uses the same key to encrypt and decrypt. This can be contrasted with asymmetric encryption. Since we normally learn symmetric encryption first, we will go over a brief encryption overview before covering symmetric-specific details.

Ciphers 🔤

Encryption almost always includes a cipher. A cipher systematically defines the rules for how to transform data. Below you can see an example of a Caesar cipher, which works by swapping letters up or down x number of steps. The output (ciphertext) is unintelligible, but someone who knows the number of steps would be able to decrypt the message.

ABCDEFGHIJKLMNOPQRSTUVWXYZ
DEFGHIJKLMNOPQRSTUVWXYZABC

Shift +3

KHOOR ZRUOG

Binary data 💾

The beauty of data in a digital paradigm is that everything is encoded in binary, which is easily converted into number values. This means that we can use mathematical functions and calculations in our encryption algorithms.

Below you can see an example of a message encoded in UTF-8. It can also be represented in binary, decimal, hex and base64. You will be able to modify the data by applying any combination of mathematical operators (+, -, x, ÷). This will encrypt the data. Continuing, you can try to inverse your operators to decrypt the ciphertext.

83  101  99  114  101  116

Encrypt by applying math operations to each byte

+ Add
- Subtract
x Multiply
÷ Divide

Ciphertext (encrypted data)

86  104  102  117  104  119
UTF-8 text
Vhfuhw

Decrypt: can you reverse it?

+ Add
- Subtract
x Multiply
÷ Divide

Decrypted data

86  104  102  117  104  119
UTF-8 text
Vhfuhw

As you can see, with some guess work, you can reverse the encryption. Modern encryption replaces human-readable math with operations like XOR, bit shifting, and substitution-permutation networks. These methods are less intuitive for humans, but they're fast for computers and produce outputs with maximum entropy, making patterns nearly impossible to detect.

Encryption algorithms have evolved over the years to become harder for attackers to decrypt. Many have also become more efficient, which makes them faster and less power hungry.

AES 🔐

The Advanced Encryption Standard (AES), developed in the 1990s by Joan Daemen and Vincent Rijmen, has become one of the most popular specifications for symmetric encryption. It is both fast and secure. It works by dividing a payload into equal blocks (128, 192, or 256 bits), then applying a random stream of data, called a key, to perform a series of operations. The key provides the algorithm with the information it needs to both encrypt and decrypt. Because the same key is used for both operations, all AES modes are classified as symmetric encryption.

In the Javascript crypto.subtle library, we pass three parameters to encrypt and decrypt: algorithm, key, and data. The algorithm is comprised of an object that accepts the name of the mode to use, along with other mode-specific values. The key is a CryptoKey that can be generated using crypto.subtle.generateKey. The data is an ArrayBuffer, TypedArray, or DataView. See Mozilla docs.

The crypto.subtle library has implemented three AES modes of operation: Cipher Block Chaining Mode (CBC), Counter Mode (CTR), and Galois/Counter Mode (GCM). These modes are oftentimes referred to as algorithms. We will go through each of them individually, but the TLDR is "use GCM."

CBC ⛓️

Cipher Block Chaining (CBC) mode is one of the oldest digital encryption methods, dating back to the Data Encryption Standard (DES) in the 1970s. It uses both a key and an initialization vector (IV) to scramble the first block of data into ciphertext. Then it takes the key, previous ciphertext and the next chunk of plaintext to create each successive block of ciphertext. It's called a chain because each subsequent block depends on the previous ciphertext.

IVPlaintext 1⊕ XORAES (key)Ciphertext 1Plaintext 2⊕ XORAES (key)Ciphertext 2Plaintext 3pad⊕ XORAES (key)Ciphertext 3

Because the algorithm requires a full block for every round, it will add padding to the final ciphertext. Below you can watch the chain form: type a message and each block is XOR'd with the previous ciphertext (or the IV for the first block) before encryption. The highlighted bytes at the end are PKCS#7 padding, where each padding byte holds the number of padding bytes added.

Encrypting…

Below is an example of how to encrypt and decrypt with CBC that you can copy and paste directly into your browser: See Mozilla docs.

const startingText = "the og block chain";

const key = await crypto.subtle.generateKey(
  { name: "aes-cbc", length: 256 },
  false,
  ["encrypt", "decrypt"],
);

// CBC uses a 128-bit (16 byte) IV, the same size as the AES block size.
// The IV XORs with the first plaintext block before encryption, which
// randomizes the output even if you encrypt the same message twice.
//
// Rules:
//   - Must be unique per encryption (never reuse with the same key)
//   - Does NOT need to be secret; it's typically sent alongside the ciphertext
//   - Randomizing all 16 bytes is the safest and simplest approach
const iv = crypto.getRandomValues(new Uint8Array(16));
console.log("IV: " + iv);

const ciphertext = await crypto.subtle.encrypt(
  {
    name: "aes-cbc",
    iv,
  },
  key,
  new TextEncoder().encode(startingText),
);

// WebCrypto handles PKCS#7 padding automatically under the hood.
// Since "the og block chain" is 18 bytes, it spills into a second 16-byte block,
// so 14 bytes of 0x0E are appended to fill it out, making the ciphertext 32 bytes.
// ciphertext.byteLength will always be a multiple of 16.
console.log("Encrypted message: " + new Uint8Array(ciphertext));
console.log(
  "Ciphertext length (should be multiple of 16): " + ciphertext.byteLength,
);

const plaintext = await crypto.subtle.decrypt(
  {
    name: "aes-cbc",
    iv: iv, // must be the same IV used to encrypt
  },
  key,
  ciphertext,
);

// WebCrypto strips the PKCS#7 padding automatically on decrypt.
// However, note: unlike GCM, CBC has NO authentication. If the ciphertext
// is tampered with, decrypt() may still succeed and return garbage plaintext.
// In production, always pair CBC with a MAC (e.g. HMAC-SHA256) to detect tampering.
console.log("Decrypted message: " + new TextDecoder().decode(plaintext));

Unfortunately, the padding required for the CBC algorithm has been a vector for exploitation. The activity below demonstrates the AES-CBC Padding Oracle Vulnerability, which works whenever a server leaks whether the padding was valid with a distinct error code, or simply by answering faster when a malformed payload fails before it reaches the credential check. That single bit of leaked information is enough to recover the plaintext one byte at a time, and then to forge an IV that changes the value the server sees, all without ever learning the key.

Starting server…

While the padding exploit is what makes it possible to retrieve plaintext, the property that makes forgery possible is called malleability. A cipher is malleable when editing the ciphertext changes the decrypted plaintext in a predictable way, and CBC is malleable because every block is XOR'd with the previous one. On top of that, CBC alone offers no integrity, so the server has no way to notice that the IV it received is not the one it issued. Malleability is the same weakness found in plain CTR, and it's what GCM ultimately fixes.

CTR 🔢

Counter Mode requires an initial count and a unique nonce. The count increments for each block of data that is encrypted and the nonce ensures sufficient entropy. This means the same byte of data will look different every time it is encrypted. Without a unique nonce, two messages end up encrypted with the same keystream, and XOR'ing their ciphertexts together cancels the keystream out, leaving the two plaintexts XOR'd with each other.

Because the counter is incremented predictably, rather than relying on the previous ciphertext, CTR can be parallelized, which makes it extremely fast.

Nonce‖0AES (key)keystreamPlaintext 1⊕ XORCiphertext 1Nonce‖1AES (key)keystreamPlaintext 2⊕ XORCiphertext 2Nonce‖2AES (key)keystreamPlaintext 3⊕ XORCiphertext 3no chaining: blocks run in parallel

In crypto.subtle the initial count and nonce are combined in the same field, commonly referred to as the counter. A length field determines how many of the low bits of the counter are delegated to initialize the count, while the rest will be delegated to the nonce. In a typical configuration, you will see a 16-byte (128-bit) counter, with a length of 64. So half the bits will be the nonce and the other half will serve as the initial count. See Mozilla docs.

const startingText = "ctr 4 life";

const key = await crypto.subtle.generateKey(
  { name: "aes-ctr", length: 256 },
  false,
  ["encrypt", "decrypt"],
);

//    bytes index 0-7  → random nonce   (64 bits, never changes)
//    bytes index 8-15 → counter = 0    (64 bits, increments per block)
const counterBlock = new Uint8Array(16); // 0000000000000000
crypto.getRandomValues(counterBlock.subarray(0, 8)); // make the nonce segment random
console.log("Counter block: " + counterBlock);

// In real-world applications, it's simpler to randomize the whole counter block because the counter can start anywhere
// const counterBlock = crypto.getRandomValues(new Uint8Array(16));

const ciphertext = await crypto.subtle.encrypt(
  {
    name: "aes-ctr",
    counter: counterBlock,
    length: 64, // lower 64 bits are the counter, starting at 0
  },
  key,
  new TextEncoder().encode(startingText),
);

console.log("Encrypted message: " + new Uint8Array(ciphertext));

const plaintext = await crypto.subtle.decrypt(
  {
    name: "aes-ctr",
    counter: counterBlock,
    length: 64,
  },
  key,
  ciphertext,
);

console.log("Decrypted message: " + new TextDecoder().decode(plaintext));

Like CBC, CTR comes with the drawback of malleability because it always generates a keystream and XORs with the plaintext. Editing a single byte in CTR plaintext or ciphertext systematically changes its corresponding text, and there's no way for the decrypting party to verify whether the ciphertext was tampered with. These disadvantages apply even when using a unique key and a unique nonce for every payload. modified.

In the following demo, you can practice bit flipping ciphertext to see if you can change the amount in a pretend bank transaction. It's possible to do, even with the key rotating on every request.

AES-CTR Malleability Vulnerability

Let's suppose a bad actor is intercepting web traffic to and from a banking app. The hacker can transfer money between two of their own accounts to learn the bank's encryption. In this example the hacker has discovered the payload for sending $10 is {"amount":"10"}. Even though the attacker can only see encrypted ciphertext, with AES-CTR it's still possible to modify the ciphertext in a predictable way. Try to see if you can find a way to modify the cipher text to change the amount of the transfer.

Click on the bits to flip them. Hit send to see the bank's API response to your request. After processing the transaction, the encryption key will rotate.

Intercepted ciphertext

0e0·
185·
267g
3cf·
457W
50f·
6377
793·
814·
9e7·
102c,
117f·
1274t
130f·
1449I
Bank's response will appear here

While no encryption is technically uncrackable, the fact that both CBC and CTR are malleable makes them problematic without providing additional security measures. GCM, which will be discussed next, solves malleability entirely. However, in systems that do not support GCM or where the cost of upgrading to GCM is high, the specific vulnerabilities discussed can still be avoided by implementing normal security measures like random timeouts on every decryption request, displaying the same error for a malformed and incorrect ciphertext, and enforcing maximum attempts.

GCM 🛡️

Galois/Counter Mode is an upgrade to CTR, so it possesses many of the advantages of CTR, like parallelization and no padding vulnerabilities. What makes it special is its Authenticated Encryption with Associated Data (AEAD). It does this by running a GHASH computation over associated data (AD/AAD) and each ciphertext block in parallel with the CTR encryption stream. Once both streams complete, their results are combined to produce an authentication tag, which is appended to the ciphertext. The final output provides confidentiality, integrity, and authenticity, eliminating the malleability inherent in plain CTR mode.

Assoc. dataNonce‖1AES (key)keystreamPlaintext 1⊕ XORCiphertext 1GHASHNonce‖2AES (key)keystreamPlaintext 2⊕ XORCiphertext 2GHASHAuth tagoutput is ciphertext ‖ tag

In crypto.subtle, GCM requires an initialization vector iv, which should be random and can never be repeated with the same key, the tagLength (defaults to 128, which is the recommended amount) that sets the size of the authentication tag, and, optionally but highly recommended, additionalData. See Mozilla docs.

additionalData is the "Associated Data" in AEAD. It never gets encrypted and it never shows up in the ciphertext. Instead it feeds the GHASH computation, which means the auth tag depends on it. Usually it holds metadata like a message ID or a row version, and its job is to tie the ciphertext to the record it came from. That alone stops an attacker from lifting a valid payload out of one user's account and dropping it into another's. Encrypt and decrypt have to pass the same bytes in additionalData or decrypt throws.

const startingText = "Galois/Counter Mode";

const key = await crypto.subtle.generateKey(
  { name: "aes-gcm", length: 256 },
  false,
  ["encrypt", "decrypt"],
);

// GCM uses a 96-bit (12 byte) IV, which is the NIST-recommended size.
// The IV just needs to be unique per encryption. Never reuse it with the same key.
//
//    bytes 0-11 → random IV (96 bits)
//
// The simplest and safest approach is to randomize all 12 bytes:
const iv = crypto.getRandomValues(new Uint8Array(12));
console.log("IV: " + iv);

// GCM also supports "Additional Authenticated Data" (AAD), plaintext metadata
// that gets authenticated but NOT encrypted (e.g. a header, user ID, message ID).
// If the AAD doesn't match on decrypt, it will throw an error.
// This is optional. Omit the additionalData field if you don't need it.
const additionalData = new TextEncoder().encode("message-id:42");

const ciphertext = await crypto.subtle.encrypt(
  {
    name: "aes-gcm",
    iv,
    additionalData, // optional but highly recommended
    tagLength: 128, // auth tag size in bits (128 is the default and recommended)
  },
  key,
  new TextEncoder().encode(startingText),
);

// Note: WebCrypto appends the 16-byte authentication tag to the end of the ciphertext.
// So ciphertext.byteLength = plaintext.byteLength + 16
console.log(
  "Encrypted message (ciphertext + auth tag): " + new Uint8Array(ciphertext),
);

const plaintext = await crypto.subtle.decrypt(
  {
    name: "aes-gcm",
    iv, // must be the same IV used to encrypt
    additionalData, // must match exactly, or decryption throws
    tagLength: 128,
  },
  key,
  ciphertext,
);

// If the auth tag verification fails (tampered ciphertext or wrong AAD),
// decrypt() throws a DOMException. It never returns corrupted plaintext.
console.log("Decrypted message: " + new TextDecoder().decode(plaintext));

In GCM, decrypting tampered ciphertext will throw an error, which is a good thing. Decryption runs the same GHASH calculation over the ciphertext, the additional authenticated data and their lengths, mixes in a value that only the key can produce, and checks the result against the auth tag that arrived with the message. Any change to the ciphertext will not produce the same auth tag, which triggers the error. An attacker cannot re-patch the tag to cover their edit, because computing a valid one requires the key. Below you can see how this error behavior compares to the other algorithms we reviewed.

Tamper detection: CBC vs CTR vs GCM

The message below is encrypted three times with the same key, once in each mode. Flip any bit of the ciphertext and it is flipped in all three at the same index. CBC and CTR will hand back "plaintext"; GCM refuses.

Encrypting…

GCM's main issues come when it is used improperly. As few as one reused IV with the same key can enable forging. Auth tags less than 64 bits long can technically be brute-forced, so many systems have stopped allowing authentication tags shorter than 96 bits. As with other Message Authentication Codes (MACs) and AEAD schemes, forgery becomes more probable as more data is encrypted with the same key, but these limitations do not emerge until encrypting several terabytes of data or performing over 4 billion encryption operations.

The IV reuse case is worth seeing directly because it is the easiest mistake to make. Underneath the authentication, GCM is still CTR, so two messages encrypted with the same IV and key can be XOR'd together. The ciphertexts cancel each other out and what's left is the two plaintexts XOR'd together. Below, toggle the IV reuse off and on to watch the second message fall out of the ciphertext.

GCM IV reuse

GCM builds a keystream from the key and the IV alone. The plaintext never touches it. Encrypt two messages under the same key and the same IV and both get the same keystream, so XOR'ing the two ciphertexts cancels it out completely: C₁ ⊕ C₂ = P₁ ⊕ P₂. An attacker who knows (or guesses) one message reads the other, and never needs the key.

Encrypting…

Confidentiality is only the first casualty. The same repeated keystream also leaks GCM's authentication subkey through the pair of tags, which lets an attacker forge valid tags to send new messages, completely ruining the promise of non-malleability. Moral of the story: always use a unique IV.

While GCM is natively available in Javascript and can be sufficient, there are more symmetric modes made possible through libraries like ChaCha20-Poly1305, XChaCha20-Poly1305, and AES-GCM-SIV.

Conclusion 🏁

Symmetric encryption provides a foundation for other concepts that make modern cryptography possible. Its advantages include its relative simplicity and inexpensive computation, while its weakness is the need to securely transfer the same key to the points where data needs to be encrypted and decrypted. Cryptographers have come up with many ways around this limitation, but that is for another article.