Secure Secrets: Client-Side Encryption with the Web Crypto API in React js

by iamvkr on

Table of contents

🔐 Encrypting Stuff in React with the Web Crypto API

Ever wanted to hide some sensitive info in your React app? Maybe you’re building a password manager, or just trying to keep your super-secret to-do list encrypted from snooping eyes. Whatever your reason, let’s talk about how you can use the Web Crypto API to do that – all inside a friendly React app.

🌐 First Off, What Is the Web Crypto API?

The Web Crypto API is like your browser’s built-in security toolkit. It lets you do cryptographic operations like hashing, encrypting, decrypting, and generating keys – all using native browser code. That means no shady third-party libraries, and it’s pretty fast and secure.

🛠️ Setting Up the React App

Here’s a basic React app with two key functions: encrypting some text and then decrypting it using a password.

The React Component (App.jsx)

import { useState } from 'react'
import './App.css'
import { decryptData, encryptData } from './cryptohandler';

function App() {
    const [plainText, setplainText] = useState("");
    const [password, setpassword] = useState("");
    const [Encrypted, setEncrypted] = useState("");
    const [Decrypted, setDecrypted] = useState("");

    const handleEncrypt = async () => {
        const encrypted_text = await encryptData(password, plainText);
        if (encrypted_text.status) {
            setEncrypted(encrypted_text.encryptedText)
        } else {
            setEncrypted("ERROR ENC")
        }
    }

    const handleDecrypt = async () => {
        const decrepted_text = await decryptData(password, Encrypted);
        if (decrepted_text.status) {
            setDecrypted(decrepted_text.decryptedText)
        } else {
            setDecrypted("ERROR DEC")
        }
    }

    return (
        <>
            <div>
                {/* master password */}
                <p>
                    <input type="text" placeholder='Password' value={password} onChange={(e) => { setpassword(e.target.value) }} />
                </p>
            </div>
            {/* encrypt */}
            <div>
                <textarea name="plainInput" placeholder='Enter plain text'
                    value={plainText}
                    onChange={(e) => { setplainText(e.target.value) }}></textarea>
                <p>
                    <button onClick={handleEncrypt}>Encrypt</button>
                </p>
                <p>
                    {Encrypted}
                </p>
            </div>

            {/* decrypt */}
            <div>
                <textarea name="plainInput" placeholder='Enter cipher text' value={Encrypted}></textarea>
                <p>
                    <button onClick={handleDecrypt}>Decrypt</button>
                </p>
                <p>
                    {Decrypted}
                </p>
            </div>
        </>
    )
}

export default App

🔑 What’s Going On?

  • You type a password and some text.
  • Click “Encrypt” → it scrambles your message.
  • Copy-paste that encrypted gibberish and click “Decrypt” → if your password’s correct, the original message comes back!

🔍 Behind the Scenes: cryptohandler.jsx

This is where the actual crypto magic happens.

Step 1: Creating a Key from Password

We don’t use the password directly to encrypt. Instead, we turn it into a key using PBKDF2 (a fancy algorithm that makes brute-forcing harder), with a unique salt to make things even more secure.

const generateKeyFromPassword = async (password, salt) => {
    const textEncoder = new TextEncoder();
    const passwordKey = await window.crypto.subtle.importKey(
        'raw',
        textEncoder.encode(password),
        { name: 'PBKDF2' },
        false,
        ['deriveKey']
    );

    return await window.crypto.subtle.deriveKey(
        {
            name: 'PBKDF2',
            salt: salt,
            iterations: 10000,
            hash: 'SHA-256',
        },
        passwordKey,
        { name: 'AES-GCM', length: 256 },
        true,
        ['encrypt', 'decrypt']
    );
};

Step 2: Encrypting the Text

export const encryptData = async (password, plaintext) => {
    ...
    const salt = window.crypto.getRandomValues(new Uint8Array(16));
    const iv = window.crypto.getRandomValues(new Uint8Array(12));
    const key = await generateKeyFromPassword(password, salt);
    ...
    const encryptedData = await window.crypto.subtle.encrypt(
        { name: 'AES-GCM', iv: iv },
        key,
        textEncoder.encode(plaintext)
    );
    ...
};

