The Web Cryptography API in Action

Learn the basics of encryption, signing, and hashing with the Web Cryptography API

Nieky Allen
Slalom Build
8 min readJan 30, 2023

--

Cryptography is at the core of many applications and security practices that we take for granted. Whether someone is wiring money to a bank account or signing up for a website, it’s needed in different forms to keep information, money, and even people safe. Browsers often fall short and require developers to be proactive in securing their applications. By using the Web Cryptography API, you can leverage powerful and standardized tooling across your users’ devices to keep your application and their data safe.

To learn more about the details and benefits of the Web Crypto API, check out the previous article, Unlocking the Web Cryptography API.

The following are some basic examples that you can try implementing yourself to get a feel for common cryptography operations. For a closer look at the examples, check them out here.

Setup

Before we start manipulating data, I find it is helpful to centralize our cryptographic operations into a single reference that will work regardless of the browser.

// initialize SubtleCrypto and fallback to webkit instance
const operations = window.crypto.subtle || window.crypto.webkitSubtle;

// if Web Crypto or SubtleCrypto is not supported, notify the user
if (!operations) {
alert('Web Crypto is not supported on this browser');
console.warn('Web Crypto API not supported');
}

As you can see, we do a conditional check to see if operations was defined on the first attempt and fallback to use webkitSubtle if not. This is because browsers that use the WebKit browser engine, most notably Safari, expose these methods on this alternative property. If neither of these are defined, then we want to notify the user necessary features are not supported. (This is also a good use case for a polyfill.)

Encryption & decryption

Encryption & decryption patterns can be used to make information unrecognizable and only retrievable with a password or more accurately, a key. There are various kinds of encryption approaches and patterns that can be implemented. The following is an instance of what’s called symmetric encryption, where a single key is used for both encryption and decryption.

const ALGO_NAME = 'AES-GCM';
const iv = window.crypto.getRandomValues(new Uint8Array(12));

const superSecret = 'password';

(async () => {
const key = await operations.generateKey(
{ name: ALGO_NAME, length: 256 },
false, // defines key as extractable, use false b/c we do not need to store it
['encrypt', 'decrypt']
);

// input must be encoded to a buffer or TypedArray
const encoder = new TextEncoder();
const superSecretEncoded = encoder.encode(superSecret);

const encryptedData = await operations.encrypt(
{ name: ALGO_NAME, iv },
key,
superSecretEncoded
);

...
})();

We start by defining some constants and generating an initialization vector using another helpful tool from Web Crypto: random value generation. A key is then created for the chosen algorithm and bit length. Since we don’t want to allow anyone to extract or export this key to be transferred or stored, we pass false as the second parameter. The final parameter defines what operations this key can be used for. We will use different values here throughout our examples.

Notice after key generation a TextEncoder is implemented, this is because both the encrypt and decrypt methods require data to be passed as an ArrayBuffer or TypedArray. After encrypt completes its operations, our encryptedData value is safe for storage or transit and can only be revealed using our generated key.

In order to decrypt the payload, we can do the following:

(async () => {
...

const decryptedData = await operations.decrypt(
{
name: ALGO_NAME,
iv,
},
key,
encryptedData
);

const decoder = new TextDecoder();
const superSecretDecrypted = decoder.decode(decryptedData);

if (superSecret === superSecretDecrypted) {
const msg = 'Encryption & decryption successful!';
console.log(msg);
alert(msg);
} else {
console.error('Decryption failed');
alert('Input and output do not match!');
}
})();

Again, note that the output from the decrypt method now requires decoding in order to be returned to its original state. In this case, that would be in the form of a human readable string. As a developer, it’s important to consider what encoding/decoding scheme is best for you depending on the data you are handling.

Services like banking and healthcare rely heavily on encryption to keep data safe and secret. There could be dire consequences if a system that manages your health or financial records does not practice good key storage, generation, and rotation practices. Encrypting on the client, browser or otherwise, could be beneficial to help protect a cache of sensitive data or ensure network requests are masked even if the traffic is sniffed.

Signing & verification

Most signing & verification patterns developers encounter center around API authentication. The following example shows how you could go about signing and verifying a small data object containing user data. We are going to use our same operations interface that we defined in our “Setup” step.

const ALGO_NAME = 'ECDSA';
const ALGO_OPTIONS = {
name: ALGO_NAME,
hash: { name: 'SHA-384' },
};

const userId = window.crypto.randomUUID();
const userInfo = {
id: userId,
lastLogin: Date.now(),
};

(async () => {
const { privateKey, publicKey } = await operations.generateKey(
{
name: ALGO_NAME,
namedCurve: 'P-384',
},
false, // defines key as extractable, use false b/c we do not need to store it
['sign', 'verify']
);

// input must be stringified and encoded to a buffer or TypedArray
const dataString = JSON.stringify(userInfo);
const encoder = new TextEncoder();
const encodedData = encoder.encode(dataString);

const signature = await operations.sign(
ALGO_OPTIONS,
privateKey,
encodedData
);

...
})();

