Guide to Web Crypto API for encryption/decryption

Tony
6 min readMay 15, 2023

--

Depending on your environment and requirements, the Web Crypto API may be needed required or suitable to perform cryptographic operations. For example, it is commonly used with Edge Middleware and Edge functions when working with Next.js + Vercel. In this guide, we explore briefly how we can use the Web Crypto API to perform (symmetric) encryption/decryption operations to secure data for your applications.

To begin, we need to understand the concept of symmetric encryption.

Symmetric Encryption

When people talk about “encryption,” they tend to mean symmetric encryption which is useful for encrypting text into a random string of characters. A common scenario where this is relevant is encrypting user data on a server so that it’s stored “encrypted at rest” in a database.

In layman terms, symmetric encryption is when you take the text you want to encrypt (called the plaintext) and use a secret key with an encryption algorithm to output the encrypted text (called the ciphertext). The operation is reversible and so decryption is when we can use the same secret with the plaintext.

Symmetric encryption

Looks easy right?

Unfortunately, when it comes time to implement symmetric encryption, developers get it wrong all the time oftentimes because there’re a lot to understand:

  • Encoding formats: Data can be encoded/decoded in many ways like base64 , hex , etc. These different representations often confuse developers when converting from one format to another.
  • Algorithms and configuration: There are many encryption algorithms to consider from like aes-256-gcm or aes-256-cbc , each with their own requirements.
  • Randomness: Keys used in encryption procedures should be generated randomly to ensure high entropy. Often times, developers think they’re generating sufficiently-random keys but they’re not.
  • Complexity: Encryption in Node.js involves a few steps that aren’t always intuitive to developers and some concepts are genuinely puzzling at first. Take, for instance, the concept of an initialization vector (IV); developers new to cryptography often re-use these across their encryption processes and this is a big no-no.

Anyways, in this article I won’t cover the above nuances since they’re more crypto-heavy (we’ll save this discussion for another time) but focus more on how we can perform encryption correctly with the Web Crypto API by example.

The best way to explain how to correctly perform the encryption is to demonstrate a proper implementation using the aes-256-gcm algorithm. Let’s start with encryption.

Encryption

export const encryptSymmetric = async (plaintext: string, key: string) => {
// create a random 96-bit initialization vector (IV)
const iv = crypto.getRandomValues(new Uint8Array(12));

// encode the text you want to encrypt
const encodedPlaintext = new TextEncoder().encode(plaintext);

// prepare the secret key for encryption
const secretKey = await crypto.subtle.importKey('raw', Buffer.from(key, 'base64'), {
name: 'AES-GCM',
length: 256
}, true, ['encrypt', 'decrypt']);

// encrypt the text with the secret key
const ciphertext = await crypto.subtle.encrypt({
name: 'AES-GCM',
iv
}, secretKey, encodedPlaintext);

// return the encrypted text "ciphertext" and the IV
// encoded in base64
return ({
ciphertext: Buffer.from(ciphertext).toString('base64'),
iv: Buffer.from(iv).toString('base64')
});
}

// some plaintext you want to encrypt
const plaintext = 'The quick brown fox jumps over the lazy dog';

// create or bring your own base64-encoded encryption key
const key = Buffer.from(
crypto.getRandomValues(new Uint8Array(32))
).toString('base64');

// encryption
const {
ciphertext,
iv
} = await encryptSymmetric(plaintext, key);

To perform the encryption, we need two items:

  • plaintext: The text that you want to encrypt.
  • key: A base64, 256-bit encryption key.

We get the following outputs from the encryption:

  • ciphertext: The encrypted text.
  • iv: A piece of data generated during the encryption process to help verify that the encrypted text was not tampered with later during the decryption process.

Let’s walk through the code:

// some plaintext you want to encrypt
const plaintext = 'The quick brown fox jumps over the lazy dog';

// create or bring your own base64-encoded encryption key
const key = Buffer.from(
crypto.getRandomValues(new Uint8Array(32))
).toString('base64');

Here we define the input variables needed to perform the encryption. Beyond the text that you want to encrypt, you need to generate a key that is 256-bits long. It is important to generate these items randomly to ensure greater security via higher entropy. Note that I encode everything in base64 format since its easy to store conceptually.

Next, let’s dissect the encryption function:

export const encryptSymmetric = async (plaintext: string, key: string) => {
// create a random 96-bit initialization vector (IV)
const iv = crypto.getRandomValues(new Uint8Array(12));

// encode the text you want to encrypt
const encodedPlaintext = new TextEncoder().encode(plaintext);

// prepare the secret key for encryption
const secretKey = await crypto.subtle.importKey('raw', Buffer.from(key, 'base64'), {
name: 'AES-GCM',
length: 256
}, true, ['encrypt', 'decrypt']);

// encrypt the text with the secret key
const ciphertext = await crypto.subtle.encrypt({
name: 'AES-GCM',
iv
}, secretKey, encodedPlaintext);

// return the encrypted text "ciphertext" and the IV
// encoded in base64
return ({
ciphertext: Buffer.from(ciphertext).toString('base64'),
iv: Buffer.from(iv).toString('base64')
});
}

Here, the encryption function creates a new initialization vector iv. The next part encodes the plaintext you want to encrypt into a Uint8Array and prepares a secretKey from the key you passed in to be used for encryption. Finally, the encryption is performed using the aes-256-gcm algorithm to produce the encrypted text ciphertext .

The last thing you should know about encryption is how to handle the data. In the context of encrypting user data and storing it at rest, you’d want to store the encrypted data ciphertext and iv in the database and keep the secret key stored somewhere else securely such as as an environment variable on the server or better yet in a a dedicated secret manager like Infisical. When you want to retrieve the data, you can query for it from the database and decrypt it using the secret key.

Speaking of which, let’s dive into decryption. As before, let’s start with a proper implementation.

Decryption

export const decryptSymmetric = async (ciphertext: string, iv: string, key: string) => {
// prepare the secret key
const secretKey = await crypto.subtle.importKey(
'raw',
Buffer.from(key, 'base64'),
{
name: 'AES-GCM',
length: 256
}, true, ['encrypt', 'decrypt']);

// decrypt the encrypted text "ciphertext" with the secret key and IV
const cleartext = await crypto.subtle.decrypt({
name: 'AES-GCM',
iv: Buffer.from(iv, 'base64'),
}, secretKey, Buffer.from(ciphertext, 'base64'));

// decode the text and return it
return new TextDecoder().decode(cleartext);
}

// decryption
const plaintext = await decryptSymmetric(ciphertext, iv, key);

To perform the decryption, we need three items:

  • key: The base64, 256-bit encryption key used to encrypt the original text.
  • ciphertext: The encrypted text that you want to decrypt.
  • iv: The 96-bit initialization vector generated and returned during the encryption.

We get the following outputs from the encryption:

  • plaintext: The original text that we encrypted.

Let’s walk through the decryption function:

export const decryptSymmetric = async (ciphertext: string, iv: string, key: string) => {
// prepare the secret key
const secretKey = await crypto.subtle.importKey(
'raw',
Buffer.from(key, 'base64'),
{
name: 'AES-GCM',
length: 256
}, true, ['encrypt', 'decrypt']);

// decrypt the encrypted text "ciphertext" with the secret key and IV
const cleartext = await crypto.subtle.decrypt({
name: 'AES-GCM',
iv: Buffer.from(iv, 'base64'),
}, secretKey, Buffer.from(ciphertext, 'base64'));

// decode the text and return it
return new TextDecoder().decode(cleartext);
}

Here, the decryption function prepares the secretKey from the passed in key to be used for decryption and performs the decryption to obtain the orginal text cleartext . Since its encoded, we return the decoded result which should be the original string.

Horray!

While this all sounds complicated, I would encourage you to take some time to understand it and define helper functions for encryptSymmetric and decryptSymmetric like above so you can reuse them consistently across your codebase.

Lastly, your data is only as secure as the keys used to encrypt the data. As such, I strongly recommend securely storing encryption keys and accessing them in your application as environment variables or fetching them back from a secret manager like Infisical at runtime. This is simple to set up and worthwhile to learn.

Infisical

Resources

Encryption/decryption can be a tricky subject, especially for folks with limited prior cryptography experience. So, before we part ways, I wanted to leave you with some resources:

  • The Web Crypto API Reference
  • If you’re using Vercel, you may find their example useful (it covers similar content to this article).
  • Crypto 101: A great introductory book about cryptography for folks who want to dive deeper.

--

--