Skip to main content
Back to Blog
Tutorials
4 min read
January 18, 2025

How to Implement End-to-End Encryption in a React Chat App

Build end-to-end encrypted messaging with Web Crypto API, key exchange, message encryption, and secure key storage.

Ryel Banfield

Founder & Lead Developer

E2EE ensures only the sender and recipient can read messages. Here is how to implement it with the Web Crypto API.

Key Generation

// lib/crypto/keys.ts

// Generate an ECDH key pair for key exchange
export async function generateKeyPair(): Promise<CryptoKeyPair> {
  return crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true, // extractable β€” needed for export
    ["deriveKey", "deriveBits"],
  );
}

// Export a public key for sharing
export async function exportPublicKey(key: CryptoKey): Promise<string> {
  const raw = await crypto.subtle.exportKey("raw", key);
  return btoa(String.fromCharCode(...new Uint8Array(raw)));
}

// Import a received public key
export async function importPublicKey(base64: string): Promise<CryptoKey> {
  const raw = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
  return crypto.subtle.importKey(
    "raw",
    raw,
    { name: "ECDH", namedCurve: "P-256" },
    true,
    [],
  );
}

Shared Secret Derivation

// lib/crypto/shared-secret.ts

// Derive a shared AES key from our private key and their public key
export async function deriveSharedKey(
  privateKey: CryptoKey,
  publicKey: CryptoKey,
): Promise<CryptoKey> {
  return crypto.subtle.deriveKey(
    { name: "ECDH", public: publicKey },
    privateKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

Message Encryption and Decryption

// lib/crypto/messages.ts

interface EncryptedMessage {
  ciphertext: string;
  iv: string;
}

export async function encryptMessage(
  plaintext: string,
  sharedKey: CryptoKey,
): Promise<EncryptedMessage> {
  const encoder = new TextEncoder();
  const data = encoder.encode(plaintext);

  // Generate a unique IV for each message
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    sharedKey,
    data,
  );

  return {
    ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
    iv: btoa(String.fromCharCode(...iv)),
  };
}

export async function decryptMessage(
  encrypted: EncryptedMessage,
  sharedKey: CryptoKey,
): Promise<string> {
  const ciphertext = Uint8Array.from(atob(encrypted.ciphertext), (c) =>
    c.charCodeAt(0),
  );
  const iv = Uint8Array.from(atob(encrypted.iv), (c) => c.charCodeAt(0));

  const decrypted = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    sharedKey,
    ciphertext,
  );

  return new TextDecoder().decode(decrypted);
}

Key Storage With IndexedDB

// lib/crypto/storage.ts

const DB_NAME = "e2ee-keys";
const STORE_NAME = "key-pairs";

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
    request.onupgradeneeded = () => {
      request.result.createObjectStore(STORE_NAME, { keyPath: "id" });
    };
  });
}

export async function storeKeyPair(
  userId: string,
  keyPair: CryptoKeyPair,
): Promise<void> {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);

  // Export keys for storage
  const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
  const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);

  store.put({ id: userId, publicKey, privateKey });

  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

export async function loadKeyPair(
  userId: string,
): Promise<CryptoKeyPair | null> {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readonly");
  const store = tx.objectStore(STORE_NAME);

  return new Promise((resolve, reject) => {
    const request = store.get(userId);
    request.onerror = () => reject(request.error);
    request.onsuccess = async () => {
      const data = request.result;
      if (!data) {
        resolve(null);
        return;
      }

      const publicKey = await crypto.subtle.importKey(
        "jwk",
        data.publicKey,
        { name: "ECDH", namedCurve: "P-256" },
        true,
        [],
      );
      const privateKey = await crypto.subtle.importKey(
        "jwk",
        data.privateKey,
        { name: "ECDH", namedCurve: "P-256" },
        true,
        ["deriveKey", "deriveBits"],
      );

      resolve({ publicKey, privateKey });
    };
  });
}

E2EE Hook

"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { generateKeyPair, exportPublicKey, importPublicKey } from "@/lib/crypto/keys";
import { deriveSharedKey } from "@/lib/crypto/shared-secret";
import { encryptMessage, decryptMessage } from "@/lib/crypto/messages";
import { storeKeyPair, loadKeyPair } from "@/lib/crypto/storage";

