WellAlly Logo
WellAlly康心伴
Development

Privacy-First Architecture: Building a Zero-Knowledge Mental Health App with Next.js & AWS

An architectural breakdown of a system where user journal entries are encrypted client-side before hitting the server, making them unreadable by developers. Explores key management, secure API design, and building truly private applications.

W
2025-12-12
10 min read

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:

  1. Client-Side Encryption: Data is encrypted in the browser using a key that only the user possesses.
  2. 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.
  3. 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.

  1. Create a Next.js App:

    code
    npx create-next-app@latest zero-knowledge-journal
    cd zero-knowledge-journal
    
    Code collapsed
  2. Install Dependencies: We'll need the AWS SDK for interacting with KMS and a library to handle cryptographic functions in the browser.

    code
    npm install @aws-sdk/client-kms crypto-js
    
    Code collapsed

    (Note: For production applications, relying on the browser's native window.crypto API is recommended for better performance and security. We'll use crypto-js here for simplicity in demonstrating the concepts.)

  3. 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, and kms:GenerateDataKey actions on the specific key you just created.
    • Store the IAM user's accessKeyId and secretAccessKey in your Next.js project's .env.local file. 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_id
    
    Code 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.

code
// 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));
};
Code collapsed

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

code
// 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`);
  }
}
Code collapsed

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

code
// 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>
  );
}
Code collapsed

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.

Resources

#

Article Tags

privacy
security
nextjs
aws
architecture
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey