Build a Secure P2P Video Chat

Build an encrypted video chat app using WebRTC, ECDH, and AES-256-GCM.

Introduction

30 Second Summary

Every time you send a message on Signal or join a video call on Teams, multiple layers of encryption work together to keep your conversation private. But what actually happens under the hood?

In this project, you will build a peer-to-peer video chat from scratch that implements both transport encryption and end-to-end encryption. You will see exactly how modern communication apps protect your data at every layer.

What You'll Build

A secure peer-to-peer video chat application that runs locally in two browser tabs, featuring live video streaming, cryptographic key exchange, and encrypted messaging.

By the end of this project, you'll have:

  • A Node.js WebSocket signaling server that coordinates peer connections without accessing message content.
  • A WebRTC peer-to-peer video call with mandatory DTLS-SRTP transport encryption.
  • An end-to-end encrypted chat using ECDH key exchange and AES-256-GCM, with a real-time security dashboard displaying encryption status and key fingerprints.
  • Secret Mission: Implement safety numbers verification so both peers can confirm their keys match, just like Signal does.

Are there any prerequisites?

You will need Node.js (version 18 or higher) and VS Code installed on your machine. Basic familiarity with JavaScript is helpful but not required.

Before We Start

Every time you send a message on Signal or make a video call on Teams, multiple layers of encryption protect your conversation from eavesdroppers. In this project, you'll build those exact encryption layers yourself.

You'll create a peer-to-peer video chat that uses transport encryption for the video stream and end-to-end encryption for text messages. Before writing any code, let's get your environment set up.

In this step, get ready to:
  • Confirm Node.js is version 18 or higher.
  • Create a project folder, initialize npm, and install the WebSocket library.
  • Create the two files that will hold your signaling server and client app.
Open VS Code and confirm Node.js is ready

You need Node.js version 18 or higher to run the WebSocket signaling server you'll build in this project. Let's confirm it's installed and ready.

  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Press Ctrl+` to open the integrated terminal.
  • Check your Node.js version by running this command:
node --version

You should see a version number like v18.x.x, v20.x.x, or higher printed in the terminal.

Seeing a version lower than 18?

Visit the official Node.js downloads page at https://nodejs.org and install the latest LTS version. After installing, close and reopen VS Code, then run node --version again.

help me troubleshoot my Node.js version

Create the project folder and install dependencies

Your project needs a dedicated folder and one npm package: ws, a lightweight WebSocket library for Node.js.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir secure-video-chat
cd secure-video-chat
  • Initialize npm and install the WebSocket library by running these commands:
npm init -y
npm install ws@8.21.0

What do these commands do?

  • npm init -y creates a package.json file that tracks your project's name, version, and dependencies.
  • npm install ws@8.21.0 downloads the ws library (a pure WebSocket implementation) into a node_modules folder and records it in package.json.
  • Verify the installation by running:
ls node_modules/ws

You should see files and folders listed inside node_modules/ws, confirming the library is installed.

Seeing 'no such file or directory'?

Make sure you ran npm install ws@8.21.0 from inside the secure-video-chat folder. If you're not sure which folder you're in, run cd (Windows) to check your current directory.

help me fix my npm install

Create the project files

This project has two files: server.js for the signaling server that coordinates connections, and index.html for the client application that runs in the browser.

  • Create both files by running these commands:

macOS

touch server.js index.html

Windows

New-Item server.js -ItemType File
New-Item index.html -ItemType File
  • Confirm the files exist by running:
ls

You should see server.js, index.html, node_modules, package.json, and package-lock.json listed in the output.

  • Open both files in VS Code by running:
code server.js index.html

What if the code command doesn't work?

If code is not recognized, open the files manually. In VS Code, click File in the top menu bar, then Open Folder, and select the secure-video-chat folder on your Desktop.

Then click each file in the Explorer sidebar to open it.

Don't see both files?

Double-check that you ran the file creation commands from inside the secure-video-chat folder. If the files ended up in the wrong place, delete them and re-run the creation commands after navigating to the correct folder with cd ~/Desktop/secure-video-chat.

help me find my project files

Your environment is ready. Next up, you'll write the WebSocket signaling server that lets two browsers find each other and establish a connection.

Build the WebSocket Signaling Server

Your project folder is set up with the ws library ready to go. Now you need a way for two browser tabs to discover each other and coordinate a connection.

In real-time apps, peers cannot connect directly without help. A signaling server acts as an introducer, relaying messages between clients so they can exchange the information needed to set up a direct peer-to-peer connection. In this step, you will build that signaling server using WebSockets.

In this step, get ready to:
  • Write a Node.js server that serves your HTML file and handles WebSocket connections.
  • Implement message forwarding so clients can relay signaling data to each other.
  • Test the server to confirm it starts and logs its URL.
Write the signaling server in server.js

The signaling server has two jobs: serve the HTML page over HTTP, and manage WebSocket connections between clients. When a client connects, the server assigns it a unique ID and tells all other connected clients about the new peer.

  • Switch to the server.js tab in VS Code and paste the following code:
const { WebSocketServer } = require('ws');
const http = require('http');
const fs = require('fs');
const path = require('path');

// Create an HTTP server that serves index.html to the browser
const server = http.createServer((req, res) => {
  if (req.url === '/' || req.url === '/index.html') {
    fs.readFile(path.join(__dirname, 'index.html'), (err, data) => {
      if (err) {
        res.writeHead(500);
        res.end('Error loading index.html');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(data);
    });
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

// Attach a WebSocket server to the HTTP server
const wss = new WebSocketServer({ server });

// Track connected clients by unique numeric ID
const clients = new Map();
let clientIdCounter = 0;

wss.on('connection', (ws) => {
  const clientId = ++clientIdCounter;
  clients.set(clientId, ws);

  // Tell the new client what their ID is
  ws.send(JSON.stringify({ type: 'assigned-id', id: clientId }));

  // Notify every other connected client that a new peer joined
  clients.forEach((client, id) => {
    if (id !== clientId && client.readyState === 1) {
      client.send(JSON.stringify({ type: 'peer-joined', peerId: clientId }));
    }
  });
});

// Start listening on port 3000
const PORT = 3000;
server.listen(PORT, () => {
  console.log(`Signaling server running at http://localhost:${PORT}`);
  console.log('Open two browser tabs to start a secure video chat.');
});

What does this code do?

The top half creates an HTTP server. When a browser requests / or /index.html, it reads and serves your HTML file. Any other URL gets a 404 response.

The bottom half attaches a WebSocketServer to that HTTP server. A Map tracks every connected client by a numeric ID. When a new client connects, the server sends them their ID and broadcasts a peer-joined message to everyone else.

You won't see a change yet. The next chunk adds message forwarding, then you will test the full server.

Implement message forwarding and disconnect handling

Right now the server can accept connections and notify peers about each other. It still needs to forward signaling messages between specific peers and clean up when someone disconnects.

  • In server.js, find the closing }); of the wss.on('connection', ...) handler (right after the clients.forEach block).
  • Add the following code just before that closing });:
  // Forward messages to a specific peer by target ID
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    const target = clients.get(message.target);
    if (target && target.readyState === 1) {
      target.send(JSON.stringify({ ...message, sender: clientId }));
    }
  });

  // Handle disconnections by removing client and notifying others
  ws.on('close', () => {
    clients.delete(clientId);
    clients.forEach((client) => {
      if (client.readyState === 1) {
        client.send(JSON.stringify({ type: 'peer-left', peerId: clientId }));
      }
    });
  });

What does this code do?

The ws.on('message') handler reads incoming messages and checks for a target field. If the target client exists and is still connected, the server forwards the entire message to them, adding the sender ID so the recipient knows who it came from.

The ws.on('close') handler fires when a client disconnects. It removes them from the clients Map and sends a peer-left message to everyone still connected.

  • Save server.js.

You won't see output yet. The next substep runs the server so you can confirm everything works.

Test the server

