WellAlly Logo
WellAlly康心伴
Development

The Ultimate Guide to Client-Side Encryption for a Zero-Knowledge Health App

A deep-dive into building a zero-knowledge health app. Learn to encrypt user data on the client-side with React and the Web Crypto API before it ever touches your server, ensuring complete user privacy.

W
2025-12-14
11 min read

In the world of health tech, user trust is paramount. A single data breach can not only ruin your reputation but can also expose life-altering personal health information (PHI). While server-side encryption and HTTPS are standard practice, they have a fundamental vulnerability: the server, and anyone who compromises it, can see user data in plaintext. This is where a zero-knowledge architecture comes in.

A zero-knowledge system is a security model where the service provider (that's you!) has no knowledge of the contents of the data they store. This is achieved by performing all encryption and decryption operations on the client's device. The server only ever receives and stores encrypted blobs of data. Even in the event of a full database breach, user health data remains unreadable.

In this deep-dive, we'll build the core cryptographic functionality for a zero-knowledge health journaling app using React/React Native. We will use the browser's native and highly secure Web Crypto API to derive keys from a user's password, encrypt their journal entries, and decrypt them for viewing, ensuring only the user can ever access their sensitive health information.

Prerequisites:

  • Solid understanding of React/React Native and modern JavaScript (async/await).
  • Node.js and a package manager (npm/yarn) installed.
  • Familiarity with basic cryptographic concepts like symmetric encryption is helpful but not required.

Why this matters to developers: Building a zero-knowledge app is a powerful differentiator. It demonstrates a commitment to user privacy that goes beyond legal requirements, building deep user trust and providing a significant competitive advantage.

Understanding the Problem: The Limits of Standard Encryption

Traditionally, a web application's security model looks like this:

  1. A user enters data into a React app.
  2. The data is sent over an HTTPS connection to the server.
  3. The server receives the plaintext data.
  4. The server might encrypt the data before storing it in a database (encryption at rest).

The critical flaw is in step 3. The server has access to the plaintext data. This means a malicious actor who gains access to your server, a rogue employee, or a government subpoena could potentially expose user data.

Our zero-knowledge approach shifts the encryption boundary:

  1. A user enters data into the React app.
  2. The data is encrypted directly in the browser/device using a key only the user possesses.
  3. The encrypted data (ciphertext) is sent over HTTPS to the server.
  4. The server stores the ciphertext, having zero knowledge of the original content.

This "host-proof hosting" model ensures you, the service provider, can't be a liability for your users' sensitive data.

Prerequisites: Setting Up Our Crypto Toolkit

We will rely on the Web Crypto API, which is a low-level interface for cryptographic operations built directly into modern browsers. This is a huge security advantage as it means we don't need to bundle third-party crypto libraries, reducing our attack surface.

Important Note: The Web Crypto API is only available in secure contexts (HTTPS or localhost). This is a security feature to prevent man-in-the-middle attacks.

Let's create a utility file to house our cryptographic functions.

code
# In your React or React Native project
touch src/cryptoUtils.js
Code collapsed

We will build out this file step-by-step.

Step 1: Deriving an Encryption Key from a Password

A core challenge is creating a strong encryption key from a user's password. We can't use the password directly as a key because users often choose weak, predictable passwords. Instead, we use a Key Derivation Function (KDF). We'll use PBKDF2 (Password-Based Key Derivation Function 2), a widely supported and robust algorithm.

A KDF takes a password and a salt (a unique, random value for each user) and runs them through a computationally intensive process to produce a strong, uniform cryptographic key.

What we're doing

We will create a function that takes a user's password and a salt to generate a CryptoKey object, which is the special object used by the Web Crypto API for encryption.

Implementation

code
// src/cryptoUtils.js

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

/**
 * Derives a cryptographic key from a password and salt.
 * @param {string} password - The user's password.
 * @param {Uint8Array} salt - A random salt.
 * @param {number} iterations - The number of iterations for PBKDF2.
 * @returns {Promise<CryptoKey>} - The derived cryptographic key.
 */
export const deriveKeyFromPassword = async (password, salt, iterations = 100000) => {
  const passwordBuffer = textEncoder.encode(password);

  // Import the password as a raw key for PBKDF2.
  const baseKey = await window.crypto.subtle.importKey(
    "raw",
    passwordBuffer,
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  );

  // Derive the key using PBKDF2 with AES-GCM parameters.
  const derivedKey = await window.crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: iterations,
      hash: "SHA-256",
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    true, // The key can be exported if needed (e.g., for backup)
    ["encrypt", "decrypt"]
  );

  return derivedKey;
};

/**
 * Generates a new random salt.
 * @returns {Uint8Array} - A 16-byte random salt.
 */
export const generateSalt = () => {
  return window.crypto.getRandomValues(new Uint8Array(16));
};
Code collapsed

How it works

  1. generateSalt: We create a function to generate a cryptographically secure 16-byte random salt. This salt must be stored alongside the user's data on the server. It's not a secret, but it must be unique per user to prevent rainbow table attacks.
  2. deriveKeyFromPassword:
    • We encode the user's password string into a byte stream (Uint8Array).
    • importKey takes the raw password and prepares it for use with the PBKDF2 algorithm.
    • deriveKey is the core of the process. It takes the base key (from the password), the salt, a high number of iterations (making it slow and expensive for attackers), and a hash function (SHA-256) to produce our final CryptoKey. We specify that this key will be used for AES-GCM, a secure and modern encryption algorithm.

Step 2: Encrypting Health Data

Now that we have a key, we can encrypt the user's health journal entry. For each encryption operation, we also need to generate a unique Initialization Vector (IV). The IV ensures that encrypting the same text multiple times with the same key produces different ciphertexts, which is crucial for security. Like the salt, the IV is not a secret and should be stored on the server.

What we're doing

Create a function that takes the user's journal entry (plaintext) and their derived key, and returns the encrypted data (ciphertext) along with the IV used for the encryption.

Implementation

code
// src/cryptoUtils.js

/**
 * Encrypts data using AES-GCM.
 * @param {string} plaintext - The data to encrypt.
 * @param {CryptoKey} key - The cryptographic key.
 * @returns {Promise<{ciphertext: ArrayBuffer, iv: Uint8Array}>}
 */
export const encryptData = async (plaintext, key) => {
  const dataBuffer = textEncoder.encode(plaintext);
  // The IV should be unique for each encryption operation.
  const iv = window.crypto.getRandomValues(new Uint8Array(12));

  const ciphertext = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv,
    },
    key,
    dataBuffer
  );

  return { ciphertext, iv };
};
Code collapsed

How it works

  1. We generate a new 12-byte random IV for this specific encryption operation. It's critical to generate a new IV for every encryption.
  2. We encode the plaintext health data into a byte stream.
  3. window.crypto.subtle.encrypt performs the encryption using the specified algorithm (AES-GCM), our derived key, the IV, and the data buffer.
  4. The result is an ArrayBuffer containing the ciphertext, which we return along with the IV.

Step 3: Storing Data on the "Zero-Knowledge" Server

Our React/React Native component will now call these utility functions and send the result to the server. The server's job is simply to store the encrypted data without ever trying to interpret it.

Server-Side Data Model

Your backend API and database need to be designed to handle this. For a journal_entries table, your schema might look like this:

code
CREATE TABLE journal_entries (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    encrypted_entry TEXT NOT NULL, -- Base64 encoded ciphertext
    iv TEXT NOT NULL,              -- Base64 encoded IV
    created_at TIMESTAMPZ NOT NULL,
    -- NO plaintext data is stored!
);

-- The user table must store the salt
CREATE TABLE users (
    id UUID PRIMARY KEY,
    username TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL, -- For authentication, NOT encryption
    encryption_salt TEXT NOT NULL -- Base64 encoded salt
);
Code collapsed

React Component Implementation

Here's how a component might use our crypto utilities to save a new journal entry. For transport and storage, it's easiest to convert the ArrayBuffer and Uint8Array data into Base64 strings.

code
// src/components/JournalEntryForm.jsx
import React, { useState } from 'react';
import { deriveKeyFromPassword, encryptData } from '../cryptoUtils';

// Helper to convert ArrayBuffer to Base64
const bufferToBase64 = (buffer) => {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
};

const JournalEntryForm = ({ userPassword, userSalt }) => {
  const [entry, setEntry] = useState('');
  const [isSaving, setIsSaving] = useState(false);

  const handleSave = async () => {
    if (!entry || !userPassword || !userSalt) {
      alert("Missing data for encryption.");
      return;
    }
    setIsSaving(true);
    try {
      // 1. Derive the key from the user's password and their unique salt
      const key = await deriveKeyFromPassword(userPassword, userSalt);

      // 2. Encrypt the journal entry
      const { ciphertext, iv } = await encryptData(entry, key);

      // 3. Convert binary data to Base64 for JSON transport
      const encryptedEntryBase64 = bufferToBase64(ciphertext);
      const ivBase64 = bufferToBase64(iv);

      // 4. Send the encrypted data to the zero-knowledge server
      await fetch('/api/journal', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          encrypted_entry: encryptedEntryBase64,
          iv: ivBase64,
        }),
      });

      setEntry('');
      alert('Your entry has been securely saved! ✨');
    } catch (error) {
      console.error("Encryption failed:", error);
      alert("Could not save your entry securely.");
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div>
      <textarea
        value={entry}
        onChange={(e) => setEntry(e.target.value)}
        placeholder="Write your private health journal entry here..."
      />
      <button onClick={handleSave} disabled={isSaving}>
        {isSaving ? 'Saving...' : 'Save Securely'}
      </button>
    </div>
  );
};

export default JournalEntryForm;
Code collapsed

Step 4: Decrypting and Displaying Data

To view an entry, we reverse the process. We fetch the encrypted data, IV, and salt from the server, re-derive the key using the user's password, and decrypt the data.

What we're doing

Create a final utility function for decryption and use it in a component that displays journal entries.

Implementation

code
// src/cryptoUtils.js

/**
 * Decrypts data using AES-GCM.
 * @param {ArrayBuffer} ciphertext - The encrypted data.
 * @param {CryptoKey} key - The cryptographic key.
 * @param {Uint8Array} iv - The initialization vector used for encryption.
 * @returns {Promise<string>} - The decrypted plaintext.
 */
export const decryptData = async (ciphertext, key, iv) => {
  const decryptedBuffer = await window.crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: iv,
    },
    key,
    ciphertext
  );

  return textDecoder.decode(decryptedBuffer);
};
Code collapsed

React Component for Decryption

code
// src/components/JournalViewer.jsx
import React, { useState, useEffect } from 'react';
import { deriveKeyFromPassword, decryptData } from '../cryptoUtils';

// Helper to convert Base64 to Uint8Array
const base64ToUint8Array = (base64) => {
    const binary_string = window.atob(base64);
    const len = binary_string.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) {
        bytes[i] = binary_string.charCodeAt(i);
    }
    return bytes;
};

const JournalViewer = ({ userPassword, userSalt }) => {
  const [decryptedEntries, setDecryptedEntries] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchAndDecryptEntries = async () => {
      try {
        // 1. Fetch encrypted entries from the server
        const response = await fetch('/api/journal');
        const entries = await response.json();

        // 2. Derive the key (only needs to be done once)
        const salt = base64ToUint8Array(userSalt);
        const key = await deriveKeyFromPassword(userPassword, salt);

        // 3. Decrypt each entry
        const decryptedPromises = entries.map(async (entry) => {
          const ciphertext = base64ToUint8Array(entry.encrypted_entry);
          const iv = base64ToUint8Array(entry.iv);
          const plaintext = await decryptData(ciphertext, key, iv);
          return { id: entry.id, text: plaintext, createdAt: entry.created_at };
        });

        const results = await Promise.all(decryptedPromises);
        setDecryptedEntries(results);
      } catch (error) {
        console.error("Decryption failed:", error);
        alert("Could not decrypt your entries. Please check your password.");
      } finally {
        setIsLoading(false);
      }
    };

    if(userPassword && userSalt) {
        fetchAndDecryptEntries();
    }
  }, [userPassword, userSalt]);

  if (isLoading) return <p>Decrypting your data...</p>;

  return (
    <div>
      {decryptedEntries.map(entry => (
        <div key={entry.id}>
          <p>{entry.text}</p>
          <small>Saved on: {new Date(entry.createdAt).toLocaleString()}</small>
        </div>
      ))}
    </div>
  );
};

export default JournalViewer;
Code collapsed

Security Best Practices & Trade-offs

  • Key Management is Everything: The security of this entire system hinges on the user's password. If the password is weak, the derived key is weak. Encourage users to use strong, unique passwords. The most significant challenge in a zero-knowledge system is account recovery. If a user forgets their password, their data is irrecoverably lost. There is no "forgot password" flow that can recover the encryption key. Some systems implement complex social recovery mechanisms or downloadable recovery keys, but this adds significant complexity.
  • Don't Store the Password or Key: Never store the user's password or the derived CryptoKey in localStorage, cookies, or even React state for long periods. The key should ideally be derived from a password input just before it's needed and held only in memory.
  • Use HTTPS Always: The Web Crypto API requires it for a reason. Without HTTPS, an attacker could intercept your JavaScript code and replace it with a malicious version that steals the user's password.
  • Performance Considerations: The key derivation (PBKDF2) is intentionally slow to deter brute-force attacks. You'll notice a slight delay when deriving the key. Perform this operation sparingly—once per session is ideal. Cache the CryptoKey object in memory (e.g., in a React Context) for the duration of the user's session, but be careful not to persist it anywhere.

Alternative Approaches

  • Third-party Libraries: Libraries like crypto-js exist, but they are generally not recommended over the native Web Crypto API. The Web Crypto API is more secure, maintained by browser vendors, and often faster as it can utilize underlying hardware support.
  • Asymmetric Encryption (Public/Private Keys): For features like sharing health data with a doctor, you would need asymmetric encryption (e.g., RSA-OAEP). In this model, the doctor has a public key (for encrypting) and a private key (for decrypting). This is more complex to manage but allows for secure data sharing.

Conclusion

We've successfully built the foundation of a zero-knowledge system. By leveraging the client's device and the robust Web Crypto API, we've created an architecture where user privacy is cryptographically guaranteed. The server acts as a dumb storage locker, unable to access the sensitive health information it holds.

This approach puts the user in complete control of their data, building a foundation of trust that is essential for any application dealing with sensitive information.

Next steps for you:

  1. Integrate this logic into a state management solution like Redux or React Context to manage the CryptoKey.
  2. Build a secure authentication flow that provides the user's password to the crypto logic.
  3. Explore key backup and recovery strategies for your users.

Resources

#

Article Tags

privacysecurityreacthealthtechencryption
W

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

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management