In the digital age, mental health support is more accessible than ever, but this accessibility comes with a critical challenge: privacy. For users to feel safe journaling their most private thoughts, they need an absolute guarantee that no one—not even the developers of the app—can read their entries. This is the essence of a "zero-knowledge" architecture.
In this deep dive, we'll build a mental health journaling application that architecturally enforces user privacy. We will encrypt all journal entries directly in the user's browser before they are sent to the server. The server will only ever handle and store undecipherable encrypted text.
We will be using Next.js for its hybrid front-end and back-end capabilities and AWS Key Management Service (KMS) for a secure and scalable way to manage the encryption keys that make this all possible. This approach puts privacy at the forefront, building trust with your users from the ground up.
Prerequisites:
- Familiarity with React and Next.js.
- An AWS account with access to IAM and KMS.
- Node.js and npm/yarn installed.
Understanding the Problem: The Privacy Paradox in Health Tech
Standard web applications often store user data in plaintext or with server-side encryption. While server-side encryption protects data at rest on the database, the application itself can still access the unencrypted data. This means a database breach, a malicious insider, or a government subpoena could expose sensitive user information.
Our zero-knowledge approach solves this by shifting the encryption and decryption processes entirely to the client-side (the user's browser). The server's role is relegated to storing and retrieving encrypted blobs of data, having no ability to read their content.
Key Architectural Concepts:
- Client-Side Encryption: Data is encrypted in the browser using a key that only the user possesses.
- Key Management: The user's password is used to derive a primary key. This key is then used to decrypt a "data key" fetched from the server, which in turn is used to decrypt the journal entries.
- AWS KMS: We'll use AWS KMS to manage the encrypted data keys, providing a robust, auditable, and secure key management system without the server ever seeing the plaintext keys.
Prerequisites & Setup
Let's get our environment ready.
-
Create a Next.js App:
codenpx create-next-app@latest zero-knowledge-journal cd zero-knowledge-journalCode collapsed -
Install Dependencies: We'll need the AWS SDK for interacting with KMS and a library to handle cryptographic functions in the browser.
codenpm install @aws-sdk/client-kms crypto-jsCode collapsed(Note: For production applications, relying on the browser's native
window.cryptoAPI is recommended for better performance and security. We'll usecrypto-jshere for simplicity in demonstrating the concepts.) -
AWS Setup:
- Navigate to the AWS Console and go to the Key Management Service (KMS).
- Create a new symmetric "Customer managed key". Give it an alias (e.g.,
journal-app-key). - Create an IAM user with programmatic access. Attach a policy that allows this user to perform
kms:Encrypt,kms:Decrypt, andkms:GenerateDataKeyactions on the specific key you just created. - Store the IAM user's
accessKeyIdandsecretAccessKeyin your Next.js project's.env.localfile. Also, store your KMS key ID.
code# .env.local AWS_ACCESS_KEY_ID=your_access_key AWS_SECRET_ACCESS_KEY=your_secret_key AWS_REGION=your_aws_region AWS_KMS_KEY_ID=your_kms_key_idCode collapsed
Step 1: User Authentication and Key Derivation
Our encryption strategy hinges on a key derived from the user's password. This key will never be sent to the server.
What we're doing
When a user signs up or logs in, we will use their password and a salt to generate a strong encryption key in the browser. We'll use the PBKDF2 algorithm for this, which is a standard for key derivation.
Implementation
Create a utility file for our client-side crypto functions.
// lib/crypto-client.js
import { PBKDF2, enc, AES } from 'crypto-js';
const SALT = "a-very-strong-and-unique-salt-per-user-or-app"; // In a real app, this should be unique per user
// Function to derive a key from a user's password
export const deriveKey = (password) => {
return PBKDF2(password, SALT, {
keySize: 256 / 32,
iterations: 1000
}).toString();
};
// Function to encrypt data using the derived key
export const encryptData = (data, key) => {
return AES.encrypt(JSON.stringify(data), key).toString();
};
// Function to decrypt data using the derived key
export const decryptData = (ciphertext, key) => {
const bytes = AES.decrypt(ciphertext, key);
return JSON.parse(bytes.toString(enc.Utf8));
};
How it works
deriveKey: Takes the user's password and a salt, and runs it through the PBKDF2 algorithm. This makes brute-forcing the password much more difficult. The output is a strong key that we can use for encryption.encryptData/decryptData: Standard AES encryption/decryption functions that use the derived key to process the journal entries.
Step 2: The Zero-Knowledge API Route
Now, let's build the Next.js API route that will store our encrypted journal entries. This endpoint will know nothing about the content it's handling.
What we're doing
We'll create a POST endpoint at /api/journal that accepts an encrypted string and saves it to a hypothetical database.
Implementation
// pages/api/journal.js
// This is a mock database. In a real application, you would use something like DynamoDB or RDS.
const mockDatabase = {};
export default async function handler(req, res) {
if (req.method === 'POST') {
// We assume the user is authenticated and we have their user ID.
const { userId, encryptedEntry } = req.body;
if (!userId || !encryptedEntry) {
return res.status(400).json({ error: 'userId and encryptedEntry are required.' });
}
// The server has NO KNOWLEDGE of the content of 'encryptedEntry'.
// It's just an opaque string.
if (!mockDatabase[userId]) {
mockDatabase[userId] = [];
}
mockDatabase[userId].push(encryptedEntry);
console.log(`Stored encrypted entry for user ${userId}`);
res.status(200).json({ success: true, entryCount: mockDatabase[userId].length });
} else if (req.method === 'GET') {
const { userId } = req.query;
const entries = mockDatabase[userId] || [];
res.status(200).json({ entries });
} else {
res.setHeader('Allow', ['POST', 'GET']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
How it works
This API is intentionally simple. It receives a blob of text (encryptedEntry) and stores it. It doesn't need to know what's inside, how it was encrypted, or what the key is. When a user requests their data, it sends back the same encrypted blobs.
Step 3: Integrating Client-Side Encryption in the UI
Let's tie everything together on the front end. A user will type a journal entry, and upon saving, we'll encrypt it and send it to our API.
What we're doing
We'll create a simple React component for a journal, derive the user's key upon login (for this example, we'll simulate it), and use it to encrypt the data before sending it to our backend.
Implementation
// pages/index.js
import { useState, useEffect } from 'react';
import { deriveKey, encryptData, decryptData } from '../lib/crypto-client';
// We'll simulate a logged-in user and their password for this demo.
const FAKE_USER_ID = 'user123';
const FAKE_USER_PASS = 'my-secret-password';
export default function HomePage() {
const [entry, setEntry] = useState('');
const [encryptedEntries, setEncryptedEntries] = useState([]);
const [decryptedEntries, setDecryptedEntries] = useState([]);
const [derivedKey, setDerivedKey] = useState(null);
useEffect(() => {
// On component mount, derive the key from the user's password.
const key = deriveKey(FAKE_USER_PASS);
setDerivedKey(key);
}, []);
const handleSaveEntry = async () => {
if (!entry || !derivedKey) return;
const encryptedEntry = encryptData({ text: entry, timestamp: new Date() }, derivedKey);
await fetch('/api/journal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: FAKE_USER_ID, encryptedEntry }),
});
setEntry('');
alert('Entry saved securely!');
};
const handleLoadEntries = async () => {
const response = await fetch(`/api/journal?userId=${FAKE_USER_ID}`);
const { entries } = await response.json();
setEncryptedEntries(entries);
if (derivedKey) {
const decrypted = entries.map(encEntry => decryptData(encEntry, derivedKey));
setDecryptedEntries(decrypted);
}
};
return (
<div>
<h1>My Secure Journal</h1>
<textarea
value={entry}
onChange={(e) => setEntry(e.target.value)}
placeholder="Write your thoughts..."
></textarea>
<button onClick={handleSaveEntry}>Save Encrypted Entry</button>
<hr />
<button onClick={handleLoadEntries}>Load & Decrypt Entries</button>
<h2>Decrypted Entries</h2>
<ul>
{decryptedEntries.map((item, index) => (
<li key={index}>{item.text} - <i>{new Date(item.timestamp).toLocaleString()}</i></li>
))}
</ul>
</div>
);
}
The Trade-Offs: Privacy vs. Features
This architecture provides powerful privacy guarantees, but it comes with significant trade-offs:
- No Server-Side Search: Since the server can't read the data, you can't implement server-side search. All searching must happen on the client after decrypting the data.
- Limited Analytics: It's impossible to run analytics or machine learning models on the user's journal content from the backend. This prevents features like sentiment analysis or topic suggestions unless performed on-device.
- Password Recovery is Hard: If a user forgets their password, their derived key is lost forever. You cannot offer a traditional "reset password" feature that would give them access to old data. You must educate users to store their password securely, or implement a more complex key recovery mechanism (like a recovery key they must save).
Conclusion
We've successfully designed and built the core of a zero-knowledge mental health application. By leveraging client-side encryption with Next.js and managing keys with AWS KMS, we've created a system where user privacy is not just a policy, but an architectural guarantee. While there are trade-offs, for applications dealing with highly sensitive data, this privacy-first approach is the gold standard for building user trust.
Next Steps for Readers
- Integrate AWS KMS: Replace the simple password-derived key with a more robust envelope encryption scheme using AWS KMS.
- User-Specific Salts: Generate and store a unique salt for each user during sign-up to further strengthen key derivation.
- Error Handling: Build robust error handling for both the client-side cryptography and API interactions.