The signaling server is fully written. Time to start it and confirm it launches without errors.

  • Run the server by entering this command in your VS Code terminal:
node server.js

You should see two lines printed in your terminal:

Signaling server running at http://localhost:3000
Open two browser tabs to start a secure video chat.

Don't see the startup message?

  • If you see SyntaxError, double check that the ws.on('message') and ws.on('close') handlers are inside the wss.on('connection') callback, not after its closing bracket.
  • If you see EADDRINUSE, port 3000 is already in use. Close any other servers running on that port and try again.
  • If you see Cannot find module 'ws', make sure you are running the command from inside your project folder where node_modules lives.

Help me debug my signaling server

  • Stop the server by pressing Ctrl+C in the terminal.

Why stop the server?

You will restart the server in the next step after building the client-side HTML. Stopping it now keeps your terminal free for other commands.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and that the server is stopped.

ⓧ I'd like to double check the full code

Here is the complete server.js file at this point:

const { WebSocketServer } = require('ws');
const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer((req, res) => {
  if (req.url === '/' || req.url === '/index.html') {
    fs.readFile(path.join(__dirname, 'index.html'), (err, data) => {
      if (err) {
        res.writeHead(500);
        res.end('Error loading index.html');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(data);
    });
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

const wss = new WebSocketServer({ server });

const clients = new Map();
let clientIdCounter = 0;

wss.on('connection', (ws) => {
  const clientId = ++clientIdCounter;
  clients.set(clientId, ws);

  ws.send(JSON.stringify({ type: 'assigned-id', id: clientId }));

  clients.forEach((client, id) => {
    if (id !== clientId && client.readyState === 1) {
      client.send(JSON.stringify({ type: 'peer-joined', peerId: clientId }));
    }
  });

  ws.on('message', (data) => {
    const message = JSON.parse(data);
    const target = clients.get(message.target);
    if (target && target.readyState === 1) {
      target.send(JSON.stringify({ ...message, sender: clientId }));
    }
  });

  ws.on('close', () => {
    clients.delete(clientId);
    clients.forEach((client) => {
      if (client.readyState === 1) {
        client.send(JSON.stringify({ type: 'peer-left', peerId: clientId }));
      }
    });
  });
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`Signaling server running at http://localhost:${PORT}`);
  console.log('Open two browser tabs to start a secure video chat.');
});

Your signaling server is complete. Next up, you will build the client-side HTML that connects to this server and establishes a live video call between two browser tabs.

Establish the WebRTC Video Connection

Your signaling server can relay messages between peers. Now it is time to build the client that actually uses it.

In this step, you will create the browser-based interface for the video chat. The client requests camera access, connects to the signaling server over a WebSocket, and uses WebRTC to stream video directly between two browser tabs.

In this step, get ready to:
  • Build the HTML page with a dark-themed video grid layout.
  • Implement the WebSocket signaling and WebRTC peer connection in JavaScript.
  • Test live video streaming between two browser tabs.
Build the HTML structure

The client page needs two video elements (one for your local camera, one for the remote peer), a connection status indicator, and styling that keeps everything readable on a dark background.

  • Switch back to index.html in VS Code (it is currently empty).
  • Paste the following HTML structure into the file:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Secure P2P Video Chat</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
    .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
    h1 { text-align: center; margin-bottom: 20px; color: #00d4aa; }
    .video-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
    .video-container { position: relative; background: #16213e; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; }
    .video-container video { width: 100%; height: 100%; object-fit: cover; }
    .video-label { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.7); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
    .status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
    .status-indicator.secure { background: #00d4aa; }
    .status-indicator.waiting { background: #ffa500; }
    .status-indicator.disconnected { background: #ff4444; }
    .connection-status { text-align: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 20px; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Secure P2P Video Chat</h1>
    <!-- Connection status banner -->
    <div class="connection-status" id="connectionStatus">
      <span class="status-indicator waiting"></span>
      Waiting for peer to connect...
    </div>
    <!-- Video grid: local camera on the left, remote peer on the right -->
    <div class="video-grid">
      <div class="video-container">
        <video id="localVideo" autoplay muted playsinline></video>
        <div class="video-label">You (Local)</div>
      </div>
      <div class="video-container">
        <video id="remoteVideo" autoplay playsinline></video>
        <div class="video-label">Peer (Remote)</div>
      </div>
    </div>
  </div>
  <script>
    // WebRTC video chat code will go here
  </script>
</body>
</html>

What does this HTML do?

  • The connectionStatus div shows a colored dot (orange for waiting, green for connected, red for disconnected) so you always know the current state.
  • The localVideo element has the muted attribute so you do not hear your own microphone echoing back at you.
  • The playsinline attribute prevents mobile browsers from forcing the video into fullscreen mode.
  • Save index.html.

Seeing a blank page?

The file needs to be saved before the server can serve it. Make sure you saved with Ctrl+S (Windows) or Cmd+S (macOS).

help me debug a blank page in my video chat project

Implement the WebSocket signaling and WebRTC connection

The JavaScript in this file handles three responsibilities: requesting camera access, connecting to the signaling server, and managing the WebRTC peer connection that streams video directly between browsers.

  • In index.html, find the comment // WebRTC video chat code will go here inside the <script> tag.
  • Replace that comment with the following initialization code:
    // State variables for the connection
    let ws;
    let myId;
    let peerId;
    let peerConnection;
    let localStream;

    // Entry point: start camera, then connect to signaling server
    async function init() {
      await startLocalVideo();
      connectToSignalingServer();
    }

    // Request camera and microphone access
    async function startLocalVideo() {
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      document.getElementById('localVideo').srcObject = localStream;
    }

What does this code do?

  • The five let declarations hold references to the WebSocket, peer IDs, the RTCPeerConnection, and the local camera stream.
  • The init() function kicks things off by first getting camera access, then connecting to the signaling server.
  • The getUserMedia() call asks the browser for permission to use the camera and microphone. It returns a stream that gets attached to the localVideo element.

Browser permission prompt

When the page loads, your browser will display a permission prompt asking to use your camera and microphone. Click Allow to grant access. Without it, the video chat cannot function.

  • Add the signaling server connection function directly below the code you just pasted, still inside the <script> tag:
    // Connect to the WebSocket signaling server and route incoming messages
    function connectToSignalingServer() {
      ws = new WebSocket(`ws://${window.location.host}`);
      ws.onopen = () => { console.log('Connected to signaling server'); };
      ws.onmessage = async (event) => {
        const message = JSON.parse(event.data);
        switch (message.type) {
          case 'assigned-id': myId = message.id; break;
          case 'peer-joined': peerId = message.peerId; await startCall(); break;
          case 'offer': peerId = message.sender; await handleOffer(message); break;
          case 'answer': await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)); break;
          case 'ice-candidate': if (message.candidate) { await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); } break;
          case 'peer-left': handlePeerDisconnect(); break;
        }
      };
    }

What does this code do?

  • The WebSocket connects to the same host that served the page, reusing the same port (3000).
  • The switch statement routes each message type from the signaling server to the correct handler. When a peer joins, it starts a call. When an offer arrives, it answers. ICE candidates are exchanged for NAT traversal (finding a network path between the two peers).
  • Add the WebRTC peer connection and call lifecycle functions below the signaling code:
    // Create and configure the WebRTC peer connection
    function createPeerConnection() {
      peerConnection = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
      localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); });
      peerConnection.ontrack = (event) => {
        document.getElementById('remoteVideo').srcObject = event.streams[0];
        document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator secure"></span>Peer connected - Encrypted video active';
      };
      peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          ws.send(JSON.stringify({ type: 'ice-candidate', target: peerId, candidate: event.candidate }));
        }
      };
    }

    // Initiate a call to the peer (called when a new peer joins)
    async function startCall() {
      createPeerConnection();
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);
      ws.send(JSON.stringify({ type: 'offer', target: peerId, sdp: offer }));
    }

    // Respond to an incoming call offer
    async function handleOffer(message) {
      createPeerConnection();
      await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);
      ws.send(JSON.stringify({ type: 'answer', target: peerId, sdp: answer }));
    }

    // Clean up when the peer disconnects
    function handlePeerDisconnect() {
      document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator disconnected"></span>Peer disconnected';
      document.getElementById('remoteVideo').srcObject = null;
      if (peerConnection) { peerConnection.close(); peerConnection = null; }
      peerId = null;
    }

    // Start the application
    init();