🔒 What’s Happening?

  • We generate random salt and iv (initialization vector – think of it as a random start).
  • We encrypt using AES-GCM, which is fast, secure, and supports authentication.
  • Finally, we mash everything (salt + iv + encrypted text) together and base64 it so it can be stored/transmitted easily.

Step 3: Decryption

export const decryptData = async (password, ciphertext) => {
    ...
    const combinedData = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
    const salt = combinedData.slice(0, 16);
    const iv = combinedData.slice(16, 16 + 12);
    const encryptedData = combinedData.slice(28);
    ...
    const decryptedBuffer = await window.crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: iv },
        key,
        encryptedData
    );
    ...
};

🔓 Decryption Logic

  • We pull the salt and iv back out.
  • Derive the same key from the password + salt.
  • Decrypt using the key and iv. If all goes well, your original text returns!

⚠️ Quick Gotchas

  • If you change the password even slightly, decryption fails.
  • Web Crypto API only works in secure contexts (HTTPS or localhost).
  • If you store the encrypted data, make sure you don’t lose the salt or iv (we’re handling that by bundling it all together).

Final code: cryptohandler.jsx

const generateKeyFromPassword = async (password, salt) => {
    const textEncoder = new TextEncoder();
    const passwordKey = await window.crypto.subtle.importKey(
        'raw',
        textEncoder.encode(password),
        { name: 'PBKDF2' },
        false,
        ['deriveKey']
    );

    return await window.crypto.subtle.deriveKey(
        {
            name: 'PBKDF2',
            salt: salt,
            iterations: 10000,
            hash: 'SHA-256',
        },
        passwordKey,
        { name: 'AES-GCM', length: 256 },
        true,
        ['encrypt', 'decrypt']
    );
};

export const encryptData = async (password, plaintext) => {

    if (!password || !plaintext) {
        return {
            status: false,
            message: 'Please enter a password and plaintext.'
        };
    }

    try {
        const salt = window.crypto.getRandomValues(new Uint8Array(16));
        const iv = window.crypto.getRandomValues(new Uint8Array(12));
        const key = await generateKeyFromPassword(password, salt);
        const textEncoder = new TextEncoder();
        const data = textEncoder.encode(plaintext);

        const encryptedData = await window.crypto.subtle.encrypt(
            { name: 'AES-GCM', iv: iv },
            key,
            data
        );

        // Combine salt, IV, and ciphertext for storage/transmission
        const combinedData = new Uint8Array([...salt, ...iv, ...new Uint8Array(encryptedData)]);
        const base64Ciphertext = btoa(String.fromCharCode(...combinedData));
        return {
            status: true,
            encryptedText: base64Ciphertext
        };
    } catch (e) {
        return {
            status: false,
            message: `Encryption error: ${e.message}`
        };
    }
};

export const decryptData = async (password, ciphertext) => {

    if (!password || !ciphertext) {
        return {
            status: false,
            message: 'Please enter a password and ciphertext.'
        }
    }

    try {
        const combinedData = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
        const salt = combinedData.slice(0, 16);
        const iv = combinedData.slice(16, 16 + 12);
        const encryptedData = combinedData.slice(16 + 12);

        const key = await generateKeyFromPassword(password, salt);
        const decryptedBuffer = await window.crypto.subtle.decrypt(
            { name: 'AES-GCM', iv: iv },
            key,
            encryptedData
        );

        const textDecoder = new TextDecoder();
        const decodedTxt = textDecoder.decode(decryptedBuffer);
        return {
            status: true,
            decryptedText: decodedTxt
        }
    } catch (e) {
        return {
            status: false,
            message: `Encryption error: ${e.message}`
        };
    }
};

🚀 Wrap-Up

Using the Web Crypto API in React is surprisingly straightforward once you get a hang of it. With just a few lines, you can add solid encryption to your app – no sketchy libraries needed!

So next time you need to store something private on the client side, consider giving this approach a shot. It’s secure, native, and works like magic

Hope you enjoyed reading this post. Comment your thoughts below and share it if you liked reading the post!

Table of contents generated with markdown-toc