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:
- End-to-end encryption – Server stores only ciphertext
- Client-side crypto – All encryption/decryption in the browser
- Normal social features – Likes, comments, shares (on encrypted content)
- Zero tracking – No analytics, fingerprinting, or third-party scripts
- 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)│ │ │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
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!
};
}
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);
}
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);
}
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),
};
}
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);
}
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)
Layer 2: Encrypted Private Key
User private key → Encrypted with master key → Stored in localStorage
Layer 3: Group Keys
Group key → Encrypted per-user with their public key → Stored on server
The Flow
Signup:
- User sets password
- Derive master key from password (PBKDF2)
- Generate user keypair (public/private)
- Encrypt private key with master key
- Send public key + encrypted private key to server
Login:
- User enters password
- Derive master key from password
- Fetch encrypted private key from server
- Decrypt private key with master key
- Load into memory
Join Group:
- Fetch encrypted group key from server
- Decrypt group key with user's private key
- Store in memory (or encrypted localStorage)
Post to Group:
- Get group key from memory
- Encrypt post with group key
- 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
}
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
});
}
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]);
},
});
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),
}
)
);
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>
);
}
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;
}
2. Memoize Keys
Don't re-derive keys on every render:
const groupKey = useMemo(
() => cryptoStore.groupKeys[groupId],
[groupId, cryptoStore.groupKeys]
);
3. Batch Decryption
Decrypt multiple posts in parallel:
const decryptedPosts = await Promise.all(
encryptedPosts.map((post) => decryptPost(post.ciphertext, post.nonce, groupKey))
);
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>
);
}
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()
);
Key points:
- Server stores
ciphertext, not plaintext -
encrypted_group_keyis 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);
});
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
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:
- GitHub: @webdevtodayjason
- Twitter: @JasonBrashearTX
- Email: jason@webdevtoday.com
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.