What does this code do?

  • The createPeerConnection() function sets up a new RTCPeerConnection with a STUN server for NAT traversal. It attaches your local camera tracks, listens for remote video tracks, and forwards ICE candidates through the signaling server.
  • The startCall() function is triggered when a second tab opens. It creates an SDP offer describing its media capabilities and sends it to the peer.
  • The handleOffer() function runs on the receiving tab. It accepts the offer and sends back an SDP answer, completing the negotiation.
  • The handlePeerDisconnect() function cleans up the connection, clears the remote video, and resets state when the other tab closes.

Why is video encrypted automatically?

WebRTC mandates DTLS-SRTP for all media streams. There is no way to negotiate plain (unencrypted) video between two WebRTC peers. This transport encryption is always active the moment the peer connection establishes.

  • Save index.html.

Seeing a syntax error in the console?

Make sure there are no extra or missing curly braces between the three code blocks you pasted. All the functions should sit at the same indentation level inside the <script> tag, with the init(); call at the very end.

help me fix a syntax error in my WebRTC video chat script

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html with all three JavaScript sections inside the script tag.

ⓧ I'd like to double check the full code

Here is the complete index.html file. Compare yours against it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Secure P2P Video Chat</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
    .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
    h1 { text-align: center; margin-bottom: 20px; color: #00d4aa; }
    .video-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
    .video-container { position: relative; background: #16213e; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; }
    .video-container video { width: 100%; height: 100%; object-fit: cover; }
    .video-label { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.7); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
    .status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
    .status-indicator.secure { background: #00d4aa; }
    .status-indicator.waiting { background: #ffa500; }
    .status-indicator.disconnected { background: #ff4444; }
    .connection-status { text-align: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 20px; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Secure P2P Video Chat</h1>
    <div class="connection-status" id="connectionStatus">
      <span class="status-indicator waiting"></span>
      Waiting for peer to connect...
    </div>
    <div class="video-grid">
      <div class="video-container">
        <video id="localVideo" autoplay muted playsinline></video>
        <div class="video-label">You (Local)</div>
      </div>
      <div class="video-container">
        <video id="remoteVideo" autoplay playsinline></video>
        <div class="video-label">Peer (Remote)</div>
      </div>
    </div>
  </div>
  <script>
    let ws;
    let myId;
    let peerId;
    let peerConnection;
    let localStream;

    async function init() {
      await startLocalVideo();
      connectToSignalingServer();
    }

    async function startLocalVideo() {
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      document.getElementById('localVideo').srcObject = localStream;
    }

    function connectToSignalingServer() {
      ws = new WebSocket(`ws://${window.location.host}`);
      ws.onopen = () => { console.log('Connected to signaling server'); };
      ws.onmessage = async (event) => {
        const message = JSON.parse(event.data);
        switch (message.type) {
          case 'assigned-id': myId = message.id; break;
          case 'peer-joined': peerId = message.peerId; await startCall(); break;
          case 'offer': peerId = message.sender; await handleOffer(message); break;
          case 'answer': await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)); break;
          case 'ice-candidate': if (message.candidate) { await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); } break;
          case 'peer-left': handlePeerDisconnect(); break;
        }
      };
    }

    function createPeerConnection() {
      peerConnection = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
      localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); });
      peerConnection.ontrack = (event) => {
        document.getElementById('remoteVideo').srcObject = event.streams[0];
        document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator secure"></span>Peer connected - Encrypted video active';
      };
      peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          ws.send(JSON.stringify({ type: 'ice-candidate', target: peerId, candidate: event.candidate }));
        }
      };
    }

    async function startCall() {
      createPeerConnection();
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);
      ws.send(JSON.stringify({ type: 'offer', target: peerId, sdp: offer }));
    }

    async function handleOffer(message) {
      createPeerConnection();
      await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);
      ws.send(JSON.stringify({ type: 'answer', target: peerId, sdp: answer }));
    }

    function handlePeerDisconnect() {
      document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator disconnected"></span>Peer disconnected';
      document.getElementById('remoteVideo').srcObject = null;
      if (peerConnection) { peerConnection.close(); peerConnection = null; }
      peerId = null;
    }

    init();
  </script>
</body>
</html>
Test the video connection between two tabs

With the HTML complete, you can now run the signaling server and open two tabs to see the full video connection in action.

  • Start the signaling server by running this command in your VS Code terminal:
node server.js
  • Open Google Chrome (or any Chromium-based browser).
  • Navigate to http://localhost:3000 in the first tab.
  • Your browser will ask to use your camera and microphone. Click Allow.
  • Open a second tab and navigate to http://localhost:3000 again.
  • Click Allow for camera and microphone access in the second tab as well.

Within a second or two, both tabs should display live video from your camera. The connection status banner updates to show a green dot with the text "Peer connected - Encrypted video active".

What is DTLS-SRTP?

DTLS-SRTP is the mandatory transport encryption layer in WebRTC. Every audio and video packet flowing between peers is encrypted automatically. There is no way to negotiate plain (unencrypted) RTP between two WebRTC peers.

This means your video is already encrypted in transit. In the next steps, you will add a second encryption layer on top for text messages that even your own signaling server cannot read.

Not seeing video in both tabs?

  • Make sure you clicked Allow on the camera permission prompt in both tabs. If you accidentally denied it, click the camera icon in the address bar to reset permissions.
  • Check that the terminal running node server.js has no errors. If you see an error about port 3000 already in use, stop any other process on that port and restart the server.
  • Make sure you opened both tabs in the same browser. WebRTC connections require both peers to use the same signaling server instance.

help me debug my WebRTC video connection between two browser tabs

Your peer-to-peer video chat is live and already protected by transport encryption. Next up, you will layer end-to-end encryption on top by implementing an ECDH key exchange so the signaling server cannot read your messages.

Implement ECDH Key Exchange

Your video chat is streaming encrypted video between two tabs. The DTLS-SRTP layer protects data in transit, but there is a gap. The signaling server still relays messages in plaintext.

To close that gap, both peers need a shared secret that the server never sees. Elliptic Curve Diffie-Hellman (ECDH) lets two parties derive the same secret key by exchanging only public values. In this step, you will generate ECDH key pairs, exchange public keys, and derive a shared AES-256-GCM encryption key that the signaling server cannot reconstruct.

In this step, get ready to:
  • Generate an ECDH P-256 key pair using the Web Crypto API.
  • Exchange public keys between peers and derive a shared AES-256-GCM secret.
  • Display key fingerprints in a security dashboard to verify the exchange.
Generate an ECDH key pair on page load