interface UseE2EEOptions {
  userId: string;
  onPublicKey: (publicKey: string) => void; // share via server
}

export function useE2EE({ userId, onPublicKey }: UseE2EEOptions) {
  const [ready, setReady] = useState(false);
  const keyPairRef = useRef<CryptoKeyPair | null>(null);
  const sharedKeysRef = useRef<Map<string, CryptoKey>>(new Map());

  // Initialize key pair
  useEffect(() => {
    async function init() {
      let keyPair = await loadKeyPair(userId);
      if (!keyPair) {
        keyPair = await generateKeyPair();
        await storeKeyPair(userId, keyPair);
      }
      keyPairRef.current = keyPair;
      const publicKeyString = await exportPublicKey(keyPair.publicKey);
      onPublicKey(publicKeyString);
      setReady(true);
    }
    init();
  }, [userId, onPublicKey]);

  // Add a peer's public key and derive shared secret
  const addPeer = useCallback(async (peerId: string, peerPublicKey: string) => {
    if (!keyPairRef.current) return;
    const importedKey = await importPublicKey(peerPublicKey);
    const sharedKey = await deriveSharedKey(
      keyPairRef.current.privateKey,
      importedKey,
    );
    sharedKeysRef.current.set(peerId, sharedKey);
  }, []);

  // Encrypt a message for a peer
  const encrypt = useCallback(async (peerId: string, plaintext: string) => {
    const sharedKey = sharedKeysRef.current.get(peerId);
    if (!sharedKey) throw new Error("No shared key with peer");
    return encryptMessage(plaintext, sharedKey);
  }, []);

  // Decrypt a message from a peer
  const decrypt = useCallback(async (peerId: string, ciphertext: { ciphertext: string; iv: string }) => {
    const sharedKey = sharedKeysRef.current.get(peerId);
    if (!sharedKey) throw new Error("No shared key with peer");
    return decryptMessage(ciphertext, sharedKey);
  }, []);

  return { ready, addPeer, encrypt, decrypt };
}

Usage in a Chat Component

"use client";

import { useE2EE } from "@/hooks/use-e2ee";
import { useState, useCallback } from "react";

export function EncryptedChat({ userId, peerId }: { userId: string; peerId: string }) {
  const [messages, setMessages] = useState<{ text: string; from: string }[]>([]);
  const [input, setInput] = useState("");

  const { ready, addPeer, encrypt, decrypt } = useE2EE({
    userId,
    onPublicKey: (key) => {
      // Send public key to server for peer to receive
      fetch("/api/keys", {
        method: "POST",
        body: JSON.stringify({ userId, publicKey: key }),
        headers: { "Content-Type": "application/json" },
      });
    },
  });

  const handleSend = useCallback(async () => {
    if (!input.trim()) return;
    const encrypted = await encrypt(peerId, input.trim());

    // Send encrypted message via your transport
    await fetch("/api/messages", {
      method: "POST",
      body: JSON.stringify({ to: peerId, ...encrypted }),
      headers: { "Content-Type": "application/json" },
    });

    setMessages((prev) => [...prev, { text: input.trim(), from: "me" }]);
    setInput("");
  }, [input, peerId, encrypt]);

  if (!ready) return <div>Setting up encryption...</div>;

  return (
    <div className="flex flex-col h-96 border rounded-lg">
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`text-sm px-3 py-2 rounded-lg max-w-xs ${
              msg.from === "me"
                ? "bg-primary text-primary-foreground ml-auto"
                : "bg-muted"
            }`}
          >
            {msg.text}
          </div>
        ))}
      </div>
      <div className="flex gap-2 p-3 border-t">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          className="flex-1 px-3 py-2 border rounded-md text-sm"
          placeholder="Encrypted message..."
        />
        <button
          onClick={handleSend}
          className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"
        >
          Send
        </button>
      </div>
    </div>
  );
}

Need Secure Communication Features?

We build encrypted communication systems for sensitive industries. Contact us to discuss your requirements.

encryptionE2EEsecurityWeb Cryptochattutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles