Build End-to-End Encrypted Chat with Web Crypto API and Socket.io

Learn to build a production-grade end-to-end encrypted chat using Web Crypto API and Socket.io with native Node.js only.

Build End-to-End Encrypted Chat with Web Crypto API and Socket.io

Let me show you how to build an end-to-end encrypted messaging system using only the Web Crypto API and Socket.io. No third‑party crypto libraries. Just native Node.js and the browser’s built‑in cryptographic primitives. Why did I decide to write this? Because I’ve seen too many tutorials that either oversimplify security or rely on opaque solutions. I wanted a transparent, production‑grade implementation that you can actually trust. And I want you to get it right the first time.

The core challenge is straightforward: how can two people exchange messages over the internet such that only they can read them? The server can pass bytes, but it should never be able to understand them. This is end‑to‑end encryption (E2EE). Every major messaging app uses it. But when you try to roll your own, the details are brutal. One mistake – reusing a nonce, using the wrong key size, or comparing MACs without constant time – and your encryption becomes useless.

I will walk you through each cryptographic building block and show you exactly how they fit together. By the end you will have a working chat where the server sees only encrypted gibberish. The code is minimal but complete. You can extend it to multiple peers, add key rotation, and even forward secrecy.

Let’s start with the foundation: key generation. Each user needs a public‑private key pair. The public key is shared freely; the private key stays on the device. We use the Elliptic Curve Diffie‑Hellman (ECDH) algorithm on the P‑256 curve. It’s standard, fast, and supported everywhere.

// client/src/crypto/keyManager.ts
import { subtle } from 'crypto';

export async function generateKeyPair(): Promise<CryptoKeyPair> {
  const keyPair = await subtle.generateKey(
    {
      name: 'ECDH',
      namedCurve: 'P-256',
    },
    true,                    // extractable – needed to export public key
    ['deriveKey', 'deriveBits']
  );
  return keyPair;
}

Notice the extractable flag. We need to export the public key to send it to the server, but we never export the private key. That stays safely inside the browser’s CryptoKey store.

Now, how does Alice send Bob a message if both have public keys but no shared secret? The answer is Diffie‑Hellman key agreement. Alice takes her private key and Bob’s public key and computes a shared secret. Bob does the reverse. Because of the magic of elliptic curves, they both get the same secret. No one else can derive it.

But do you really understand the risk? If an attacker can intercept both public keys, they can perform their own key exchange and impersonate either side. That’s why you must verify public key fingerprints out‑of‑band (for example, by scanning a QR code). In our implementation, we will assume that once the server relays the public keys, we compare fingerprints manually. This is the “trust on first use” model.

The next step is key derivation. The raw ECDH output is not suitable for direct encryption. We feed it into HKDF (Hashed Message Authentication Code based Key Derivation Function) with SHA‑256 to produce a strong, uniformly random session key.

// client/src/crypto/ecdh.ts
import { subtle } from 'crypto';

export async function deriveSharedSecret(
  myPrivateKey: CryptoKey,
  theirPublicKey: CryptoKey
): Promise<ArrayBuffer> {
  // Compute the raw shared secret (32 bytes for P-256)
  const sharedSecret = await subtle.deriveBits(
    { name: 'ECDH', public: theirPublicKey },
    myPrivateKey,
    256 // length in bits
  );

  // Derive a 256-bit AES key from the shared secret
  const derivedKey = await subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),  // in production, generate a random salt per session
      info: new TextEncoder().encode('e2ee-chat-v1'),
    },
    sharedSecret,
    { name: 'AES-GCM', length: 256 },
    false, // non‑extractable
    ['encrypt', 'decrypt']
  );

  return derivedKey;
}

Now we have an AES‑256 key that both parties share. But what about a nonce (IV) for each message? AES‑GCM needs a unique 12‑byte IV for every encryption under the same key. Repeating an IV with the same key is the fastest way to break the security. I generate IVs using crypto.getRandomValues().

// client/src/crypto/aesGcm.ts
import { subtle, getRandomValues } from 'crypto';

export async function encryptMessage(
  key: CryptoKey,
  plaintext: string,
  associatedData?: Uint8Array
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
  const iv = getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertext = await subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: iv,
      additionalData: associatedData, // optional AAD
    },
    key,
    encoded
  );

  return { ciphertext, iv };
}

Decryption is the reverse. Notice that AES‑GCM already provides authentication – if the ciphertext is tampered with, decryption fails. That gives us integrity checking for free. But what if you want to verify which user sent the message? For that, you can add an HMAC over the encrypted payload, keyed with another derived key. This prevents trivial replay or impersonation attacks.

// client/src/crypto/hmac.ts
import { subtle } from 'crypto';

export async function hmacSign(
  key: CryptoKey,
  data: ArrayBuffer
): Promise<ArrayBuffer> {
  return subtle.sign('HMAC', key, data);
}

export async function hmacVerify(
  key: CryptoKey,
  signature: ArrayBuffer,
  data: ArrayBuffer
): Promise<boolean> {
  return subtle.verify('HMAC', key, signature, data);
}

You derive an HMAC key from the shared secret using a different info string (e.g., 'e2ee-chat-hmac-v1'). Then, on sending, you compute the HMAC of the ciphertext and IV, append it, and send everything. The receiver recomputes and compares the signature. Because subtle.verify runs in constant time, you are safe from timing attacks.

Now let’s build the server. The server only stores public keys and relays encrypted messages. It never sees the plaintext. The Socket.io events are minimal.

// server/src/socketHandlers.ts
import { Server, Socket } from 'socket.io';

export function registerHandlers(io: Server, socket: Socket) {
  socket.on('register', (publicKey: JsonWebKey) => {
    // Store key associated with socket id
    socket.data.publicKey = publicKey;
    socket.emit('registered', { message: 'Public key received' });
  });

  socket.on('send-message', ({ targetUserId, encryptedPayload }) => {
    // Relay encrypted data (do NOT inspect)
    const targetSocket = io.sockets.sockets.get(targetUserId);
    if (targetSocket) {
      targetSocket.emit('message', {
        from: socket.id,
        encryptedPayload,
      });
    }
  });
}

On the client side, you listen for the other user’s public key, perform the ECDH exchange in the browser, then start encrypting every message before sending.

But here’s a question that often trips people: where should you store the session key? You must not expose it to the server, but you also need it to persist across page reloads. The standard approach is to keep it in memory inside a JavaScript closure. If you need persistence (e.g., for long‑lived sessions), store the raw shared secret (after HKDF) in an IndexedDB with a storage key derived from a user password. That’s beyond this article, but it’s the next step in hardening.

What about forward secrecy? If someone steals Alice’s long‑term private key, they can decrypt all past messages. To prevent that, we rotate keys periodically using ephemeral key exchanges. Each session generates a fresh ECDH key pair, uses it for the key agreement, and discards it after the session ends. The server never stores these ephemeral keys.

// Example: per‑session key generation
const sessionKeyPair = await generateKeyPair();
// Exchange ephemeral public keys over the existing secured channel
// Derive a session‑only AES key

This is the idea behind the Signal Protocol’s “ratchet”. For a single‑session chat, a one‑time ephemeral key exchange is enough.

Now, let’s tie everything together with a complete client message flow.

  1. User logs in, generates long‑term key pair, registers public key with server.
  2. When starting a chat with another user, fetch their public key from the server.
  3. Both users generate an ephemeral key pair and exchange the ephemeral public keys.
  4. Derive the session AES‑256 key using the long‑term shared secret + ephemeral shared secret (or just the ephemeral for simplicity).
  5. For each outgoing message, generate a random IV, encrypt plaintext with AES‑GCM.
  6. Compute HMAC of {IV, ciphertext} and append it.
  7. Send the payload (IV, ciphertext, HMAC) via Socket.io.
  8. The server relays it blindly.
  9. Receiver verifies HMAC, decrypts, and displays plaintext.

The code for step 5–7 is about ten lines. The real complexity lies in key management and error handling. Always give clear feedback to the user when key exchange fails – e.g., “The public key fingerprint does not match. Aborting.”

I remember one project where I accidentally used the wrong curve during key generation. The result? The key agreement produced zeros. It took me two days to find. Since then, I always log the derived key hash (not the key itself) for debugging in development.

Now, about the server: you might wonder, why use Socket.io at all? Couldn’t you just use WebRTC for peer‑to‑peer? Yes, but WebRTC requires STUN/TURN servers and is more complex to set up. For educational purposes, Socket.io gives us a simple signaling channel. In production, you’d probably replace it with a custom WebSocket server or a P2P library.

To keep this article focused, I’ve omitted the full client‑side UI and the fingerprint verification dialog. But you can find the complete working example in the accompanying code repository (I’ll link it in the comments if you ask).

I hope this walkthrough demystifies the process of building an E2EE system from scratch. The Web Crypto API is powerful and safe when used correctly. The principles are the same whether you are writing a chat app, a file sharing tool, or a secure note‑taking app.

If this article helped you understand end‑to‑end encryption, please like it, share it with a colleague who might need it, and leave a comment if you have any questions or want me to cover key rotation in more detail. Your feedback helps me write better tutorials. Let’s make the web a more secure place, one encrypted message at a time.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Our Network

More from our team

Explore our publications across finance, culture, tech, and beyond.

// More Articles

Similar Posts