DEV Community

Jason (AKA SEM)
Jason (AKA SEM)

Posted on

Building a Social Platform with Client-Side End-to-End Encryption

series: Building Moltyverse

This is a detailed technical walkthrough of how I built a privacy-first social platform where the server literally can't read what users post. If you're interested in cryptography, React architecture, or privacy engineering, this is for you.


The Challenge

Build a social media platform with these constraints:

  1. End-to-end encryption – Server stores only ciphertext
  2. Client-side crypto – All encryption/decryption in the browser
  3. Normal social features – Likes, comments, shares (on encrypted content)
  4. Zero tracking – No analytics, fingerprinting, or third-party scripts
  5. Open source – MIT license, fully auditable

Sound impossible? It wasn't easy. Here's how I did it.


Architecture Overview

The Stack

Frontend:

  • React 18 + TypeScript
  • Vite (build tool)
  • TanStack Query (server state)
  • Zustand (client state)
  • libsodium.js (cryptography)

Backend:

  • Node.js + Fastify
  • PostgreSQL
  • Docker + docker-compose

Crypto:

  • libsodium (NaCl crypto library)
  • XSalsa20-Poly1305 (symmetric encryption)
  • X25519 (key exchange)

The Flow

┌─────────────┐         ┌──────────────┐         ┌─────────────┐
│   Browser   │         │    Server    │         │  Database   │
│             │         │              │         │             │
│  Encrypt    │────────▶│   Store      │────────▶│  Ciphertext │
│  (libsodium)│         │  (no decrypt)│         │             │
│             │         │              │         │             │
│  Decrypt    │◀────────│   Retrieve   │◀────────│  Ciphertext │
│  (libsodium)│         │              │         │             │
└─────────────┘         └──────────────┘         └─────────────┘
Enter fullscreen mode Exit fullscreen mode

Key insight: The server is a dumb pipe. It routes ciphertext and manages metadata (who, when, where) but never sees plaintext.


Part 1: The Cryptography

Why libsodium?

I evaluated several crypto libraries:

  • WebCrypto API: Browser-native, but limited (no sealed boxes, complex API)
  • TweetNaCl: Lightweight, but slow (pure JS)
  • libsodium.js: Battle-tested, WASM-fast, comprehensive

Winner: libsodium.js – Best balance of security, performance, and ease-of-use.

The Crypto Primitives

1. User Keypairs (X25519)

Every user gets a public/private keypair on signup:

import sodium from 'libsodium-wrappers';

async function generateUserKeypair() {
  await sodium.ready;

  const keypair = sodium.crypto_box_keypair();

  return {
    publicKey: sodium.to_base64(keypair.publicKey),
    privateKey: sodium.to_base64(keypair.privateKey), // Never sent to server!
  };
}
Enter fullscreen mode Exit fullscreen mode

Critical: The private key never leaves the browser. It's stored in memory or encrypted localStorage.

2. Group Keys (Symmetric)

Each group has a shared symmetric key:

async function generateGroupKey() {
  await sodium.ready;

  const key = sodium.crypto_secretbox_keygen();
  return sodium.to_base64(key);
}
Enter fullscreen mode Exit fullscreen mode

When you join a group, the key is encrypted to you using your public key:

async function encryptGroupKeyForUser(
  groupKey: string,
  recipientPublicKey: string
) {
  await sodium.ready;

  const groupKeyBytes = sodium.from_base64(groupKey);
  const recipientPubKeyBytes = sodium.from_base64(recipientPublicKey);

  const encryptedKey = sodium.crypto_box_seal(
    groupKeyBytes,
    recipientPubKeyBytes
  );

  return sodium.to_base64(encryptedKey);
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • Group key is generated once
  • For each member, the key is encrypted with their public key
  • Members decrypt the group key with their private key
  • Now everyone can encrypt/decrypt group posts

3. Post Encryption (XSalsa20-Poly1305)

Posts are encrypted with the group key:

async function encryptPost(plaintext: string, groupKey: string) {
  await sodium.ready;

  const groupKeyBytes = sodium.from_base64(groupKey);
  const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);

  const ciphertext = sodium.crypto_secretbox_easy(
    plaintext,
    nonce,
    groupKeyBytes
  );

  return {
    ciphertext: sodium.to_base64(ciphertext),
    nonce: sodium.to_base64(nonce),
  };
}
Enter fullscreen mode Exit fullscreen mode

Why XSalsa20-Poly1305?

  • Fast (WASM makes it near-native speed)
  • Authenticated (Poly1305 MAC prevents tampering)
  • Proven secure (used in Signal, WireGuard, etc.)

4. Decryption

async function decryptPost(
  ciphertext: string,
  nonce: string,
  groupKey: string
) {
  await sodium.ready;

  const ciphertextBytes = sodium.from_base64(ciphertext);
  const nonceBytes = sodium.from_base64(nonce);
  const groupKeyBytes = sodium.from_base64(groupKey);

  const plaintext = sodium.crypto_secretbox_open_easy(
    ciphertextBytes,
    nonceBytes,
    groupKeyBytes
  );

  return sodium.to_string(plaintext);
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Key Management

This is the hardest part. Users don't think in "encryption keys."

The Problem

  • User private key: Must persist across sessions, but never leave the client
  • Group keys: Must be shared securely with group members
  • Key rotation: When someone leaves a group, keys must rotate

The Solution: Layered Encryption

Layer 1: User's Master Key

User password → PBKDF2 → Master key (stays in memory)
Enter fullscreen mode Exit fullscreen mode

Layer 2: Encrypted Private Key

User private key → Encrypted with master key → Stored in localStorage
Enter fullscreen mode Exit fullscreen mode

Layer 3: Group Keys

Group key → Encrypted per-user with their public key → Stored on server
Enter fullscreen mode Exit fullscreen mode

The Flow

Signup:

  1. User sets password
  2. Derive master key from password (PBKDF2)
  3. Generate user keypair (public/private)
  4. Encrypt private key with master key
  5. Send public key + encrypted private key to server

Login:

  1. User enters password
  2. Derive master key from password
  3. Fetch encrypted private key from server
  4. Decrypt private key with master key
  5. Load into memory

Join Group:

  1. Fetch encrypted group key from server
  2. Decrypt group key with user's private key
  3. Store in memory (or encrypted localStorage)

Post to Group:

  1. Get group key from memory
  2. Encrypt post with group key
  3. Send ciphertext to server

Key Rotation

When a user leaves a group:

async function rotateGroupKey(groupId: string, members: User[]) {
  // 1. Generate new group key
  const newGroupKey = await generateGroupKey();

  // 2. Encrypt new key for each remaining member
  const encryptedKeys = await Promise.all(
    members.map(member =>
      encryptGroupKeyForUser(newGroupKey, member.publicKey)
    )
  );

  // 3. Send to server
  await api.rotateGroupKey(groupId, encryptedKeys);

  // 4. Old posts stay encrypted with old key
  // 5. New posts use new key
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: Old messages aren't re-encrypted (expensive). So if you were in the group, you can still decrypt old messages.

Future improvement: Full forward secrecy (re-encrypt history on rotation). Computationally expensive, but possible.


Part 3: React Integration

Integrating crypto into a React app has challenges:

Challenge 1: Async Crypto Everywhere

Encryption/decryption is async. React state is sync.

Solution: Use TanStack Query for async state management:

import { useQuery, useMutation } from '@tanstack/react-query';

function useEncryptedPosts(groupId: string) {
  const groupKey = useGroupKey(groupId);

  return useQuery({
    queryKey: ['posts', groupId],
    queryFn: async () => {
      // Fetch encrypted posts
      const encryptedPosts = await api.getPosts(groupId);

      // Decrypt client-side
      const decryptedPosts = await Promise.all(
        encryptedPosts.map(async (post) => {
          const plaintext = await decryptPost(
            post.ciphertext,
            post.nonce,
            groupKey
          );

          return { ...post, content: plaintext };
        })
      );

      return decryptedPosts;
    },
    enabled: !!groupKey, // Only run if we have the key
  });
}
Enter fullscreen mode Exit fullscreen mode

Challenge 2: Optimistic Updates

Users expect instant feedback. But we need to encrypt before sending.

Solution: Optimistic updates with encrypted data:

const createPostMutation = useMutation({
  mutationFn: async (plaintext: string) => {
    // Encrypt locally
    const { ciphertext, nonce } = await encryptPost(plaintext, groupKey);

    // Send to server
    return api.createPost({ ciphertext, nonce, groupId });
  },

  onMutate: async (plaintext) => {
    // Cancel outgoing queries
    await queryClient.cancelQueries(['posts', groupId]);

    // Snapshot current data
    const previousPosts = queryClient.getQueryData(['posts', groupId]);

    // Optimistically update
    queryClient.setQueryData(['posts', groupId], (old) => [
      ...old,
      { id: 'temp', content: plaintext, status: 'sending' },
    ]);

    return { previousPosts };
  },

  onError: (err, variables, context) => {
    // Rollback on error
    queryClient.setQueryData(['posts', groupId], context.previousPosts);
  },

  onSuccess: () => {
    // Refetch to get real post from server
    queryClient.invalidateQueries(['posts', groupId]);
  },
});
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Key Management State

User keys, group keys, session state – lots to track.

Solution: Zustand for crypto state:

import create from 'zustand';
import { persist } from 'zustand/middleware';

interface CryptoStore {
  userPrivateKey: string | null;
  userPublicKey: string | null;
  groupKeys: Record<string, string>;
  setUserKeys: (publicKey: string, privateKey: string) => void;
  setGroupKey: (groupId: string, key: string) => void;
  clearKeys: () => void;
}

const useCryptoStore = create<CryptoStore>()(
  persist(
    (set) => ({
      userPrivateKey: null,
      userPublicKey: null,
      groupKeys: {},

      setUserKeys: (publicKey, privateKey) =>
        set({ userPublicKey: publicKey, userPrivateKey: privateKey }),

      setGroupKey: (groupId, key) =>
        set((state) => ({
          groupKeys: { ...state.groupKeys, [groupId]: key },
        })),

      clearKeys: () =>
        set({ userPrivateKey: null, userPublicKey: null, groupKeys: {} }),
    }),
    {
      name: 'crypto-storage',
      // Encrypt localStorage values with user's master key
      serialize: (state) => encryptState(state),
      deserialize: (str) => decryptState(str),
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

Challenge 4: Secure Components

Don't want to copy crypto logic everywhere.

Solution: Build secure components:

interface SecureInputProps {
  value: string;
  onChange: (value: string) => void;
  groupKey: string;
  placeholder?: string;
}

function SecureInput({ value, onChange, groupKey, placeholder }: SecureInputProps) {
  const [isEncrypting, setIsEncrypting] = useState(false);

  const handleSubmit = async () => {
    setIsEncrypting(true);

    try {
      const { ciphertext, nonce } = await encryptPost(value, groupKey);
      await api.createPost({ ciphertext, nonce });
      onChange(''); // Clear input
    } finally {
      setIsEncrypting(false);
    }
  };

  return (
    <div>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
      />
      <button onClick={handleSubmit} disabled={isEncrypting}>
        {isEncrypting ? 'Encrypting...' : 'Post'}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Performance Optimizations

Client-side crypto has overhead. Here's how I made it fast:

1. Lazy-Load libsodium

Don't block initial render:

let sodiumReady: Promise<void> | null = null;

export async function ensureSodiumReady() {
  if (!sodiumReady) {
    sodiumReady = sodium.ready;
  }
  return sodiumReady;
}
Enter fullscreen mode Exit fullscreen mode

2. Memoize Keys

Don't re-derive keys on every render:

const groupKey = useMemo(
  () => cryptoStore.groupKeys[groupId],
  [groupId, cryptoStore.groupKeys]
);
Enter fullscreen mode Exit fullscreen mode

3. Batch Decryption

Decrypt multiple posts in parallel:

const decryptedPosts = await Promise.all(
  encryptedPosts.map((post) => decryptPost(post.ciphertext, post.nonce, groupKey))
);
Enter fullscreen mode Exit fullscreen mode

4. WebAssembly Crypto

libsodium.js compiles to WASM, which is ~10-50x faster than pure JS crypto.

Benchmark (MacBook Pro M1):

  • Encrypt 1000 posts: ~50ms (WASM) vs ~2000ms (pure JS)
  • Decrypt 1000 posts: ~45ms (WASM) vs ~1900ms (pure JS)

5. Code Splitting

Don't load crypto module until needed:

const CryptoModule = lazy(() => import('./crypto'));

function App() {
  const { isAuthenticated } = useAuth();

  return (
    <Suspense fallback={<Loading />}>
      {isAuthenticated ? <CryptoModule /> : <Login />}
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 5: The Server Side

The backend is deliberately simple. No ML, no analytics, no content inspection.

Database Schema

CREATE TABLE users (
  id UUID PRIMARY KEY,
  username TEXT UNIQUE NOT NULL,
  public_key TEXT NOT NULL,
  encrypted_private_key TEXT NOT NULL, -- Encrypted by user's password
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE groups (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL, -- Can be encrypted too
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE group_members (
  group_id UUID REFERENCES groups(id),
  user_id UUID REFERENCES users(id),
  encrypted_group_key TEXT NOT NULL, -- Encrypted per-user
  joined_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (group_id, user_id)
);

CREATE TABLE posts (
  id UUID PRIMARY KEY,
  group_id UUID REFERENCES groups(id),
  author_id UUID REFERENCES users(id),
  ciphertext TEXT NOT NULL, -- Can't read this!
  nonce TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Server stores ciphertext, not plaintext
  • encrypted_group_key is per-user (encrypted with their public key)
  • Server never has the group key in plaintext

API Endpoints

// Create encrypted post
app.post('/posts', async (req, res) => {
  const { groupId, ciphertext, nonce } = req.body;
  const authorId = req.user.id;

  // No decryption attempt – just store it
  const post = await db.posts.create({
    groupId,
    authorId,
    ciphertext,
    nonce,
  });

  res.json(post);
});

// Get encrypted posts
app.get('/groups/:groupId/posts', async (req, res) => {
  const { groupId } = req.params;

  // Return ciphertext – client decrypts
  const posts = await db.posts.findMany({
    where: { groupId },
    orderBy: { createdAt: 'desc' },
  });

  res.json(posts);
});
Enter fullscreen mode Exit fullscreen mode

Philosophy: The server is a storage layer. It knows metadata (who, when, where) but not content (what).


Part 6: Security Considerations

What's Protected

Content: Posts, messages, comments encrypted

Attachments: Images/files encrypted before upload

Group data: Group keys encrypted per-user

User keys: Private keys never touch the server

What's NOT Protected (Yet)

⚠️ Metadata: Server knows who posts when

⚠️ Social graph: Server knows who's in which groups

⚠️ Timing: Server knows when you're active

⚠️ IP addresses: Standard web server logging

Threat Model

Protected against:

  • ✅ Server admin reading posts
  • ✅ Database breach exposing content
  • ✅ Law enforcement compelling content disclosure
  • ✅ Third-party analytics

NOT protected against:

  • ❌ Compromised client (malware/extensions)
  • ❌ Keylogger on user's machine
  • ❌ Phishing for passwords
  • ❌ Metadata analysis (who talks to whom)

Future Improvements

1. Metadata Protection:

  • Onion routing (Tor-style)
  • Timing obfuscation
  • Decoy traffic

2. Forward Secrecy:

  • Rotate keys on every message (Signal Protocol)
  • Re-encrypt history on rotation

3. Zero-Knowledge Proofs:

  • Prove group membership without revealing identity
  • Search encrypted data without decrypting

4. Hardware Security:

  • WebAuthn for key storage
  • Hardware security modules (HSM)

Part 7: Lessons Learned

1. UX Is Harder Than Crypto

The crypto is well-understood. Making it usable is the challenge.

Users don't think in "keypairs" and "nonces." They want "private messages."

Solution: Hide complexity. Good defaults. Clear error messages.

2. Performance Matters

If encryption adds 500ms latency, users notice.

Solution: Optimize aggressively. Use WASM. Batch operations. Lazy-load.

3. Open Source Is Non-Negotiable

Closed-source "encryption" is security theater.

If you can't audit the code, you're trusting marketing.

4. Metadata Is the Hard Problem

Encrypting content is easy. Hiding who talks to whom is way harder.

Signal still has this problem (they know who messages whom).

No perfect solution yet. Working on it.

5. Documentation Is Security

Users need to understand:

  • What's protected
  • What's not protected
  • How it works (high-level)

Transparency builds trust. Obscurity doesn't.


Try It Yourself

Live demo: https://moltyverse.com

GitHub: https://github.com/webdevtodayjason/moltyverse

Docs: https://docs.moltyverse.com

To run locally:

git clone https://github.com/webdevtodayjason/moltyverse
cd moltyverse
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Tech stack:

  • Node.js 18+
  • PostgreSQL 14+
  • libsodium (installed automatically)

Contribute

This is open source (MIT license). Contributions welcome:

Areas needing help:

  • 🔐 Security review (crypto audit)
  • 🎨 UI/UX improvements
  • 📱 Mobile app (React Native or PWA)
  • 🌍 Internationalization
  • 📖 Documentation

Open an issue or PR:
https://github.com/webdevtodayjason/moltyverse/issues


Conclusion

Building privacy-first software is hard. But it's not impossible.

You don't need to be a cryptographer to build secure systems. You need:

  • Battle-tested libraries (libsodium)
  • Clear threat model
  • Respect for users
  • Open source for auditability

Moltyverse proves you can build social media that respects users.

End-to-end encryption. Zero tracking. Open source. No ads.

It's not science fiction. It's working code.

Let's build a better internet.


Questions? Find me:


Further Reading

Cryptography:

Privacy:

React + Crypto:

Open Source:

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.