Build a Zero-PII Encrypted Messenger
Build a real-time E2E encrypted chat app where the server never sees plaintext.
Introduction
30 Second Summary
Every time you sign up for a messaging app, you hand over your phone number, your name, and your contact list. Your messages might be encrypted in transit, but the company behind the app still knows exactly who you are and who you talk to.
In this project, you will build a real-time encrypted messenger that requires zero personal information to use. Two users can generate cryptographic identities, exchange invite codes to connect, and send messages through a relay server that provably never sees the plaintext content.
What You'll Build
A privacy-first encrypted messenger using Node.js, WebSockets, and TweetNaCl public-key cryptography that runs entirely on your local machine.
By the end of this project, you'll have:
- A WebSocket relay server that routes encrypted message blobs without storing or decrypting them.
- A browser-based chat client with client-side key generation, encryption, and decryption using x25519-xsalsa20-poly1305.
- An invite-code system for zero-knowledge peer discovery that requires no personal information.
- Secret Mission: Add ephemeral messaging with automatic message expiry and a "burn after reading" mode.
Are there any prerequisites?
You should have basic JavaScript knowledge (variables, functions, events) and be comfortable running commands in a terminal. No prior experience with cryptography or WebSockets is needed.
Before We Start
Before We Start...
Set Up Your Development Environment
Before you can build the encrypted messenger, you need the right tools installed and a project folder ready to go.
In this step, you will install Node.js (the JavaScript runtime that powers your server), create your project folder, and set up the file structure you will build on throughout this project.
In this step, get ready to:
- Install Node.js and verify it works.
- Create your project folder and install the WebSocket dependency.
- Set up the file structure for both the server and client.
Install Node.js
Node.js is the JavaScript runtime that will power your relay server. It also comes with npm, a package manager you will use to install dependencies.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if Node.js is already installed by running this command:
node --version
✔️ I see a version number
You are good to go. Any version v18 or higher works for this project.
- Also confirm npm is available by running:
npm --version
You should see a version number like 10.x.x or higher.
ⓧ Command not found
- Go to https://nodejs.org in your browser.
- Download the LTS version (v24).
- Run the installer and accept all default options.
- Close your terminal completely and open a new one.
- Run node --version again to confirm the install worked.
Why do I need to close and reopen my terminal?
Your terminal loads its list of available commands when it first opens. Installing Node.js adds new commands, but your already-open terminal does not know about them yet. Opening a fresh terminal window picks up the new installation.
Create the project folder and install dependencies
Your encrypted messenger needs a dedicated folder and one server-side dependency: the ws package. This is a lightweight WebSocket library that handles real-time communication between the server and connected browser clients.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir encrypted-messenger
cd encrypted-messenger
- Initialize a new npm project by running:
npm init -y
You should see output showing a new package.json file was created with default values.
- Install the WebSocket server library by running:
npm install ws@8.20.0
You should see output that says added 1 package. This confirms ws was installed successfully.
Why ws instead of Socket.io?
The ws package is a pure WebSocket implementation with zero dependencies. Socket.io adds abstraction layers like polling fallback and rooms that are unnecessary for this project and would obscure the underlying protocol.
For a privacy-focused relay server, simpler means fewer places for things to leak.
Seeing a permission error or network timeout?
If you see EACCES on macOS, do not use sudo. Reinstall Node.js using the official installer from nodejs.org, which sets correct permissions.
If you see a network error, check your internet connection and try again. Corporate firewalls sometimes block the npm registry.
help me fix my npm install error
Create the project files
Your project needs two parts: a server file at the root, and a set of client files inside a public folder that browsers will load.
- Create the public folder and all empty project files by running these commands:
macOS
mkdir public
touch server.js public/index.html public/crypto.js public/app.js
Windows
mkdir public
New-Item server.js -ItemType File
New-Item public/index.html -ItemType File
New-Item public/crypto.js -ItemType File
New-Item public/app.js -ItemType File
- Confirm all files were created by running:
ls
ls public
The first command should show node_modules, package.json, package-lock.json, public, and server.js. The second should show app.js, crypto.js, and index.html.
What is each file for?
- server.js is the WebSocket relay server. It routes encrypted messages without ever reading them.
- public/index.html is the chat interface that runs in the browser.
- public/crypto.js handles identity generation, encryption, and decryption entirely client-side.
- public/app.js connects the UI to the WebSocket server and manages the chat flow.
Missing a file or folder?
Make sure you ran the mkdir public command before creating files inside it. The folder must exist first.
Check that your terminal is still inside the encrypted-messenger folder. Run pwd (macOS) or cd (Windows) to see your current location.
help me find my missing project files
Your development environment is ready. Next up, you will build the WebSocket relay server that routes encrypted messages between users without ever peeking at the content.
Build the WebSocket Relay Server
In an encrypted messenger, the server's job is to relay sealed envelopes between users without ever peeking inside.
In this step, you will write the relay server that accepts WebSocket connections, routes encrypted blobs between users by UUID, and serves your static front-end files. The server will never store or decrypt any message content.
In this step, get ready to:
- Create an HTTP server that serves static files from the public folder.
- Add WebSocket upgrade support so clients can establish persistent connections.
- Implement message routing that forwards encrypted payloads between users without inspecting them.
Write the static file server and WebSocket setup
The server needs to do two things: serve your HTML/JS/CSS files over HTTP, and upgrade connections to WebSocket for real-time messaging.
- Open server.js in your editor and add the following code:
const http = require('http');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');
const PORT = 3000;
// Simple static file server
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css'
};
const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, 'public', req.url === '/' ? 'index.html' : req.url);
const ext = path.extname(filePath);
const contentType = mimeTypes[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
});
});
// WebSocket relay server
const wss = new WebSocketServer({ server });
What does this code do?
- http, fs, and path are built-in Node.js modules for creating a web server, reading files, and constructing file paths.
- mimeTypes maps file extensions to content types so browsers know how to handle each file.
- The HTTP server reads files from your public folder and returns them to the browser. If a file is missing, it responds with a 404.
- WebSocketServer from the ws package attaches to the HTTP server and upgrades connections to persistent WebSocket channels.
- Save server.js.
You won't see a change yet. The next chunk adds the connection handler that makes the server useful.
Implement message routing and invite-code relay
When a client connects, the server needs to track which UUID maps to which WebSocket connection. It then routes messages by reading only the to field and forwarding the encrypted payload without opening it.
- In server.js, add the following code below the const wss = new WebSocketServer({ server }); line:
// Map of userId -> WebSocket connection
const connections = new Map();
wss.on('connection', (ws) => {
let userId = null;
ws.on('message', (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
} catch (e) {
return;
}
// Registration: client announces its UUID
if (msg.type === 'register') {
userId = msg.userId;
connections.set(userId, ws);
console.log(`[SERVER] User registered: ${userId}`);
return;
}
// Relay: forward encrypted payload to target user
if (msg.type === 'message' || msg.type === 'invite-accept') {
const targetWs = connections.get(msg.to);
if (targetWs && targetWs.readyState === 1) {
console.log(`[SERVER] Relaying ${msg.type} from ${userId} to ${msg.to}`);
if (msg.type === 'message') {
console.log(`[SERVER] Encrypted payload: ${msg.payload.substring(0, 60)}...`);
}
targetWs.send(JSON.stringify({ ...msg, from: userId }));
}
return;
}
// Invite: sender broadcasts invite acceptance
if (msg.type === 'invite-offer') {
const targetWs = connections.get(msg.to);
if (targetWs && targetWs.readyState === 1) {
targetWs.send(JSON.stringify({ ...msg, from: userId }));
}
return;
}
});
ws.on('close', () => {
if (userId) {
connections.delete(userId);
console.log(`[SERVER] User disconnected: ${userId}`);
}
});
});
What does this code do?
- connections is a Map that links each user's UUID to their active WebSocket. This is the only state the server holds, and only in memory.
- The register handler stores the user's UUID when they first connect.
- The message handler looks up the target UUID in the Map, then forwards the encrypted payload without decrypting it. The server only logs the first 60 characters of ciphertext to prove it never sees plaintext.
- The invite-offer and invite-accept handlers relay public key exchanges so two peers can discover each other without a central directory.
- On disconnect, the server removes the user from the Map to keep memory clean.
Why does the server never see plaintext?
The relay server only reads the type and to fields of each message. The actual content sits inside the payload field as an encrypted base64 string that the server forwards untouched.
This is the "dumb relay" pattern used in privacy-focused messengers. Even if the server is compromised, the attacker gets only encrypted blobs.
- Save server.js.
You won't see a change yet. The next chunk starts the server so you can test it.
Start the relay server
The final piece tells the server to start listening for connections on port 3000.
- In server.js, add this code at the very bottom of the file, below the closing }); of the wss.on('connection') block:
server.listen(PORT, () => {
console.log(`[SERVER] Encrypted messenger relay running on http://localhost:${PORT}`);
console.log(`[SERVER] Open two browser tabs to test.`);
console.log(`[SERVER] This server NEVER sees plaintext messages.`);
});
What does this code do?
server.listen(PORT) starts the HTTP server on port 3000. Because the WebSocketServer is attached to this HTTP server, WebSocket connections are automatically available on the same port.
- Save server.js.
- Run the server by entering this command in your terminal:
node server.js
You should see three lines of output in your terminal:
[SERVER] Encrypted messenger relay running on http://localhost:3000
[SERVER] Open two browser tabs to test.
[SERVER] This server NEVER sees plaintext messages.
Seeing an error instead?
- If you see Error: Cannot find module 'ws', make sure you ran npm install ws@8.20.0 inside the encrypted-messenger folder.
- If you see EADDRINUSE, port 3000 is already in use. Close any other processes on that port, or change the PORT value in your file temporarily.
- If you see a SyntaxError, double check your closing braces and parentheses match the full code below.
Still stuck? Help me debug my server.js startup error
- Press Ctrl+C in your terminal to stop the server for now.
Double check your full code matches what you should have so far.
✔️ Awesome, I've got everything!
Great. Make sure you have saved server.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js file. Make sure yours matches exactly.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');
const PORT = 3000;
// Simple static file server
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css'
};
const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, 'public', req.url === '/' ? 'index.html' : req.url);
const ext = path.extname(filePath);
const contentType = mimeTypes[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
});
});
// WebSocket relay server
const wss = new WebSocketServer({ server });
// Map of userId -> WebSocket connection
const connections = new Map();
wss.on('connection', (ws) => {
let userId = null;
ws.on('message', (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
} catch (e) {
return;
}
// Registration: client announces its UUID
if (msg.type === 'register') {
userId = msg.userId;
connections.set(userId, ws);
console.log(`[SERVER] User registered: ${userId}`);
return;
}
// Relay: forward encrypted payload to target user
if (msg.type === 'message' || msg.type === 'invite-accept') {
const targetWs = connections.get(msg.to);
if (targetWs && targetWs.readyState === 1) {
console.log(`[SERVER] Relaying ${msg.type} from ${userId} to ${msg.to}`);
if (msg.type === 'message') {
console.log(`[SERVER] Encrypted payload: ${msg.payload.substring(0, 60)}...`);
}
targetWs.send(JSON.stringify({ ...msg, from: userId }));
}
return;
}
// Invite: sender broadcasts invite acceptance
if (msg.type === 'invite-offer') {
const targetWs = connections.get(msg.to);
if (targetWs && targetWs.readyState === 1) {
targetWs.send(JSON.stringify({ ...msg, from: userId }));
}
return;
}
});
ws.on('close', () => {
if (userId) {
connections.delete(userId);
console.log(`[SERVER] User disconnected: ${userId}`);
}
});
});
server.listen(PORT, () => {
console.log(`[SERVER] Encrypted messenger relay running on http://localhost:${PORT}`);
console.log(`[SERVER] Open two browser tabs to test.`);
console.log(`[SERVER] This server NEVER sees plaintext messages.`);
});
Your relay server is live and ready to forward encrypted messages. Next up, you will write the client-side cryptography module that generates keypairs and encrypts messages before they ever leave the browser.
Implement Client-Side Cryptographic Identity
Your relay server is ready to forward encrypted blobs between users. But right now, there is nothing producing those encrypted blobs.
In this step, you will write the client-side cryptography module that handles everything the server must never touch. Identity generation, key creation, encryption, and decryption all happen in the browser.
In this step, get ready to:
- Generate a cryptographic identity (UUID + keypair) entirely in the browser.
- Build invite-code functions for zero-knowledge peer discovery.
- Implement end-to-end encryption and decryption using public-key cryptography.
Generate a cryptographic identity and invite codes
Traditional messengers tie your identity to a phone number or email address. This messenger uses a UUID (a random string like a1b2c3d4-e5f6-...) paired with a cryptographic keypair. No personal information required.
The module also needs invite-code functions so two users can exchange public keys without a central directory. An invite code is just a base64-encoded bundle of someone's UUID and public key.
- Open public/crypto.js in your editor and paste the following code:
// Zero-PII Identity and Encryption Module
const ZeroCrypto = (() => {
// Generate a UUID-based identity (no PII required)
function generateIdentity() {
const userId = crypto.randomUUID();
const keyPair = nacl.box.keyPair();
return {
userId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey
};
}
// Create an invite code containing userId and public key
function createInviteCode(identity) {
const data = {
userId: identity.userId,
publicKey: nacl.util.encodeBase64(identity.publicKey)
};
return btoa(JSON.stringify(data));
}
// Parse an invite code to extract peer's userId and public key
function parseInviteCode(code) {
try {
const data = JSON.parse(atob(code));
return {
userId: data.userId,
publicKey: nacl.util.decodeBase64(data.publicKey)
};
} catch (e) {
return null;
}
}
What does this code do?
This is the first half of a module wrapped in an IIFE (Immediately Invoked Function Expression). The IIFE pattern keeps all the functions private until we explicitly return them at the end.
- generateIdentity() creates a random UUID with the browser's built-in crypto.randomUUID() and a keypair using TweetNaCl's nacl.box.keyPair(). The secret key never leaves the browser.
- createInviteCode() bundles a user's UUID and public key into a single base64 string they can share out-of-band (like pasting in a chat).
- parseInviteCode() reverses the process, extracting the peer's UUID and public key from the code. It returns null if the code is invalid.
What is public-key cryptography?
Public-key cryptography uses a pair of mathematically linked keys. Your public key can be shared with anyone. Your secret key must stay private.
When someone encrypts a message with your public key, only your secret key can decrypt it. This means two people can communicate securely without ever sharing a secret password.
- Save crypto.js but keep it open. You will add the encryption functions directly below this code in the next substep.
Encrypt messages with public-key cryptography
The encryptMessage function takes plaintext and transforms it into an encrypted blob that only the intended recipient can read. It uses the x25519-xsalsa20-poly1305 algorithm through TweetNaCl's nacl.box() function.
- In public/crypto.js, add the following function directly below the parseInviteCode function's closing brace:
// Encrypt a message for a specific peer
function encryptMessage(plaintext, theirPublicKey, mySecretKey) {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const messageBytes = nacl.util.decodeUTF8(plaintext);
const encrypted = nacl.box(messageBytes, nonce, theirPublicKey, mySecretKey);
if (!encrypted) {
throw new Error('Encryption failed');
}
// Prepend nonce to ciphertext
const fullMessage = new Uint8Array(nonce.length + encrypted.length);
fullMessage.set(nonce);
fullMessage.set(encrypted, nonce.length);
return nacl.util.encodeBase64(fullMessage);
}
What does this code do?
- nacl.randomBytes(nacl.box.nonceLength) generates a random 24-byte nonce. A nonce is a one-time-use number that ensures encrypting the same message twice produces different ciphertext.
- nacl.box() performs authenticated encryption. It encrypts AND verifies the message has not been tampered with.
- The nonce is prepended to the ciphertext before encoding. The recipient needs the same nonce to decrypt, so it travels alongside the encrypted data.
- The final result is a base64 string. This is what the relay server sees. It cannot decrypt this without the secret key.
Why prepend the nonce?
The nonce is not a secret. It just needs to be unique per message. By prepending it to the ciphertext, the recipient can slice it off and use it for decryption without needing a separate channel to receive it.
- Save crypto.js. You won't see a change yet. The decryption function comes next to complete the module.
Decrypt incoming messages
Decryption reverses the process. It takes the base64 blob, extracts the nonce, and uses nacl.box.open() to recover the original plaintext. If the message was tampered with or the wrong key is used, decryption fails gracefully by returning null.
- In public/crypto.js, add the following function directly below the encryptMessage function's closing brace:
// Decrypt a message from a specific peer
function decryptMessage(encryptedBase64, theirPublicKey, mySecretKey) {
try {
const fullMessage = nacl.util.decodeBase64(encryptedBase64);
const nonce = fullMessage.slice(0, nacl.box.nonceLength);
const ciphertext = fullMessage.slice(nacl.box.nonceLength);
const decrypted = nacl.box.open(ciphertext, nonce, theirPublicKey, mySecretKey);
if (!decrypted) {
return null;
}
return nacl.util.encodeUTF8(decrypted);
} catch (e) {
return null;
}
}
return {
generateIdentity,
createInviteCode,
parseInviteCode,
encryptMessage,
decryptMessage
};
})();
What does this code do?
- nacl.util.decodeBase64() converts the base64 string back into raw bytes.
- fullMessage.slice(0, nacl.box.nonceLength) extracts the first 24 bytes as the nonce. The rest is the ciphertext.
- nacl.box.open() decrypts and verifies authenticity in one step. It returns null if the message was tampered with or the wrong key was used.
- The return { ... } block at the end exposes all five functions as the public API of the ZeroCrypto module.
- Save crypto.js.
You won't see any visible output yet because the HTML page has not been built. The crypto module will be loaded by the browser in the next step when you write index.html.
Seeing a syntax error later?
The most common issue with this file is a missing closing })(); at the very end. Make sure the last line of your file is })(); which closes the IIFE.
Also check that you have exactly five function names listed inside the return { ... } block, separated by commas.
Help me debug a syntax error in my crypto.js IIFE module.
✔️ Awesome, I've got everything!
Great. Double check you have saved public/crypto.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete public/crypto.js file. Make sure yours matches exactly:
// Zero-PII Identity and Encryption Module
const ZeroCrypto = (() => {
// Generate a UUID-based identity (no PII required)
function generateIdentity() {
const userId = crypto.randomUUID();
const keyPair = nacl.box.keyPair();
return {
userId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey
};
}
// Create an invite code containing userId and public key
function createInviteCode(identity) {
const data = {
userId: identity.userId,
publicKey: nacl.util.encodeBase64(identity.publicKey)
};
return btoa(JSON.stringify(data));
}
// Parse an invite code to extract peer's userId and public key
function parseInviteCode(code) {
try {
const data = JSON.parse(atob(code));
return {
userId: data.userId,
publicKey: nacl.util.decodeBase64(data.publicKey)
};
} catch (e) {
return null;
}
}
// Encrypt a message for a specific peer
function encryptMessage(plaintext, theirPublicKey, mySecretKey) {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const messageBytes = nacl.util.decodeUTF8(plaintext);
const encrypted = nacl.box(messageBytes, nonce, theirPublicKey, mySecretKey);
if (!encrypted) {
throw new Error('Encryption failed');
}
// Prepend nonce to ciphertext
const fullMessage = new Uint8Array(nonce.length + encrypted.length);
fullMessage.set(nonce);
fullMessage.set(encrypted, nonce.length);
return nacl.util.encodeBase64(fullMessage);
}
// Decrypt a message from a specific peer
function decryptMessage(encryptedBase64, theirPublicKey, mySecretKey) {
try {
const fullMessage = nacl.util.decodeBase64(encryptedBase64);
const nonce = fullMessage.slice(0, nacl.box.nonceLength);
const ciphertext = fullMessage.slice(nacl.box.nonceLength);
const decrypted = nacl.box.open(ciphertext, nonce, theirPublicKey, mySecretKey);
if (!decrypted) {
return null;
}
return nacl.util.encodeUTF8(decrypted);
} catch (e) {
return null;
}
}
return {
generateIdentity,
createInviteCode,
parseInviteCode,
encryptMessage,
decryptMessage
};
})();
Your encryption engine is complete. Every message will be locked with a unique nonce and the recipient's public key. Next up, you will build the chat UI and wire it to the WebSocket server so two browser tabs can actually talk to each other.
Build the Chat UI and WebSocket Client
Your server can relay encrypted blobs and your crypto module can encrypt and decrypt messages. The missing pieces are what the user actually sees and interacts with.
In this step, you will build the browser-based chat interface and the client-side JavaScript that connects to your relay server. The client generates a cryptographic identity on page load, manages peer discovery through invite codes, and encrypts every outgoing message before it ever leaves the browser.
In this step, get ready to:
- Write the HTML chat interface with a dark theme, invite section, and message area.
- Build the WebSocket client that auto-connects, registers your UUID, and handles incoming messages.
- Implement the invite-code flow for zero-knowledge peer discovery and key exchange.
Write the chat interface HTML
The HTML file is the user-facing layer of your messenger. It needs a header showing the user's UUID, a connection status bar, an invite-code section for peer discovery, a scrollable message log, and a text input for composing messages.
- Open public/index.html in your editor and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zero-PII Encrypted Messenger</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; height: 100vh; display: flex; flex-direction: column; }
.header { background: #161b22; padding: 12px 20px; border-bottom: 1px solid #30363d; }
.header h1 { font-size: 16px; color: #58a6ff; }
.header .identity { font-size: 12px; color: #8b949e; margin-top: 4px; font-family: monospace; }
.status { font-size: 12px; padding: 8px 20px; background: #1c2128; border-bottom: 1px solid #30363d; }
.status.connected { color: #3fb950; }
.status.disconnected { color: #f85149; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.invite-section { padding: 12px 20px; background: #161b22; border-bottom: 1px solid #30363d; }
.invite-section h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; }
.invite-row { display: flex; gap: 8px; margin-bottom: 8px; }
.invite-row input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-size: 12px; font-family: monospace; }
.invite-row button { background: #238636; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.invite-row button:hover { background: #2ea043; }
.messages { flex: 1; overflow-y: auto; padding: 20px; }
.message { margin-bottom: 12px; padding: 10px 14px; border-radius: 8px; max-width: 70%; word-wrap: break-word; }
.message.sent { background: #1f6feb; margin-left: auto; }
.message.received { background: #21262d; border: 1px solid #30363d; }
.message .meta { font-size: 10px; color: #8b949e; margin-top: 4px; }
.input-area { padding: 12px 20px; background: #161b22; border-top: 1px solid #30363d; display: flex; gap: 8px; }
.input-area input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 10px 14px; border-radius: 6px; font-size: 14px; }
.input-area button { background: #238636; color: #fff; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
.input-area button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
.crypto-display { padding: 8px 20px; background: #1c2128; border-top: 1px solid #30363d; font-size: 11px; font-family: monospace; color: #f0883e; max-height: 60px; overflow-y: auto; }
</style>
</head>
<body>
<div class="header">
<h1>Zero-PII Encrypted Messenger</h1>
<div class="identity">Your ID: <span id="userId">Generating...</span></div>
</div>
<div class="status disconnected" id="status">Disconnected</div>
<div class="main">
<div class="invite-section">
<h3>Peer Discovery (No Contact List Required)</h3>
<div class="invite-row">
<input type="text" id="myInviteCode" readonly placeholder="Your invite code will appear here">
<button id="generateInvite">Generate Invite</button>
</div>
<div class="invite-row">
<input type="text" id="peerInviteCode" placeholder="Paste peer's invite code here">
<button id="acceptInvite">Connect</button>
</div>
</div>
<div class="messages" id="messages"></div>
<div class="crypto-display" id="cryptoDisplay">Encrypted payloads will appear here...</div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Type a message..." disabled>
<button id="sendBtn" disabled>Send</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tweetnacl/1.0.3/nacl-fast.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tweetnacl-util/0.15.1/nacl-util.min.js"></script>
<script src="crypto.js"></script>
<script src="app.js"></script>
</body>
</html>
What does this code do?
The HTML sets up a full-screen dark-themed chat interface with several key sections.
- The #userId span displays the user's generated UUID. The #status div toggles between green (connected) and red (disconnected) text via CSS classes.
- The invite section has two rows: one for generating your own invite code and one for pasting a peer's code. This is how two users discover each other without a central contact server.
- The #messageInput and #sendBtn start disabled. They only activate after a peer connection is established.
- The #cryptoDisplay bar at the bottom shows the raw encrypted payload that the server handles. This lets you visually prove the server never sees plaintext.
- The four <script> tags load TweetNaCl from a CDN (no bundler needed), then your crypto.js module, then app.js which ties everything together.
- Save public/index.html.
You won't see anything in the browser yet. The page needs app.js to function, which you will write next.
Seeing a syntax error in your editor?
Make sure the opening <!DOCTYPE html> is on the very first line with no blank lines above it.
Check that all <div> tags have matching closing </div> tags. A missing closing tag will break the layout.
help me fix an HTML syntax error in my index.html
✔️ Awesome, I've got everything!
Great. Double check you have saved public/index.html before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zero-PII Encrypted Messenger</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; height: 100vh; display: flex; flex-direction: column; }
.header { background: #161b22; padding: 12px 20px; border-bottom: 1px solid #30363d; }
.header h1 { font-size: 16px; color: #58a6ff; }
.header .identity { font-size: 12px; color: #8b949e; margin-top: 4px; font-family: monospace; }
.status { font-size: 12px; padding: 8px 20px; background: #1c2128; border-bottom: 1px solid #30363d; }
.status.connected { color: #3fb950; }
.status.disconnected { color: #f85149; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.invite-section { padding: 12px 20px; background: #161b22; border-bottom: 1px solid #30363d; }
.invite-section h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; }
.invite-row { display: flex; gap: 8px; margin-bottom: 8px; }
.invite-row input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-size: 12px; font-family: monospace; }
.invite-row button { background: #238636; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.invite-row button:hover { background: #2ea043; }
.messages { flex: 1; overflow-y: auto; padding: 20px; }
.message { margin-bottom: 12px; padding: 10px 14px; border-radius: 8px; max-width: 70%; word-wrap: break-word; }
.message.sent { background: #1f6feb; margin-left: auto; }
.message.received { background: #21262d; border: 1px solid #30363d; }
.message .meta { font-size: 10px; color: #8b949e; margin-top: 4px; }
.input-area { padding: 12px 20px; background: #161b22; border-top: 1px solid #30363d; display: flex; gap: 8px; }
.input-area input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 10px 14px; border-radius: 6px; font-size: 14px; }
.input-area button { background: #238636; color: #fff; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
.input-area button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
.crypto-display { padding: 8px 20px; background: #1c2128; border-top: 1px solid #30363d; font-size: 11px; font-family: monospace; color: #f0883e; max-height: 60px; overflow-y: auto; }
</style>
</head>
<body>
<div class="header">
<h1>Zero-PII Encrypted Messenger</h1>
<div class="identity">Your ID: <span id="userId">Generating...</span></div>
</div>
<div class="status disconnected" id="status">Disconnected</div>
<div class="main">
<div class="invite-section">
<h3>Peer Discovery (No Contact List Required)</h3>
<div class="invite-row">
<input type="text" id="myInviteCode" readonly placeholder="Your invite code will appear here">
<button id="generateInvite">Generate Invite</button>
</div>
<div class="invite-row">
<input type="text" id="peerInviteCode" placeholder="Paste peer's invite code here">
<button id="acceptInvite">Connect</button>
</div>
</div>
<div class="messages" id="messages"></div>
<div class="crypto-display" id="cryptoDisplay">Encrypted payloads will appear here...</div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Type a message..." disabled>
<button id="sendBtn" disabled>Send</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tweetnacl/1.0.3/nacl-fast.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tweetnacl-util/0.15.1/nacl-util.min.js"></script>
<script src="crypto.js"></script>
<script src="app.js"></script>
</body>
</html>
Write the WebSocket client logic
The app.js file is the brain of the client. It connects to your relay server over WebSockets, registers the user's UUID, and routes incoming messages to the decryption layer before displaying them.
- Open public/app.js and paste the following state and connection code:
// Application State
const state = {
identity: null,
peer: null,
ws: null
};
// DOM Elements
const elements = {
userId: document.getElementById('userId'),
status: document.getElementById('status'),
myInviteCode: document.getElementById('myInviteCode'),
generateInvite: document.getElementById('generateInvite'),
peerInviteCode: document.getElementById('peerInviteCode'),
acceptInvite: document.getElementById('acceptInvite'),
messages: document.getElementById('messages'),
cryptoDisplay: document.getElementById('cryptoDisplay'),
messageInput: document.getElementById('messageInput'),
sendBtn: document.getElementById('sendBtn')
};
// Initialize identity
function init() {
state.identity = ZeroCrypto.generateIdentity();
elements.userId.textContent = state.identity.userId;
connectWebSocket();
}
// WebSocket connection
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(`${protocol}//${window.location.host}`);
state.ws.onopen = () => {
setStatus('Connected to relay server', true);
// Register our UUID with the server
state.ws.send(JSON.stringify({
type: 'register',
userId: state.identity.userId
}));
};
state.ws.onclose = () => {
setStatus('Disconnected from relay server', false);
setTimeout(connectWebSocket, 3000);
};
state.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleIncomingMessage(msg);
};
}
What does this code do?
- The state object holds the user's cryptographic identity, peer info, and WebSocket reference. Everything stays in memory only.
- The elements object caches DOM references so you only query the DOM once at startup.
- The init() function generates a fresh UUID and keypair using the ZeroCrypto module from the previous step, then opens a WebSocket connection.
- The connectWebSocket() function auto-detects the protocol (ws or wss), sends a register message on open so the server knows where to route messages to this user, and auto-reconnects after 3 seconds if the connection drops.
You won't see a change yet. The next chunk adds the message handler that completes the connection logic.
- Below the connectWebSocket() function, add the incoming message handler:
// Handle incoming WebSocket messages
function handleIncomingMessage(msg) {
if (msg.type === 'invite-offer') {
// Someone is accepting our invite and sending their public key
state.peer = {
userId: msg.from,
publicKey: nacl.util.decodeBase64(msg.publicKey)
};
enableChat();
addSystemMessage(`Peer connected: ${msg.from.substring(0, 8)}...`);
}
if (msg.type === 'invite-accept') {
// Our invite acceptance was acknowledged, peer sends their key
state.peer = {
userId: msg.from,
publicKey: nacl.util.decodeBase64(msg.publicKey)
};
enableChat();
addSystemMessage(`Peer connected: ${msg.from.substring(0, 8)}...`);
}
if (msg.type === 'message') {
// Decrypt and display the message
const plaintext = ZeroCrypto.decryptMessage(
msg.payload,
state.peer.publicKey,
state.identity.secretKey
);
if (plaintext) {
addMessage(plaintext, 'received');
showCryptoPayload(`Received (encrypted): ${msg.payload.substring(0, 80)}...`);
} else {
addSystemMessage('Failed to decrypt message (wrong key or tampered)');
}
}
}
What does this code do?
- The invite-offer and invite-accept handlers store the peer's public key (decoded from base64) into state.peer. This is the key exchange step that makes encryption possible.
- When a message arrives, it contains only an encrypted base64 blob. The handler decrypts it using the peer's public key and your secret key. If decryption fails (tampered or wrong key), it shows a warning instead.
You won't see a change yet. The invite-code flow in the next section triggers the handlers you just wrote.
Implement the invite-code flow and message sending
Traditional messengers use phone numbers or email addresses to find contacts. Your messenger uses invite codes instead. An invite code is a base64-encoded bundle of your UUID and public key. When User B pastes User A's code, both sides exchange public keys without any central directory knowing who they are.
- Below the handleIncomingMessage() function, add the invite-code event listeners:
// Generate invite code
elements.generateInvite.addEventListener('click', () => {
const code = ZeroCrypto.createInviteCode(state.identity);
elements.myInviteCode.value = code;
elements.myInviteCode.select();
document.execCommand('copy');
addSystemMessage('Invite code generated and copied to clipboard. Share it with your peer.');
});
// Accept an invite code
elements.acceptInvite.addEventListener('click', () => {
const code = elements.peerInviteCode.value.trim();
if (!code) return;
const peerInfo = ZeroCrypto.parseInviteCode(code);
if (!peerInfo) {
addSystemMessage('Invalid invite code.');
return;
}
state.peer = peerInfo;
// Send our public key to the peer
state.ws.send(JSON.stringify({
type: 'invite-offer',
to: peerInfo.userId,
publicKey: nacl.util.encodeBase64(state.identity.publicKey)
}));
// Also send an acceptance back so peer gets our key
state.ws.send(JSON.stringify({
type: 'invite-accept',
to: peerInfo.userId,
publicKey: nacl.util.encodeBase64(state.identity.publicKey)
}));
enableChat();
addSystemMessage(`Connected to peer: ${peerInfo.userId.substring(0, 8)}...`);
});
What does the invite flow do?
- The generateInvite handler creates a base64 code containing your UUID and public key, then copies it to your clipboard. You share this code out-of-band (paste it in another chat, email, or read it aloud).
- The acceptInvite handler parses the peer's invite code to extract their UUID and public key. It then sends two messages through the relay: an invite-offer and an invite-accept. This delivers your public key to the peer so they can encrypt messages to you.
- The server never learns what's inside the invite code. It only sees the to field (a UUID) for routing purposes.
You won't see a change yet. The next chunk adds the message sending function and UI helpers that the code above depends on.
- Below the invite-code listeners, add the message sending logic:
// Send a message
function sendMessage() {
const plaintext = elements.messageInput.value.trim();
if (!plaintext || !state.peer) return;
const encrypted = ZeroCrypto.encryptMessage(
plaintext,
state.peer.publicKey,
state.identity.secretKey
);
state.ws.send(JSON.stringify({
type: 'message',
to: state.peer.userId,
payload: encrypted
}));
addMessage(plaintext, 'sent');
showCryptoPayload(`Sent (encrypted): ${encrypted.substring(0, 80)}...`);
elements.messageInput.value = '';
}
elements.sendBtn.addEventListener('click', sendMessage);
elements.messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
What does this code do?
- The sendMessage() function encrypts the plaintext using the peer's public key and your secret key. The server only receives the encrypted payload blob.
- The showCryptoPayload() call updates the orange bar at the bottom so you can see the raw ciphertext the server handles. This is visual proof of end-to-end encryption.
- Both the Send button click and the Enter key trigger the same sendMessage() function for convenience.
You won't see a change yet. The UI helper functions below complete the file.
- Below the event listeners, add the UI helper functions and the init() call that starts the app:
// UI Helpers
function setStatus(text, connected) {
elements.status.textContent = text;
elements.status.className = connected ? 'status connected' : 'status disconnected';
}
function enableChat() {
elements.messageInput.disabled = false;
elements.sendBtn.disabled = false;
elements.messageInput.focus();
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.textContent = text;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = new Date().toLocaleTimeString();
div.appendChild(meta);
elements.messages.appendChild(div);
elements.messages.scrollTop = elements.messages.scrollHeight;
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.style.textAlign = 'center';
div.style.color = '#8b949e';
div.style.fontSize = '12px';
div.style.margin = '8px 0';
div.textContent = text;
elements.messages.appendChild(div);
elements.messages.scrollTop = elements.messages.scrollHeight;
}
function showCryptoPayload(text) {
elements.cryptoDisplay.textContent = text;
}
// Start the application
init();
What do the helpers do?
- setStatus() toggles the status bar between green "Connected" and red "Disconnected" by swapping CSS classes.
- enableChat() removes the disabled attribute from the message input and send button. This only happens after a peer connects, ensuring you cannot send messages to nobody.
- addMessage() creates a styled chat bubble (blue for sent, dark for received) with a timestamp. addSystemMessage() creates centered gray text for connection events.
- The final init() call at the bottom kicks off the entire application: identity generation, DOM update, and WebSocket connection.
- Save public/app.js.
- Verify the app loads by starting the server. Run this in your terminal:
node server.js
- Open your browser and navigate to http://localhost:3000.
You should see a dark-themed chat interface. The header displays your UUID (a long string like a3f7c2e1-...) and the status bar shows "Connected to relay server" in green text.
- Stop the server by pressing Ctrl+C in the terminal. You will do a full communication test in the next step.
Page is blank or shows an error?
- Open your browser's Developer Tools (press F12) and check the Console tab for red error messages. A common issue is a typo in a function name or a missing closing bracket in app.js.
- If you see "nacl is not defined", make sure the <script> tags in index.html load nacl-fast.min.js and nacl-util.min.js before crypto.js and app.js. Script order matters.
- If the status stays red ("Disconnected"), confirm the terminal shows [SERVER] Encrypted messenger relay running on http://localhost:3000. If not, stop and re-run node server.js.
help me debug my encrypted messenger UI
✔️ Awesome, I've got everything!
Great. Double check you have saved public/app.js before moving on.
ⓧ I'd like to double check the full code
// Application State
const state = {
identity: null,
peer: null,
ws: null
};
// DOM Elements
const elements = {
userId: document.getElementById('userId'),
status: document.getElementById('status'),
myInviteCode: document.getElementById('myInviteCode'),
generateInvite: document.getElementById('generateInvite'),
peerInviteCode: document.getElementById('peerInviteCode'),
acceptInvite: document.getElementById('acceptInvite'),
messages: document.getElementById('messages'),
cryptoDisplay: document.getElementById('cryptoDisplay'),
messageInput: document.getElementById('messageInput'),
sendBtn: document.getElementById('sendBtn')
};
// Initialize identity
function init() {
state.identity = ZeroCrypto.generateIdentity();
elements.userId.textContent = state.identity.userId;
connectWebSocket();
}
// WebSocket connection
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(`${protocol}//${window.location.host}`);
state.ws.onopen = () => {
setStatus('Connected to relay server', true);
// Register our UUID with the server
state.ws.send(JSON.stringify({
type: 'register',
userId: state.identity.userId
}));
};
state.ws.onclose = () => {
setStatus('Disconnected from relay server', false);
setTimeout(connectWebSocket, 3000);
};
state.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleIncomingMessage(msg);
};
}
// Handle incoming WebSocket messages
function handleIncomingMessage(msg) {
if (msg.type === 'invite-offer') {
// Someone is accepting our invite and sending their public key
state.peer = {
userId: msg.from,
publicKey: nacl.util.decodeBase64(msg.publicKey)
};
enableChat();
addSystemMessage(`Peer connected: ${msg.from.substring(0, 8)}...`);
}
if (msg.type === 'invite-accept') {
// Our invite acceptance was acknowledged, peer sends their key
state.peer = {
userId: msg.from,
publicKey: nacl.util.decodeBase64(msg.publicKey)
};
enableChat();
addSystemMessage(`Peer connected: ${msg.from.substring(0, 8)}...`);
}
if (msg.type === 'message') {
// Decrypt and display the message
const plaintext = ZeroCrypto.decryptMessage(
msg.payload,
state.peer.publicKey,
state.identity.secretKey
);
if (plaintext) {
addMessage(plaintext, 'received');
showCryptoPayload(`Received (encrypted): ${msg.payload.substring(0, 80)}...`);
} else {
addSystemMessage('Failed to decrypt message (wrong key or tampered)');
}
}
}
// Generate invite code
elements.generateInvite.addEventListener('click', () => {
const code = ZeroCrypto.createInviteCode(state.identity);
elements.myInviteCode.value = code;
elements.myInviteCode.select();
document.execCommand('copy');
addSystemMessage('Invite code generated and copied to clipboard. Share it with your peer.');
});
// Accept an invite code
elements.acceptInvite.addEventListener('click', () => {
const code = elements.peerInviteCode.value.trim();
if (!code) return;
const peerInfo = ZeroCrypto.parseInviteCode(code);
if (!peerInfo) {
addSystemMessage('Invalid invite code.');
return;
}
state.peer = peerInfo;
// Send our public key to the peer
state.ws.send(JSON.stringify({
type: 'invite-offer',
to: peerInfo.userId,
publicKey: nacl.util.encodeBase64(state.identity.publicKey)
}));
// Also send an acceptance back so peer gets our key
state.ws.send(JSON.stringify({
type: 'invite-accept',
to: peerInfo.userId,
publicKey: nacl.util.encodeBase64(state.identity.publicKey)
}));
enableChat();
addSystemMessage(`Connected to peer: ${peerInfo.userId.substring(0, 8)}...`);
});
// Send a message
function sendMessage() {
const plaintext = elements.messageInput.value.trim();
if (!plaintext || !state.peer) return;
const encrypted = ZeroCrypto.encryptMessage(
plaintext,
state.peer.publicKey,
state.identity.secretKey
);
state.ws.send(JSON.stringify({
type: 'message',
to: state.peer.userId,
payload: encrypted
}));
addMessage(plaintext, 'sent');
showCryptoPayload(`Sent (encrypted): ${encrypted.substring(0, 80)}...`);
elements.messageInput.value = '';
}
elements.sendBtn.addEventListener('click', sendMessage);
elements.messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// UI Helpers
function setStatus(text, connected) {
elements.status.textContent = text;
elements.status.className = connected ? 'status connected' : 'status disconnected';
}
function enableChat() {
elements.messageInput.disabled = false;
elements.sendBtn.disabled = false;
elements.messageInput.focus();
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.textContent = text;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = new Date().toLocaleTimeString();
div.appendChild(meta);
elements.messages.appendChild(div);
elements.messages.scrollTop = elements.messages.scrollHeight;
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.style.textAlign = 'center';
div.style.color = '#8b949e';
div.style.fontSize = '12px';
div.style.margin = '8px 0';
div.textContent = text;
elements.messages.appendChild(div);
elements.messages.scrollTop = elements.messages.scrollHeight;
}
function showCryptoPayload(text) {
elements.cryptoDisplay.textContent = text;
}
// Start the application
init();
Your messenger UI is fully built. Both the HTML interface and the WebSocket client are in place. Next up, you will open two browser tabs and test the full encrypted communication flow end-to-end.
Test the Encrypted Communication
All four files are written and ready to go. Your relay server, cryptographic identity module, chat UI, and WebSocket client are all in place.
Now it is time to prove that the entire system works. You will start the server, open two browser tabs to simulate two users, and watch encrypted messages flow between them while the server only ever sees unreadable ciphertext.
In this step, get ready to:
- Start the relay server and confirm two browser tabs connect automatically.
- Exchange invite codes between the tabs to complete the key exchange.
- Send encrypted messages and verify the server never sees plaintext.
Start the server and open two browser tabs
The server needs to be running before your browser tabs can connect to it. Once it starts, it serves the static HTML/JS files and upgrades connections to WebSocket.
- Make sure your terminal is in the encrypted-messenger folder, then start the server by running:
node server.js
You should see three lines printed in your terminal:
[SERVER] Encrypted messenger relay running on http://localhost:3000
[SERVER] Open two browser tabs to test.
[SERVER] This server NEVER sees plaintext messages.
What does this output mean?
The server is now listening on port 3000. It serves your HTML, CSS, and JavaScript files over HTTP. It also handles WebSocket connections for relaying encrypted messages between peers.
- Open your browser (Chrome, Firefox, or Edge all work).
- Navigate to http://localhost:3000 in the first tab. This is Tab A.
- Open a second tab and navigate to http://localhost:3000 again. This is Tab B.
Each tab generates its own cryptographic identity on load. You should see a unique UUID displayed under "Your ID" in each tab, and the status bar should say "Connected to relay server" in green.
- Switch back to your terminal. You should see two registration messages, each with a different UUID.
Why are the UUIDs different?
Each browser tab calls crypto.randomUUID() independently. There is no server-side registration, no account creation, and no personal information exchanged. The UUID is the user's entire identity.
Not seeing "Connected to relay server"?
Check that your terminal still shows the server running. If you see an error like EADDRINUSE, another process is using port 3000. Stop it first or change the PORT value in server.js.
Make sure you are visiting http://localhost:3000 (not https). The server does not have TLS configured.
help me debug my WebSocket connection to localhost:3000
Connect the two tabs using an invite code
The two tabs are connected to the server, but they do not know about each other yet. The invite-code system handles peer discovery without a central directory or contact list.
- In Tab A, click the Generate Invite button.
- A long base64 string appears in the invite code input field. This code contains Tab A's UUID and public key. It is automatically copied to your clipboard.
- Switch to Tab B.
- Click in the text field that says "Paste peer's invite code here".
- Paste the invite code using Cmd+V (macOS) or Ctrl+V (Windows).
- Click the Connect button.
Both tabs should now show a system message saying "Peer connected" followed by the first 8 characters of the other tab's UUID. The message input field at the bottom becomes enabled.
What just happened?
When Tab B clicked Connect, it parsed Tab A's public key from the invite code and sent its own public key back through the relay server. Both tabs now hold each other's public keys in memory.
This is the key exchange step. The server relayed the key data but cannot use it. Only the matching secret key (which never left the browser) can decrypt messages encrypted with the corresponding public key.
Seeing "Invalid invite code"?
Make sure you copied the entire invite code string from Tab A. It is a long base64 string. If any characters are missing, the JSON inside it will not parse correctly.
Try clicking Generate Invite again in Tab A, then manually select all text in the invite code field, copy it, and paste it into Tab B.
help me fix invalid invite code errors in my encrypted messenger
Send messages and verify encryption
The peers are connected and ready to chat. Every message typed in one tab gets encrypted with the recipient's public key before leaving the browser. The server only ever touches the ciphertext.
- In Tab A, type Hello from Tab A! in the message input field at the bottom.
- Click Send or press Enter.
In Tab A, you should see your message appear as a blue bubble on the right side. In Tab B, the same message appears as a dark bubble on the left side with the readable plaintext.
- Look at the orange "crypto display" bar at the bottom of each tab. It shows a truncated version of the encrypted base64 payload that was actually transmitted.
- Switch to your terminal where the server is running.
You should see a log line like this:
[SERVER] Relaying message from <uuid-a> to <uuid-b>
[SERVER] Encrypted payload: aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/abcdefgh...
What does this prove?
The server log shows a scrambled base64 string. It has no way to turn this back into "Hello from Tab A!" because it does not have either user's secret key. This is the core guarantee of end-to-end encryption. The relay operator is cryptographically locked out of the conversation.
- Switch to Tab B and type Hello from Tab B! in the message field.
- Press Enter to send it.
- Switch to Tab A and confirm the message appears as a readable dark bubble.
- Check your terminal again. You should see another encrypted payload log with a completely different base64 string.
Why is each encrypted payload different?
Even if you sent the exact same plaintext twice, the ciphertext would be different every time. That is because encryptMessage() generates a fresh random 24-byte nonce for each message. This prevents an attacker from detecting repeated messages by comparing ciphertext.
- Send a few more messages back and forth between the tabs to confirm everything works reliably.
Messages not appearing in the other tab?
Make sure both tabs completed the invite-code flow. If only one tab shows "Peer connected", the key exchange may have failed. Refresh both tabs and repeat the invite process from scratch.
Check your terminal for any error messages. If you see [SERVER] Relaying message but the other tab does not display it, the decryption might be failing. Open the browser's developer console (F12) and look for errors.
help me debug messages not appearing between my two encrypted messenger tabs
Secret mission
Ephemeral Messages with Burn-After-Reading
Your messenger already keeps messages private from the server. But what about keeping messages private from time itself? Add self-destructing messages that vanish after a countdown or immediately after being read.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The encrypted-messenger project folder on your Desktop (contains server.js and the public/ folder with all client files).
- Node.js runtime installed on your system.
- The ws npm package installed inside node_modules/ in your project folder.
- The Node.js server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you want to keep experimenting with the messenger or extend it further.
- Your project folder stays on your Desktop, ready to use anytime.
- To restart the messenger later, open a terminal in the encrypted-messenger folder and run node server.js.
- Node.js remains installed and available for other projects.
Pause - I'll come back to this later
Stop the running server process to free up your terminal. Your files stay intact for when you return.
- In the terminal where the server is running, press Ctrl+C to stop the server process.
- Close the browser tabs you used for testing.
- Your project folder and all code remain on your Desktop, unchanged.
Delete - I don't want to use this again
Remove all project files from your machine. Node.js can stay installed if you use it for other work.
- In the terminal where the server is running, press Ctrl+C to stop the server.
- Delete the entire encrypted-messenger folder from your Desktop.
- Optionally, uninstall Node.js if you no longer need it for other projects. Open Settings, select Apps, then Apps & features, find Node.js in the list, and click Uninstall.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional encrypted messenger from scratch that proves you can design systems where the server operator has zero access to user content or identity.
You've learned how to:
- Built a WebSocket relay server that routes encrypted messages without ever accessing plaintext content.
- Implemented x25519-xsalsa20-poly1305 public-key encryption entirely client-side using TweetNaCl.js, including key generation, nonce handling, and authenticated decryption.
- Created a zero-PII identity system with UUID-based identity and an invite-code peer discovery mechanism that exchanges public keys without a central directory.
- Secret Mission: Added ephemeral messaging with configurable burn timers and a burn-after-reading mode.
Ready to quiz yourself?