Before the JavaScript can display key fingerprints, you need HTML elements to write to. You will add a security dashboard panel below the video grid, then build the key generation function that populates it.

  • In index.html, find the closing </style> tag. Add these CSS rules just above it:
    .security-panel { background: #16213e; border-radius: 12px; padding: 16px; }
    .security-panel h3 { color: #00d4aa; margin-bottom: 12px; font-size: 14px; }
    .security-item { margin-bottom: 12px; padding: 8px; background: #1a1a2e; border-radius: 8px; }
    .security-item .label { font-size: 11px; color: #888; margin-bottom: 4px; }
    .security-item .value { font-size: 12px; font-family: monospace; word-break: break-all; }

What does this CSS do?

These styles create a dark panel with rounded corners that matches the rest of the UI. Each security item gets its own card-like background so the dashboard reads like a series of status indicators.

  • Now find the closing </div> of the video grid (just below the second video-container div). Add the security dashboard HTML below it, still inside the container div:
    <div class="security-panel">
      <h3>Security Dashboard</h3>
      <div class="security-item">
        <div class="label">Key Exchange</div>
        <div class="value" id="keyExchangeStatus"><span class="status-indicator waiting"></span>Not started</div>
      </div>
      <div class="security-item">
        <div class="label">Your Public Key (first 16 bytes)</div>
        <div class="value" id="localKeyFingerprint">Generating...</div>
      </div>
      <div class="security-item">
        <div class="label">Peer Public Key (first 16 bytes)</div>
        <div class="value" id="remoteKeyFingerprint">Waiting for peer...</div>
      </div>
    </div>

What does this HTML do?

The security panel has three status rows. The first tracks the key exchange state. The other two display the first 16 bytes of each peer's public key as a hex fingerprint. These fingerprints let you visually confirm the exchange happened.

  • Save index.html.
  • Refresh one of your browser tabs. You should see a dark "Security Dashboard" panel below the video grid with three items showing "Not started", "Generating...", and "Waiting for peer...".

Don't see the security panel?

Make sure the security panel HTML sits inside the container div, after the video-grid div closes. If it appears outside the container, the max-width and padding will not apply.

Check that the CSS rules are placed before the closing </style> tag, not after it.

Why isn't my security dashboard showing up?

Now you will build the function that generates a cryptographic key pair and writes the local fingerprint into the dashboard.

  • In the <script> block, find the variable declarations at the top. Replace the existing five variables with these seven (adding myKeyPair and sharedKey):
    let ws;
    let myId;
    let peerId;
    let peerConnection;
    let localStream;
    let myKeyPair;
    let sharedKey;
  • Directly below the updated init() function (which you will update next), add the generateKeyPair() function by copying this code below the closing brace of init():
    async function generateKeyPair() {
      // Generate an ECDH key pair on the P-256 curve
      myKeyPair = await window.crypto.subtle.generateKey(
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        ['deriveKey']
      );
      // Export the public key as raw bytes (65 bytes uncompressed)
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      // Display first 16 bytes as a hex fingerprint
      const fingerprint = Array.from(new Uint8Array(publicKeyRaw).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('localKeyFingerprint').textContent = fingerprint;
    }

What does this code do?

  • crypto.subtle.generateKey creates a private/public key pair on the P-256 elliptic curve. The private key never leaves this browser tab.
  • exportKey('raw', ...) serialises the public key into 65 raw bytes (the uncompressed point format: a 0x04 prefix followed by the x and y coordinates).
  • The fingerprint takes the first 16 bytes of that raw export and formats them as space-separated hex. This gives you a quick visual identifier without showing the full key.
  • Now update the init() function to call generateKeyPair() before anything else. Find the current init() and replace it with:
    async function init() {
      await generateKeyPair();
      await startLocalVideo();
      connectToSignalingServer();
    }

Why generate the key pair first?

The key pair must exist before the signaling connection opens. If a peer joins immediately, you need the public key ready to send. Generating it first ensures the key is always available by the time signaling events arrive.

  • Save index.html.
  • Refresh a browser tab. The "Your Public Key" row in the security dashboard should now show a hex string like 04 a3 7b ... instead of "Generating...".

Still seeing "Generating..."?

Open the browser console (F12, then the Console tab). If you see an error about crypto.subtle being undefined, make sure you are accessing the page via http://localhost:3000. The Web Crypto API requires a secure context (localhost counts, but a raw IP does not).

Also confirm that await generateKeyPair(); is the first line inside init().

Help me debug why my key pair is not generating.

Send public keys and derive the shared secret

Each peer has generated its own key pair, but neither peer knows the other's public key yet. You need three new functions: one to send your public key, one to receive and import the peer's public key, and one to derive the shared secret from both.

  • Add the deriveSharedKey() function directly below generateKeyPair():
    async function deriveSharedKey(peerPublicKey) {
      // Combine our private key with the peer's public key to derive a shared secret
      sharedKey = await window.crypto.subtle.deriveKey(
        { name: 'ECDH', public: peerPublicKey },
        myKeyPair.privateKey,
        { name: 'AES-GCM', length: 256 },
        false,
        ['encrypt', 'decrypt']
      );
      // Update the dashboard to show key exchange is complete
      document.getElementById('keyExchangeStatus').innerHTML =
        '<span class="status-indicator secure"></span>ECDH P-256 complete';
    }

What does this code do?

crypto.subtle.deriveKey performs the ECDH math: it multiplies your private key by the peer's public point on the P-256 curve. Both peers compute the same shared point, which becomes a 256-bit AES-GCM key.

The extractable: false parameter means the derived key can only be used for encrypt/decrypt operations. It cannot be exported or read by JavaScript, adding a layer of protection.

  • Add the sendPublicKey() function below the existing handleOffer() function:
    async function sendPublicKey() {
      // Export our public key and send it to the peer via the signaling server
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      ws.send(JSON.stringify({
        type: 'public-key',
        target: peerId,
        key: Array.from(new Uint8Array(publicKeyRaw))
      }));
    }

What does this code do?

This exports the raw public key bytes and converts them to a plain JavaScript array (since WebSocket messages must be JSON-serialisable). The signaling server forwards this array to the target peer without being able to reconstruct the private key from it.

  • Add the handlePeerPublicKey() function directly below sendPublicKey():
    async function handlePeerPublicKey(keyData) {
      // Reconstruct the peer's public key from the received byte array
      const publicKeyBuffer = new Uint8Array(keyData).buffer;
      const peerPublicKey = await window.crypto.subtle.importKey(
        'raw',
        publicKeyBuffer,
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        []
      );
      // Display the peer's fingerprint in the dashboard
      const fingerprint = Array.from(new Uint8Array(publicKeyBuffer).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('remoteKeyFingerprint').textContent = fingerprint;
      // Derive the shared encryption key
      await deriveSharedKey(peerPublicKey);
    }

What does this code do?

  • importKey('raw', ...) reconstructs a CryptoKey object from the raw bytes the peer sent. The empty [] for key usages is correct because you only need the imported public key as input to deriveKey, not for direct encrypt/decrypt.
  • After importing, the function displays the peer's fingerprint and immediately calls deriveSharedKey() to compute the shared secret.

The three functions are ready. Now you need to wire them into the existing signaling flow so they run at the right moment.

  • In startCall(), find the last line inside the function (the ws.send call that sends the offer). Add this line directly below it:
      await sendPublicKey();
  • Do the same in handleOffer(). Find the ws.send call that sends the answer and add this line directly below it:
      await sendPublicKey();
  • In the ws.onmessage switch statement, find the case 'ice-candidate': line. Add this new case directly below it (before case 'peer-left':):
          case 'public-key': await handlePeerPublicKey(message.key); break;
  • Finally, in handlePeerDisconnect(), find the line peerId = null; and add this line directly above it:
      sharedKey = null;

Why clear sharedKey on disconnect?

When a peer disconnects, the shared secret is no longer valid. Setting it to null ensures a fresh key exchange must happen if a new peer connects. Reusing old keys across sessions would weaken the security guarantee.

  • Save index.html.

Seeing a syntax error in the console?

The most common issue is a missing comma or semicolon in the switch statement. Make sure the new case 'public-key': line ends with break; and sits between the ice-candidate and peer-left cases.

Help me fix a syntax error in my signaling switch statement.

Verify the key exchange in the security dashboard

Time to confirm that both peers independently derive the same shared secret.

  • Switch to your terminal in VS Code. Stop the server with Ctrl+C and restart it by running:
node server.js
  • Open two browser tabs to http://localhost:3000.
  • Allow camera access in both tabs when prompted.
  • Wait for both tabs to show live video from each other.

Once the video connection establishes, look at the Security Dashboard in each tab. You should see three things:

  • The Key Exchange row shows a green dot with "ECDH P-256 complete".
  • The Your Public Key row shows a hex fingerprint unique to that tab.
  • The Peer Public Key row shows the other tab's fingerprint.

Compare the fingerprints across tabs. Tab A's "Your Public Key" should match Tab B's "Peer Public Key", and vice versa.

What is ECDH actually doing here?

Each tab generated its own private/public key pair. They exchanged only public keys through the signaling server. Using elliptic curve math, each tab combined its own private key with the other's public key to arrive at the same shared secret.

The signaling server saw both public keys pass through, but it cannot derive the shared secret without a private key. This is the foundation of end-to-end encryption.

Key exchange still shows "Not started"?

Make sure both startCall() and handleOffer() end with await sendPublicKey();. If only one peer sends its key, the other never receives a public-key message and the exchange never completes.

Also confirm that both tabs are freshly opened after restarting the server. Old tabs may hold stale WebSocket connections.

Help me figure out why the ECDH key exchange is not completing.

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and your server is still running.

ⓧ I'd like to double check the full code

Here is the complete index.html at this point in the project:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Secure P2P Video Chat</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
    .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
    h1 { text-align: center; margin-bottom: 20px; color: #00d4aa; }
    .video-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
    .video-container { position: relative; background: #16213e; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; }
    .video-container video { width: 100%; height: 100%; object-fit: cover; }
    .video-label { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.7); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
    .status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
    .status-indicator.secure { background: #00d4aa; }
    .status-indicator.waiting { background: #ffa500; }
    .status-indicator.disconnected { background: #ff4444; }
    .connection-status { text-align: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 20px; }
    .security-panel { background: #16213e; border-radius: 12px; padding: 16px; }
    .security-panel h3 { color: #00d4aa; margin-bottom: 12px; font-size: 14px; }
    .security-item { margin-bottom: 12px; padding: 8px; background: #1a1a2e; border-radius: 8px; }
    .security-item .label { font-size: 11px; color: #888; margin-bottom: 4px; }
    .security-item .value { font-size: 12px; font-family: monospace; word-break: break-all; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Secure P2P Video Chat</h1>
    <div class="connection-status" id="connectionStatus">
      <span class="status-indicator waiting"></span>
      Waiting for peer to connect...
    </div>
    <div class="video-grid">
      <div class="video-container">
        <video id="localVideo" autoplay muted playsinline></video>
        <div class="video-label">You (Local)</div>
      </div>
      <div class="video-container">
        <video id="remoteVideo" autoplay playsinline></video>
        <div class="video-label">Peer (Remote)</div>
      </div>
    </div>
    <div class="security-panel">
      <h3>Security Dashboard</h3>
      <div class="security-item">
        <div class="label">Key Exchange</div>
        <div class="value" id="keyExchangeStatus"><span class="status-indicator waiting"></span>Not started</div>
      </div>
      <div class="security-item">
        <div class="label">Your Public Key (first 16 bytes)</div>
        <div class="value" id="localKeyFingerprint">Generating...</div>
      </div>
      <div class="security-item">
        <div class="label">Peer Public Key (first 16 bytes)</div>
        <div class="value" id="remoteKeyFingerprint">Waiting for peer...</div>
      </div>
    </div>
  </div>
  <script>
    let ws;
    let myId;
    let peerId;
    let peerConnection;
    let localStream;
    let myKeyPair;
    let sharedKey;

    async function init() {
      await generateKeyPair();
      await startLocalVideo();
      connectToSignalingServer();
    }

    async function generateKeyPair() {
      myKeyPair = await window.crypto.subtle.generateKey(
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        ['deriveKey']
      );
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      const fingerprint = Array.from(new Uint8Array(publicKeyRaw).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('localKeyFingerprint').textContent = fingerprint;
    }

    async function deriveSharedKey(peerPublicKey) {
      sharedKey = await window.crypto.subtle.deriveKey(
        { name: 'ECDH', public: peerPublicKey },
        myKeyPair.privateKey,
        { name: 'AES-GCM', length: 256 },
        false,
        ['encrypt', 'decrypt']
      );
      document.getElementById('keyExchangeStatus').innerHTML =
        '<span class="status-indicator secure"></span>ECDH P-256 complete';
    }

    async function startLocalVideo() {
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      document.getElementById('localVideo').srcObject = localStream;
    }

    function connectToSignalingServer() {
      ws = new WebSocket(`ws://${window.location.host}`);
      ws.onopen = () => { console.log('Connected to signaling server'); };
      ws.onmessage = async (event) => {
        const message = JSON.parse(event.data);
        switch (message.type) {
          case 'assigned-id': myId = message.id; break;
          case 'peer-joined': peerId = message.peerId; await startCall(); break;
          case 'offer': peerId = message.sender; await handleOffer(message); break;
          case 'answer': await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)); break;
          case 'ice-candidate': if (message.candidate) { await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); } break;
          case 'public-key': await handlePeerPublicKey(message.key); break;
          case 'peer-left': handlePeerDisconnect(); break;
        }
      };
    }

    function createPeerConnection() {
      peerConnection = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
      localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); });
      peerConnection.ontrack = (event) => {
        document.getElementById('remoteVideo').srcObject = event.streams[0];
        document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator secure"></span>Peer connected - Encrypted video active';
      };
      peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          ws.send(JSON.stringify({ type: 'ice-candidate', target: peerId, candidate: event.candidate }));
        }
      };
    }

    async function startCall() {
      createPeerConnection();
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);
      ws.send(JSON.stringify({ type: 'offer', target: peerId, sdp: offer }));
      await sendPublicKey();
    }

    async function handleOffer(message) {
      createPeerConnection();
      await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);
      ws.send(JSON.stringify({ type: 'answer', target: peerId, sdp: answer }));
      await sendPublicKey();
    }

    async function sendPublicKey() {
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      ws.send(JSON.stringify({
        type: 'public-key',
        target: peerId,
        key: Array.from(new Uint8Array(publicKeyRaw))
      }));
    }

    async function handlePeerPublicKey(keyData) {
      const publicKeyBuffer = new Uint8Array(keyData).buffer;
      const peerPublicKey = await window.crypto.subtle.importKey(
        'raw',
        publicKeyBuffer,
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        []
      );
      const fingerprint = Array.from(new Uint8Array(publicKeyBuffer).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('remoteKeyFingerprint').textContent = fingerprint;
      await deriveSharedKey(peerPublicKey);
    }

    function handlePeerDisconnect() {
      document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator disconnected"></span>Peer disconnected';
      document.getElementById('remoteVideo').srcObject = null;
      if (peerConnection) { peerConnection.close(); peerConnection = null; }
      sharedKey = null;
      peerId = null;
    }

    init();
  </script>
</body>
</html>

Both peers now share a secret 256-bit key that the server never touched. Next up, you will use that shared key to encrypt and decrypt chat messages with AES-256-GCM, proving the server truly cannot read your conversations.

Build Encrypted Chat with Security Dashboard

Your video call is now protected by transport encryption, and both peers have derived a shared secret key. The problem is that the signaling server could still read any plaintext messages you send through it.

In this step, you will build an encrypted chat system that uses the shared key to encrypt every message before it leaves your browser. You will also build a security dashboard that displays all three encryption layers in real time.

In this step, get ready to:
  • Add a chat interface with a message input that activates only after the key exchange completes.
  • Implement AES-256-GCM encryption and decryption using the shared ECDH key.
  • Wire up the full message flow and verify the signaling server can only see ciphertext.
Add the chat UI and expanded security dashboard

The chat needs a message display area, a text input, and a send button. The security dashboard needs two new indicators for transport encryption and message encryption status.

  • In index.html, find the closing .video-label CSS rule. Add the following chat styles directly after it:
    .chat-section { display: grid; grid-template-columns: 1fr 300px; gap: 16px; }
    .chat-box { background: #16213e; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; height: 300px; }
    .messages { flex: 1; overflow-y: auto; margin-bottom: 12px; }
    .message { margin-bottom: 8px; padding: 8px 12px; border-radius: 8px; max-width: 80%; }
    .message.sent { background: #0f3460; margin-left: auto; }
    .message.received { background: #2a2a4e; }
    .message .meta { font-size: 10px; color: #888; margin-top: 4px; }
    .chat-input { display: flex; gap: 8px; }
    .chat-input input { flex: 1; padding: 10px; border: 1px solid #333; border-radius: 8px; background: #1a1a2e; color: #e0e0e0; outline: none; }
    .chat-input button { padding: 10px 20px; border: none; border-radius: 8px; background: #00d4aa; color: #1a1a2e; font-weight: bold; cursor: pointer; }
    .chat-input button:disabled { background: #555; cursor: not-allowed; }

What do these styles do?

The .chat-section uses CSS grid to place the chat box and security panel side by side.

The .message.sent and .message.received classes align outgoing messages to the right and incoming messages to the left, like a familiar chat interface. The disabled button style ensures the learner cannot send messages before encryption is ready.

  • Now replace the entire <div class="security-panel"> section (including its closing </div>) with the following chat section that wraps both the chat box and the expanded security dashboard:
    <div class="chat-section">
      <div class="chat-box">
        <div class="messages" id="messages"></div>
        <div class="chat-input">
          <input type="text" id="messageInput" placeholder="Type an encrypted message..." disabled>
          <button id="sendBtn" disabled>Send</button>
        </div>
      </div>

      <div class="security-panel">
        <h3>Security Dashboard</h3>
        <div class="security-item">
          <div class="label">Connection Encryption</div>
          <div class="value" id="transportEncryption">
            <span class="status-indicator waiting"></span>Waiting...
          </div>
        </div>
        <div class="security-item">
          <div class="label">Key Exchange</div>
          <div class="value" id="keyExchangeStatus">
            <span class="status-indicator waiting"></span>Not started
          </div>
        </div>
        <div class="security-item">
          <div class="label">Message Encryption</div>
          <div class="value" id="messageEncryption">
            <span class="status-indicator waiting"></span>AES-256-GCM (pending key)
          </div>
        </div>
        <div class="security-item">
          <div class="label">Your Public Key (first 16 bytes)</div>
          <div class="value" id="localKeyFingerprint">Generating...</div>
        </div>
        <div class="security-item">
          <div class="label">Peer Public Key (first 16 bytes)</div>
          <div class="value" id="remoteKeyFingerprint">Waiting for peer...</div>
        </div>
      </div>
    </div>

What changed in the HTML?

  • A .chat-section wrapper now contains both the chat box and the security panel in a two-column grid.
  • The chat input and send button start disabled. They will only activate after the ECDH key exchange finishes and a shared key exists.
  • Two new security items track transportEncryption (DTLS-SRTP from WebRTC) and messageEncryption (AES-256-GCM for chat messages).
  • Save index.html.
  • Refresh one of your browser tabs. You should see a dark chat input box on the left (greyed out) and the security panel on the right with five status rows including the new Connection Encryption and Message Encryption items.

Don't see the chat section?

  • Make sure you replaced the old <div class="security-panel"> entirely. If you left the old HTML in place, you will have duplicate security panels.
  • Check that your new .chat-section CSS is inside the <style> block, not outside it.
  • If the layout looks broken, confirm that the .chat-section wrapper has a matching closing </div> tag.

Help me debug my chat section layout.

Implement the encrypt and decrypt functions

With the UI in place, you need two functions that use the shared AES-256-GCM key to encrypt outgoing messages and decrypt incoming ones. Each message gets a fresh random initialization vector (IV) so that identical plaintext produces different ciphertext every time.

  • In the <script> block of index.html, find the deriveSharedKey function. Add the following encryptMessage function directly after it:
    async function encryptMessage(plaintext) {
      // Encode the string into bytes
      const encoder = new TextEncoder();
      const data = encoder.encode(plaintext);
      // Generate a fresh 12-byte IV for this message
      const iv = window.crypto.getRandomValues(new Uint8Array(12));
      // Encrypt using AES-256-GCM with the shared key
      const ciphertext = await window.crypto.subtle.encrypt(
        { name: 'AES-GCM', iv },
        sharedKey,
        data
      );
      // Return IV and ciphertext as plain arrays for JSON serialization
      return {
        iv: Array.from(iv),
        ciphertext: Array.from(new Uint8Array(ciphertext))
      };
    }

Why a fresh IV for every message?

AES-GCM requires a unique 12-byte initialization vector for each encryption with the same key. Reusing an IV would let an attacker XOR two ciphertexts together and recover the plaintext.

Using crypto.getRandomValues(new Uint8Array(12)) generates a cryptographically random IV. The IV is not secret and gets sent alongside the ciphertext so the receiver can decrypt.

  • Add the decryptMessage function directly after encryptMessage:
    async function decryptMessage(encryptedData) {
      // Reconstruct the IV and ciphertext as typed arrays
      const iv = new Uint8Array(encryptedData.iv);
      const ciphertext = new Uint8Array(encryptedData.ciphertext);
      // Decrypt using AES-256-GCM with the same shared key
      const decrypted = await window.crypto.subtle.decrypt(
        { name: 'AES-GCM', iv },
        sharedKey,
        ciphertext
      );
      // Decode the bytes back into a string
      const decoder = new TextDecoder();
      return decoder.decode(decrypted);
    }

What does this code do?

The decrypt function reverses the encryption process. It reconstructs the IV and ciphertext from the plain arrays received over the network, then calls crypto.subtle.decrypt with the same shared key.

AES-GCM is authenticated encryption. If anyone tampers with the ciphertext in transit, decryption will throw an error instead of producing garbled text. This gives you both confidentiality and integrity.

  • Save index.html.
  • You will not see a visible change yet. The next substep wires these functions into the chat flow.
Wire up the send button and message handling

Now you need to connect the UI to the encryption functions. This means updating several existing functions to manage the security dashboard, adding message-handling functions, and registering event listeners.

  • In index.html, find the deriveSharedKey function. Locate the line that updates keyExchangeStatus:
      document.getElementById('keyExchangeStatus').innerHTML =
        '<span class="status-indicator secure"></span>ECDH P-256 complete';
  • Add the following three lines directly after that line, still inside the deriveSharedKey function:
      document.getElementById('messageEncryption').innerHTML =
        '<span class="status-indicator secure"></span>AES-256-GCM active';
      document.getElementById('messageInput').disabled = false;
      document.getElementById('sendBtn').disabled = false;

What does this change do?

Once the shared key is derived, the message encryption status updates to show AES-256-GCM is active. The chat input and send button are enabled so the user can start typing. Before this point, sending a message would fail because there is no key to encrypt with.

  • Find the createPeerConnection function. Inside the peerConnection.ontrack callback, find the line that sets connectionStatus:
        document.getElementById('connectionStatus').innerHTML = '<span class="status-indicator secure"></span>Peer connected - Encrypted video active';
  • Add this line directly before the connectionStatus update, so the transport encryption status is set when the remote video arrives:
        document.getElementById('transportEncryption').innerHTML =
          '<span class="status-indicator secure"></span>DTLS-SRTP active';
  • Find the connectToSignalingServer function. Inside the switch statement, find the case 'peer-left': line. Add this new case directly before it:
          case 'encrypted-message':
            await handleEncryptedMessage(message.data);
            break;

What does this case handle?

When the signaling server forwards a message with type: 'encrypted-message', this case passes the encrypted data to handleEncryptedMessage for decryption and display. The server only ever sees the arrays of numbers (ciphertext), never the plaintext.

  • Now add the three message-handling functions. Place the following code after the handlePeerPublicKey function:
    async function sendMessage() {
      const input = document.getElementById('messageInput');
      const text = input.value.trim();
      if (!text || !sharedKey) return;

      // Encrypt the message before sending
      const encrypted = await encryptMessage(text);
      ws.send(JSON.stringify({
        type: 'encrypted-message',
        target: peerId,
        data: encrypted
      }));

      // Display the sent message locally
      displayMessage(text, 'sent');
      input.value = '';
    }

    async function handleEncryptedMessage(data) {
      // Decrypt the received ciphertext
      const plaintext = await decryptMessage(data);
      displayMessage(plaintext, 'received');
    }

    function displayMessage(text, type) {
      const messagesDiv = document.getElementById('messages');
      const messageEl = document.createElement('div');
      messageEl.className = `message ${type}`;
      messageEl.innerHTML = `
        ${text}
        <div class="meta">${type === 'sent' ? 'Sent' : 'Received'} - AES-256-GCM encrypted</div>
      `;
      messagesDiv.appendChild(messageEl);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }

What do these functions do?

  • sendMessage() reads the input, encrypts the text, sends the ciphertext via the signaling server, and displays the original text locally.
  • handleEncryptedMessage() takes the received ciphertext, decrypts it with the shared key, and passes the plaintext to the display function.
  • displayMessage() renders messages in the chat area with a meta label confirming AES-256-GCM encryption was used.
  • Find the handlePeerDisconnect function. Add these two lines inside it, before the if (peerConnection) check:
      document.getElementById('messageInput').disabled = true;
      document.getElementById('sendBtn').disabled = true;
  • Finally, find the init(); call at the very bottom of the script. Add these event listeners directly before it:
    document.getElementById('sendBtn').addEventListener('click', sendMessage);
    document.getElementById('messageInput').addEventListener('keypress', (e) => {
      if (e.key === 'Enter') sendMessage();
    });

What do these listeners do?

The first listener fires sendMessage() when the user clicks the Send button. The second fires it when the user presses Enter in the input field. Both paths encrypt the message before transmission.

  • Save index.html.
  • Refresh both browser tabs at http://localhost:3000. Wait for the video connection to establish and the key exchange to complete.
  • Type a message in one tab and press Enter. You should see it appear decrypted in the other tab with the label "AES-256-GCM encrypted" underneath.
  • Check the security dashboard on the right. All three indicators should show green: DTLS-SRTP active, ECDH P-256 complete, and AES-256-GCM active.

How can I prove the server cannot read my messages?

Open the browser DevTools in one tab (press F12). Go to the Network tab and filter by WS (WebSocket). Click the active WebSocket connection and look at the Messages panel.

When you send a chat message, you will see the JSON payload contains only arrays of numbers (the IV and ciphertext), not your plaintext. This proves the signaling server relays data it cannot read.

Messages not appearing in the other tab?

  • Make sure the chat input is not greyed out. If it is still disabled, the key exchange did not complete. Refresh both tabs and wait for the fingerprints to appear in the security dashboard.
  • Check the browser console for errors. A DOMException: The operation failed error on decrypt means the shared keys do not match. Close both tabs, refresh, and reconnect.
  • Verify you added the case 'encrypted-message': to the switch statement. Without it, incoming messages are silently dropped.

Help me debug my encrypted chat messages.

✔️ Awesome, I've got everything!

Great. Double check that you have saved index.html and that encrypted messages flow between both tabs.

ⓧ I'd like to double check the full code

Here is the complete index.html at this point. Compare yours against it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Secure P2P Video Chat</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
    .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
    h1 { text-align: center; margin-bottom: 20px; color: #00d4aa; }

    .video-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
    .video-container { position: relative; background: #16213e; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; }
    .video-container video { width: 100%; height: 100%; object-fit: cover; }
    .video-label { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.7); padding: 4px 8px; border-radius: 4px; font-size: 12px; }

    .chat-section { display: grid; grid-template-columns: 1fr 300px; gap: 16px; }
    .chat-box { background: #16213e; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; height: 300px; }
    .messages { flex: 1; overflow-y: auto; margin-bottom: 12px; }
    .message { margin-bottom: 8px; padding: 8px 12px; border-radius: 8px; max-width: 80%; }
    .message.sent { background: #0f3460; margin-left: auto; }
    .message.received { background: #2a2a4e; }
    .message .meta { font-size: 10px; color: #888; margin-top: 4px; }
    .chat-input { display: flex; gap: 8px; }
    .chat-input input { flex: 1; padding: 10px; border: 1px solid #333; border-radius: 8px; background: #1a1a2e; color: #e0e0e0; outline: none; }
    .chat-input button { padding: 10px 20px; border: none; border-radius: 8px; background: #00d4aa; color: #1a1a2e; font-weight: bold; cursor: pointer; }
    .chat-input button:disabled { background: #555; cursor: not-allowed; }

    .security-panel { background: #16213e; border-radius: 12px; padding: 16px; }
    .security-panel h3 { color: #00d4aa; margin-bottom: 12px; font-size: 14px; }
    .security-item { margin-bottom: 12px; padding: 8px; background: #1a1a2e; border-radius: 8px; }
    .security-item .label { font-size: 11px; color: #888; margin-bottom: 4px; }
    .security-item .value { font-size: 12px; font-family: monospace; word-break: break-all; }
    .status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
    .status-indicator.secure { background: #00d4aa; }
    .status-indicator.waiting { background: #ffa500; }
    .status-indicator.disconnected { background: #ff4444; }

    .connection-status { text-align: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 20px; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Secure P2P Video Chat</h1>

    <div class="connection-status" id="connectionStatus">
      <span class="status-indicator waiting"></span>
      Waiting for peer to connect...
    </div>

    <div class="video-grid">
      <div class="video-container">
        <video id="localVideo" autoplay muted playsinline></video>
        <div class="video-label">You (Local)</div>
      </div>
      <div class="video-container">
        <video id="remoteVideo" autoplay playsinline></video>
        <div class="video-label">Peer (Remote)</div>
      </div>
    </div>

    <div class="chat-section">
      <div class="chat-box">
        <div class="messages" id="messages"></div>
        <div class="chat-input">
          <input type="text" id="messageInput" placeholder="Type an encrypted message..." disabled>
          <button id="sendBtn" disabled>Send</button>
        </div>
      </div>

      <div class="security-panel">
        <h3>Security Dashboard</h3>
        <div class="security-item">
          <div class="label">Connection Encryption</div>
          <div class="value" id="transportEncryption">
            <span class="status-indicator waiting"></span>Waiting...
          </div>
        </div>
        <div class="security-item">
          <div class="label">Key Exchange</div>
          <div class="value" id="keyExchangeStatus">
            <span class="status-indicator waiting"></span>Not started
          </div>
        </div>
        <div class="security-item">
          <div class="label">Message Encryption</div>
          <div class="value" id="messageEncryption">
            <span class="status-indicator waiting"></span>AES-256-GCM (pending key)
          </div>
        </div>
        <div class="security-item">
          <div class="label">Your Public Key (first 16 bytes)</div>
          <div class="value" id="localKeyFingerprint">Generating...</div>
        </div>
        <div class="security-item">
          <div class="label">Peer Public Key (first 16 bytes)</div>
          <div class="value" id="remoteKeyFingerprint">Waiting for peer...</div>
        </div>
      </div>
    </div>
  </div>

  <script>
    let ws;
    let myId;
    let peerId;
    let peerConnection;
    let localStream;
    let myKeyPair;
    let sharedKey;

    async function init() {
      await generateKeyPair();
      await startLocalVideo();
      connectToSignalingServer();
    }

    async function generateKeyPair() {
      myKeyPair = await window.crypto.subtle.generateKey(
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        ['deriveKey']
      );
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      const fingerprint = Array.from(new Uint8Array(publicKeyRaw).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('localKeyFingerprint').textContent = fingerprint;
    }

    async function deriveSharedKey(peerPublicKey) {
      sharedKey = await window.crypto.subtle.deriveKey(
        { name: 'ECDH', public: peerPublicKey },
        myKeyPair.privateKey,
        { name: 'AES-GCM', length: 256 },
        false,
        ['encrypt', 'decrypt']
      );
      document.getElementById('keyExchangeStatus').innerHTML =
        '<span class="status-indicator secure"></span>ECDH P-256 complete';
      document.getElementById('messageEncryption').innerHTML =
        '<span class="status-indicator secure"></span>AES-256-GCM active';
      document.getElementById('messageInput').disabled = false;
      document.getElementById('sendBtn').disabled = false;
    }

    async function encryptMessage(plaintext) {
      const encoder = new TextEncoder();
      const data = encoder.encode(plaintext);
      const iv = window.crypto.getRandomValues(new Uint8Array(12));
      const ciphertext = await window.crypto.subtle.encrypt(
        { name: 'AES-GCM', iv },
        sharedKey,
        data
      );
      return {
        iv: Array.from(iv),
        ciphertext: Array.from(new Uint8Array(ciphertext))
      };
    }

    async function decryptMessage(encryptedData) {
      const iv = new Uint8Array(encryptedData.iv);
      const ciphertext = new Uint8Array(encryptedData.ciphertext);
      const decrypted = await window.crypto.subtle.decrypt(
        { name: 'AES-GCM', iv },
        sharedKey,
        ciphertext
      );
      const decoder = new TextDecoder();
      return decoder.decode(decrypted);
    }

    async function startLocalVideo() {
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      document.getElementById('localVideo').srcObject = localStream;
    }

    function connectToSignalingServer() {
      ws = new WebSocket(`ws://${window.location.host}`);

      ws.onopen = () => {
        console.log('Connected to signaling server');
      };

      ws.onmessage = async (event) => {
        const message = JSON.parse(event.data);

        switch (message.type) {
          case 'assigned-id':
            myId = message.id;
            break;

          case 'peer-joined':
            peerId = message.peerId;
            await startCall();
            break;

          case 'offer':
            peerId = message.sender;
            await handleOffer(message);
            break;

          case 'answer':
            await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
            break;

          case 'ice-candidate':
            if (message.candidate) {
              await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
            }
            break;

          case 'public-key':
            await handlePeerPublicKey(message.key);
            break;

          case 'encrypted-message':
            await handleEncryptedMessage(message.data);
            break;

          case 'peer-left':
            handlePeerDisconnect();
            break;
        }
      };
    }

    function createPeerConnection() {
      peerConnection = new RTCPeerConnection({
        iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
      });

      localStream.getTracks().forEach(track => {
        peerConnection.addTrack(track, localStream);
      });

      peerConnection.ontrack = (event) => {
        document.getElementById('remoteVideo').srcObject = event.streams[0];
        document.getElementById('transportEncryption').innerHTML =
          '<span class="status-indicator secure"></span>DTLS-SRTP active';
        document.getElementById('connectionStatus').innerHTML =
          '<span class="status-indicator secure"></span>Peer connected - Encrypted video active';
      };

      peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          ws.send(JSON.stringify({
            type: 'ice-candidate',
            target: peerId,
            candidate: event.candidate
          }));
        }
      };
    }

    async function startCall() {
      createPeerConnection();
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);

      ws.send(JSON.stringify({
        type: 'offer',
        target: peerId,
        sdp: offer
      }));

      await sendPublicKey();
    }

    async function handleOffer(message) {
      createPeerConnection();
      await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);

      ws.send(JSON.stringify({
        type: 'answer',
        target: peerId,
        sdp: answer
      }));

      await sendPublicKey();
    }

    async function sendPublicKey() {
      const publicKeyRaw = await window.crypto.subtle.exportKey('raw', myKeyPair.publicKey);
      ws.send(JSON.stringify({
        type: 'public-key',
        target: peerId,
        key: Array.from(new Uint8Array(publicKeyRaw))
      }));
    }

    async function handlePeerPublicKey(keyData) {
      const publicKeyBuffer = new Uint8Array(keyData).buffer;
      const peerPublicKey = await window.crypto.subtle.importKey(
        'raw',
        publicKeyBuffer,
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        []
      );

      const fingerprint = Array.from(new Uint8Array(publicKeyBuffer).slice(0, 16))
        .map(b => b.toString(16).padStart(2, '0')).join(' ');
      document.getElementById('remoteKeyFingerprint').textContent = fingerprint;

      await deriveSharedKey(peerPublicKey);
    }

    async function sendMessage() {
      const input = document.getElementById('messageInput');
      const text = input.value.trim();
      if (!text || !sharedKey) return;

      const encrypted = await encryptMessage(text);
      ws.send(JSON.stringify({
        type: 'encrypted-message',
        target: peerId,
        data: encrypted
      }));

      displayMessage(text, 'sent');
      input.value = '';
    }

    async function handleEncryptedMessage(data) {
      const plaintext = await decryptMessage(data);
      displayMessage(plaintext, 'received');
    }

    function displayMessage(text, type) {
      const messagesDiv = document.getElementById('messages');
      const messageEl = document.createElement('div');
      messageEl.className = `message ${type}`;
      messageEl.innerHTML = `
        ${text}
        <div class="meta">${type === 'sent' ? 'Sent' : 'Received'} - AES-256-GCM encrypted</div>
      `;
      messagesDiv.appendChild(messageEl);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }

    function handlePeerDisconnect() {
      document.getElementById('connectionStatus').innerHTML =
        '<span class="status-indicator disconnected"></span>Peer disconnected';
      document.getElementById('remoteVideo').srcObject = null;
      document.getElementById('messageInput').disabled = true;
      document.getElementById('sendBtn').disabled = true;
      if (peerConnection) {
        peerConnection.close();
        peerConnection = null;
      }
      sharedKey = null;
      peerId = null;
    }

    document.getElementById('sendBtn').addEventListener('click', sendMessage);
    document.getElementById('messageInput').addEventListener('keypress', (e) => {
      if (e.key === 'Enter') sendMessage();
    });

    init();
  </script>
</body>
</html>

Your encrypted video chat is complete. You have three layers of security working together: DTLS-SRTP encrypts the video in transit, ECDH exchanges a shared secret without ever sending it over the wire, and AES-256-GCM encrypts every chat message so even your own server cannot read them. Next up, you can take on the Secret Mission and implement safety numbers verification, just like Signal does.

Secret mission

Implement Safety Numbers Verification

Your encrypted chat is secure, but how do you know you're actually talking to the right person? A man-in-the-middle attacker could intercept the key exchange and substitute their own keys. Implement the same safety numbers verification that Signal uses to let both peers confirm their keys match.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on localhost, so there are no ongoing costs.

Resources you used:

  • The Node.js signaling server process running on port 3000.
  • Project files: server.js, index.html, package.json, and the node_modules/ folder.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with the encrypted chat.

  • Your server keeps running at http://localhost:3000.
  • To restart it later, open a terminal in your project folder and run node server.js.
  • Open two browser tabs to http://localhost:3000 to start a new secure video chat session.

Pause - I'll come back to this later

Stop the running server to free up the terminal. Your files stay on disk so you can restart anytime.

  • Switch back to the terminal running the server.
  • Press Ctrl+C to stop the Node.js process.
  • Close the two browser tabs.
  • When you want to resume, open your project folder in VS Code, run node server.js, and open two tabs to http://localhost:3000.

Delete - I don't want to use this again

Remove all project files and stop the server. This frees up disk space and fully cleans your environment.

  • Switch back to the terminal running the server.
  • Press Ctrl+C to stop the Node.js process.
  • Close the two browser tabs.
  • Navigate to your Desktop and delete the project folder by running these commands:
cd ~/Desktop
rm -rf secure-video-chat

Folder name different?

Replace secure-video-chat with whatever you named your project folder in Step 1.

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force secure-video-chat
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) and checking that the project folder no longer appears.

Nice Work!

Nice Work!

You just built a fully encrypted peer-to-peer video chat application from scratch. You now understand exactly how modern communication platforms like Signal and Teams protect their users with layered encryption.

You learned how to:

  • Build a WebSocket signaling server that coordinates peer connections without ever accessing message content.
  • Establish a WebRTC peer-to-peer video call with mandatory DTLS-SRTP transport encryption.
  • Implement ECDH key exchange and AES-256-GCM authenticated end-to-end encryption for text messages with per-message initialization vectors.
  • Secret Mission: Implemented safety numbers verification matching the pattern used by Signal for identity confirmation.

Ready to quiz yourself?