When I started building Simple Life, I had a clear constraint: the server should never be able to read user data. Not because I don't trust servers — I literally built the server — but because zero-knowledge encryption is the only architecture that makes a personal vault genuinely trustworthy.
This post walks through how I designed and implemented zero-knowledge encryption in a full stack app, the tradeoffs I made, and what I'd do differently. If you're building anything that stores sensitive user data, this is relevant to you.
What Zero-Knowledge Encryption Actually Means
Zero-knowledge encryption means the server stores your data but cannot decrypt it. The encryption and decryption happen entirely on the client — the server is essentially a dumb storage layer that holds ciphertext.
This is different from "encryption at rest" (where the server holds the key) or "end-to-end encryption" (which typically implies communication between two parties). In a zero-knowledge vault, there's one party — the user — and the server exists only to sync encrypted blobs across devices.
The implications are significant:
- If I get hacked, attackers get encrypted blobs they can't decrypt
- If the user forgets their master password, I cannot help them recover their data
- Every feature must be designed around the constraint that the server is blind
The Encryption Stack
After researching options, I settled on this stack:
AES-256-GCM for Data Encryption
AES-256-GCM (Galois/Counter Mode) provides both confidentiality and authenticity in a single operation. It's the same algorithm used by Signal, 1Password, and most modern encrypted applications.
async function encrypt(plaintext: string, key: CryptoKey): Promise<EncryptedData> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoded
);
return {
ciphertext: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv),
};
}Key decisions here:
- 96-bit IV — the recommended size for GCM. A new random IV is generated for every encryption operation
- Base64 encoding for storage — ciphertext and IV stored as base64 strings in PostgreSQL
- No additional HMAC needed — GCM mode provides built-in authentication, so tampered ciphertext is automatically rejected
PBKDF2 for Key Derivation
The user's master password needs to be converted into a 256-bit encryption key. I use PBKDF2 for this:
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}I use 600,000 iterations — the OWASP recommendation for PBKDF2-SHA256 as of 2024. Higher iteration counts make brute-force attacks computationally expensive. On a modern phone, this takes about 300-500ms, which is acceptable for a one-time login operation.
The salt is a random 16-byte value generated during account creation and stored on the server. It ensures identical passwords produce different keys for different users.
Architecture: What the Server Sees
Here's the data flow:
- User enters master password on client
- Client derives encryption key using PBKDF2 + stored salt
- Client encrypts data with AES-256-GCM
- Client sends ciphertext + IV to server
- Server stores the blob — it never sees plaintext or the key
What the server stores for each item:
interface StoredItem {
id: string;
userId: string;
encryptedData: string; // base64 ciphertext
iv: string; // base64 initialization vector
type: "password" | "card" | "document";
createdAt: Date;
updatedAt: Date;
}The type is stored in plaintext — intentionally. The server needs it for filtering and organization, but the actual content (username, password, card number) is encrypted. This is a common tradeoff: metadata is visible, content is not.
Master Key Recovery
The biggest UX challenge in zero-knowledge systems is key recovery. If the user forgets their master password, the data is gone — that's the entire point. But users expect some form of recovery.
My solution: a recovery key generated at account creation.
function generateRecoveryKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return arrayBufferToHex(bytes)
.match(/.{1,4}/g)!
.join("-")
.toUpperCase();
}When a user creates an account:
- A recovery key is generated on the client
- The encryption key is encrypted using a key derived from the recovery key
- This "wrapped key" is stored on the server
- The user is shown the recovery key once and told to write it down
During recovery, the user enters their recovery key, the client unwraps the encryption key, and the user sets a new master password. The server never sees the recovery key or the unwrapped encryption key.
Migration from Legacy Encryption
Simple Life went through an encryption upgrade — the initial version used a simpler scheme. I had to build an automatic migration that:
- Detects legacy-encrypted data on login
- Decrypts with the old scheme
- Re-encrypts with AES-256-GCM
- Updates the server with new ciphertext
- Marks the migration as complete
This happens transparently during login. The lesson: plan for encryption upgrades from day one. Cryptographic best practices evolve, and you'll need to migrate eventually.
The Tech Stack
Simple Life is built with:
- React Native + Expo for the mobile app (client-side encryption happens here)
- React for the web client
- Node.js + Express for the API server
- Prisma + PostgreSQL for storage
- Docker on AWS EC2 for deployment
All encryption happens in the clients using the Web Crypto API. The server is a standard REST API that stores and retrieves encrypted blobs — zero knowledge of what's inside.
For the full stack architecture, I follow the same TypeScript best practices I use across all my projects — shared types between frontend and backend, Zod validation at the API boundary, and Prisma for type-safe database access.
Performance Considerations
Encryption on mobile devices requires careful attention to performance:
- Key derivation caching — PBKDF2 with 600K iterations takes 300-500ms. I cache the derived key in memory for the session so users don't wait on every operation
- Batch decryption — When loading a list of items, I decrypt them in parallel rather than sequentially
- Lazy decryption — Only decrypt item details when the user opens them, not on the list view
- Background encryption — New items are encrypted and synced in the background after the user saves
These optimizations make the app feel instant despite the encryption overhead.
What I Learned
1. Zero-knowledge constrains every feature decision. Want search? You can't search encrypted data server-side — search must happen client-side after decryption. Want sharing? You need a key exchange protocol. Every feature requires rethinking.
2. Testing encryption is hard. You can't just check if output "looks encrypted." You need to verify decryption produces original plaintext, different IVs produce different ciphertexts, tampered ciphertext fails authentication, and key derivation is deterministic.
3. The UX of security is the real challenge. Explaining "if you lose your recovery key and forget your password, your data is permanently lost" is harder than implementing the encryption itself.
4. Web Crypto API is excellent. Available in all modern browsers and React Native (via polyfills), it provides hardware-accelerated cryptographic operations. No need for external crypto libraries.
Should You Build Zero-Knowledge?
If you're storing sensitive user data (passwords, financial information, health records), zero-knowledge is the right architecture. The implementation complexity is manageable, and the security posture is dramatically better than server-side encryption.
If you're building a general-purpose app where the server needs to read data (for analytics, recommendations, etc.), zero-knowledge doesn't make sense.
The key is deciding early — retrofitting zero-knowledge onto an existing app is significantly harder than building it from scratch.
You can see Simple Life and my other projects on my portfolio. This project was a deep dive into cryptography and security — topics I'll continue writing about on my blog.
If you're on the full stack developer roadmap, understanding encryption patterns is essential knowledge as privacy regulations tighten globally. And for the TypeScript patterns I used across the stack, check out TypeScript full stack best practices.
I'm Manjodh Singh Saran — a full stack developer building production apps with React Native, Next.js, and Node.js. If you're building something that needs serious encryption, feel free to reach out.