To help aid in our user data generation, we use Web Crypto’s randomUUID method to create a v4 identifier. Universally Unique Identifier (UUID) generation is very common for creating IDs for users, posts, and the like, but implementations commonly lean on the uuid NPM package which receives over 73 million downloads a week. Also while randomUUID is part of Web Crypto and was not supported on the Node.js Web Crypto interface until v19, the native crypto module has had this functionality since v14.

The next thing you may notice about this implementation is that the generateKey method returns two keys instead of one, this is determined by the algorithm options you pass as the first parameter during invocation. When signing data we always use the private key and during verification we use the public key. As in the encryption invocation, we do not want these keys to be extractable. Also remember that while this example represents an instance of signing a small amount of data, data of any kind or size can be signed and verified.

Below, we use the publicKey from our key pair, the derived signature, and the original encoded version of the data to generate a simple boolean value reflecting the signature’s validity.

(async () => {
...

const isValid = await operations.verify(
ALGO_OPTIONS,
publicKey,
signature,
encodedData
);

if (isValid) {
const msg = 'Signing & verification successful!';
console.log(msg);
alert(msg);
} else {
console.error('Verification failed');
alert('Signature was invalid!');
}
})();

In a real world use case, you would probably want to separate the public and private keys during storage to bolster security. It’s almost always considered best practice to only give an execution context access to the least amount of data and operations required to accomplish its goal. In this case, if you only ever need to verify on the client, you should never need to store the private key in the browser.

A more complex application of signing and verifying has become increasingly common with remote working. Electronic signatures, or e-signatures, have grown to be a go-to way to “sign” legal documents. The security patterns implemented during this process are what make it safe enough to consider these documents legally-binding.

Hashing (Digest)

Generating a hash—or message “digest”—of data is another way to help ensure the integrity of said data. Using a hashing algorithm, one can compute a universally unique string of bits, commonly converted to hexadecimal, that represent the provided data input and nothing else. A hashing algorithm is considered compromised when two different pieces of data can result in the same hash. An essential part of digest computation is that even a very minor change to the data should result in a drastically different hash. We are going to use our operations interface again to try out hashing a couple passwords.

const ALGO_NAME = 'SHA-256';
const passwordInput = 'sihtssuegrevenlluoy';

function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

// input must be encoded to a buffer or TypedArray
const encoder = new TextEncoder();

(async () => {
const passwordInputEncoded = encoder.encode(passwordInput);

const hashBuffer = await operations.digest(
ALGO_NAME,
passwordInputEncoded
);

const hashString = bufferToHex(hashBuffer);

console.log(hashString);
// 6251e35fc6921d4b2362de4c0967cfb0bf472ea8f52f019b680615cdd3eb30de
...
})();

Like the previous use cases, we still need to encode data before it is passed to our digest method. While we don’t need a key, we do have to manually convert the buffer result to a hexadecimal string as seen in the bufferToHex method. The approach used is universally applicable to buffers as it converts each byte string to base-16 and pads with a 0 when the value is less than 16.

(async () => {
...
// input must be encoded to a buffer or TypedArray
const augmentedPasswordInputEncoded = encoder.encode(
passwordInput + '1'
);

const augmentedHashBuffer = await operations.digest(
ALGO_NAME,
augmentedPasswordInputEncoded
);

const augmentedHashString = bufferToHex(augmentedHashBuffer);

console.log(augmentedHashString);
// e439337d0da29330902be91cd69e2534b817ec571007b1ca527c6b0a9f33d153
})();

As you can see, when using the SHA-256 algorithm, very different values are generated from the two inputs despite only one character being added in the second invocation.

Hashing is very common with password validation and storage. With browser-based hashing and random value generation, you could both salt and hash your credentials before they ever leave the client. This would render the signatures even harder to reverse engineer if the authentication system was ever compromised because the salt values were never on the server.

Integrity checks using hashing are also commonly used when you download new software from the web. After the download is complete, the application download contents are often hashed and compared with the expected signature provided by the application vendor. If the digest of the downloaded software matches the expected value, then it can be confirmed it was not compromised by a malicious actor.

Future considerations

Understanding the basic implementations of encryption, signing, and hashing are a great start to being able to leverage the Web Cryptography API in a meaningful way. There are a variety of other things to think about when designing your cryptographic solutions. Where and how you store this data and the keys used to secure it becomes something else to consider and requires its own research. Maybe one algorithm for encryption is better for your needs than the others. Regardless, it’s important to remember that no system is impenetrable and it is our job as developers to design implementations that are not only functional but secure as well.

--

--

Nieky Allen
Slalom Build

Chicago-based software architect & web enthusiast | NodeJS, TypeScript | https://github.com/dallen4