Guide to Implementing E2EE in Your Modern Web Applications


End-to-end encryption (E2EE) has become a critical standard in applications that deal with sensitive communication—whether it's messaging apps, secure file sharing, or identity management platforms. Today, I’ll be sharing with you how I implemented E2EE in my Side Project. This post outlines a practical, modern approach to implementing end-to-end encryption (E2EE) using Web Crypto APIs and AES-GCM encryption.
We’ll cover:
What E2EE is and why it matters
A secure cryptosystem design
Password-derived encryption
Safely storing and managing encrypted keys
Using E2EE in Messaging
What Is End-to-End Encryption?
E2EE ensures that only the intended sender and recipient can read a message—no intermediary (not even the server) can decrypt the content.
This is achieved by:
Generating a keypair per user
Encrypting messages using the recipient's public key
Storing private keys encrypted on the client side
Our Cryptographic Design
We’ll use a hybrid encryption scheme:
AES-GCM: For encrypting and decrypting the user's private key with a symmetric key derived from their password.
RSA-OAEP: For securely exchanging symmetric keys if needed in future extensions (e.g., for messaging).
We store the encrypted private key, initialization vector (IV), and salt on the backend. Only the user can decrypt their private key client-side using their password.
Full E2EE Implementation
1. Generate an RSA Key Pair
export async function generateKeyPair() {
return await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"]
);
}
2. Derive Symmetric Key from Password
export async function deriveKeyFromPassword(password: string, salt: Uint8Array) {
const encoder = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 250000,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
3. Encrypt the Private Key
export async function encryptPrivateKey(privateKeyPem: string, derivedKey: CryptoKey) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(privateKeyPem);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
derivedKey,
encoded
);
return {
encryptedPrivateKey: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
iv: btoa(String.fromCharCode(...iv)),
};
}
4. Decrypt the Private Key
export async function decryptPrivateKey(encryptedBase64: string, derivedKey: CryptoKey, ivBase64: string) {
const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
const encryptedData = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
derivedKey,
encryptedData
);
return new TextDecoder().decode(decrypted);
}
Registration Flow
User provides a password
Salt is generated and a key is derived using PBKDF2
A fresh RSA key pair is generated
Private key is encrypted using AES-GCM with the derived key
The public key, encrypted private key, IV, and salt are stored in the database
Example:
const salt = crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKeyFromPassword(password, salt);
const { publicKey, privateKey } = await generateKeyPair();
const privateKeyPem = await exportPrivateKey(privateKey);
const { encryptedPrivateKey, iv } = await encryptPrivateKey(privateKeyPem, derivedKey);
// Send `publicKey`, `encryptedPrivateKey`, `iv`, and `salt` to the backend for storage
Sign-in and Decryption Flow
User enters password
Client fetches encrypted private key, salt, and IV
Key is derived using password + salt
Private key is decrypted client-side
Backend Model Example (Mongoose)
const userSchema = new mongoose.Schema({
username: String,
password: String, // bcrypt hashed
publicKey: String,
encryptedPrivateKey: String,
salt: String,
iv: String,
});
Security Considerations
Always generate cryptographic values (
iv
,salt
) usingcrypto.getRandomValues
Never store plain-text private keys on the server
Use HTTPS and strong CORS policies
Consider implementing rate-limiting and multi-factor authentication
Using E2EE in Messaging
Once each user has a key pair (public + encrypted private key), we can build a messaging system where only the recipient can decrypt the message, even though it passes through your server.
High-Level Message Encryption Flow
Alice wants to send a message to Bob.
Alice retrieves Bob’s public key from the server.
Alice generates a one-time AES-GCM symmetric key to encrypt the message.
Alice encrypts the AES key using Bob’s public key.
The encrypted message and encrypted AES key are sent to the backend.
Bob receives the message, decrypts the AES key using his private key, and then decrypts the message.
Message Sending – On Sender’s Side
// 1. Generate a one-time AES key
const aesKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// 2. Encrypt the message
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedMsg = new TextEncoder().encode("Hey Bob!");
const encryptedMessageBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
encodedMsg
);
// 3. Export the AES key and encrypt it with Bob’s public key
const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);
const bobPublicKey = await importRsaPublicKey(bobPublicKeyPem); // PEM to CryptoKey
const encryptedAesKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
bobPublicKey,
rawAesKey
);
// 4. Send to server
await fetch("/api/messages", {
method: "POST",
body: JSON.stringify({
to: "bob",
message: arrayBufferToBase64(encryptedMessageBuffer),
iv: arrayBufferToBase64(iv),
encryptedSymmetricKey: arrayBufferToBase64(encryptedAesKey),
}),
});
Message Receiving – On Recipient’s Side
// 1. Decrypt AES key using Bob’s private key
const encryptedAesKey = base64ToArrayBuffer(msg.encryptedSymmetricKey);
const iv = base64ToArrayBuffer(msg.iv);
const encryptedMsg = base64ToArrayBuffer(msg.message);
const bobPrivateKey = await importRsaPrivateKey(decryptedPrivateKeyPem); // PEM to CryptoKey
const rawAesKey = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
bobPrivateKey,
encryptedAesKey
);
// 2. Import the decrypted AES key
const aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
// 3. Decrypt the actual message
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedMsg
);
const decryptedMessage = new TextDecoder().decode(decryptedBuffer);
console.log("Decrypted message:", decryptedMessage);
Backend Storage Example
Your /api/messages
backend route should store:
{
from: "alice",
to: "bob",
iv: string,
encryptedSymmetricKey: string,
encryptedMessage: string,
timestamp: Date,
}
Recap of Messaging E2EE
The message is encrypted with a one-time AES key
The AES key is encrypted with the recipient’s public key
The recipient uses their private key to decrypt the AES key
The message is then decrypted on the client
In the pure E2EE model described above, you won't be able to read your own sent messages from the server, because:
You encrypted the message using a one-time AES key.
You only encrypted that AES key with the recipient's public key, so only they can decrypt it.
You (the sender) didn’t store the AES key, and your private key can't decrypt the one you sent.
SOLUTION: Encrypt the Message Twice (Dual Encryption)
Encrypt the message for both sender and receiver by encrypting the AES key twice:
Encrypt the AES key with the recipient’s public key.
Encrypt the same AES key with the sender’s public key.
Store both encrypted keys in the database.
Example Schema:
{
"message": "<AES-encrypted message>",
"iv": "<IV>",
"senderEncryptedKey": "<AES key encrypted with sender's public key>",
"receiverEncryptedKey": "<AES key encrypted with receiver's public key>",
"from": "alice",
"to": "bob"
}
Conclusion
Implementing End-to-End Encryption (E2EE) in web applications isn't just a technical choice — it’s a commitment to user privacy and security. In this blog, we explored how to build a robust E2EE system using modern browser cryptography APIs, covering:
Secure generation and encryption of key pairs
Password-based key derivation using PBKDF2
Safe storage of encrypted keys using AES-GCM
Proper export/import of keys for portability
Encrypting messages between users using asymmetric encryption
By handling private keys exclusively on the client and ensuring only intended recipients can decrypt messages, your app respects the fundamental privacy principle: "What I send is only for the eyes of who I trust."
While E2EE introduces complexity — such as key management, re-encryption flows, and loss recovery mechanisms — it forms the foundation of any secure messaging or communication platform today.
Whether you're building a messaging app, a file-sharing tool, or a collaborative workspace, E2EE ensures that sensitive data is safe from prying eyes — including your servers.
For more such articles, follow the DevHub blog and join our free tech community on Discord here.
Subscribe to my newsletter
Read articles from Varun Kumawat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Varun Kumawat
Varun Kumawat
Developer. Founder, DevHub. Mentor.