Building secure chat applications goes beyond just sending and receiving messages; true privacy requires end-to-end encryption (E2EE). With E2EE, only the intended participants can read the conversation, while even the service provider remains blind to the content.
In this guide, we'll learn how to implement E2EE in a React Native chat app using Stream's chat infrastructure. We'll combine public-key cryptography with efficient symmetric encryption to achieve strong security.
Technical Prerequisites
Before we begin, ensure you have the following:
- Free Stream account
- Node.js 14 or higher installed
- Basic knowledge of React/React Native and Node.js
- Android Studio (for Android development), Xcode (for iOS development), or Expo Go (Real Device)
Solution Architecture
Setting Up Your Development Environment
Backend Setup
We'll start by setting up the backend:
npm init
npm install express dotenv stream-chat nodemon cors
Now, create a .env
file there:
STREAM_API_KEY=Your_stream_api_key
STREAM_API_SECRET=Your_stream_api_secret_key
PORT=5000
Next, create an app.js
file with the following configuration:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// Health check
app.get('/', (req, res) => {
res.json({ status: 'ok' });
});
// Auth and Stream routes
const routes = require('./routes');
app.use('/api', routes);
The backend will now run on the configured port:
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`);
});
The routes.js
file defines all the routes required for the application. At the top, the Stream keys are initialized, and the client is configured with an extended timeout to improve reliability and ensure the frontend establishes a stable connection.
const express = require('express');
const { StreamChat } = require('stream-chat');
const router = express.Router();
const apiKey = process.env.STREAM_API_KEY;
const apiSecret = process.env.STREAM_API_SECRET;
// Configure Stream client with increased timeout
const streamServerClient = StreamChat.getInstance(apiKey, apiSecret, {
timeout: 30000, // 30 seconds instead of default 3 seconds
});
This route allows user registration without encryption and is used solely for testing chat functionality in a non-encrypted environment.
// Registration without encryption key
router.post('/register-simple', async (req, res) => {
const { userId, name } = req.body;
if (!userId || !name) return res.status(400).json({ error: 'Missing fields' });
try {
await streamServerClient.upsertUser({ id: userId, name });
res.json({ success: true });
} catch (err) {
console.error('Stream upsertUser error:', err);
res.status(500).json({ error: 'Stream upsertUser failed', details: err.message });
}
});
For encrypted registration, the route requires a user's public key, which is essential for the encryption process.
router.post('/register', async (req, res) => {
const { userId, name, publicKey } = req.body;
if (!userId || !name || !publicKey) return res.status(400).json({ error: 'Missing fields' });
try {
await streamServerClient.upsertUser({ id: userId, name, public_key: publicKey });
res.json({ success: true });
} catch (err) {
console.error('Stream upsertUser error:', err);
res.status(500).json({ error: 'Stream upsertUser failed', details: err.message });
}
});
The login routes handle user authentication and issue tokens needed to verify users.
// Mock login (no password, just userId)
router.post('/login', async (req, res) => {
const { userId } = req.body;
if (!userId) return res.status(400).json({ error: 'Missing userId' });
// In production, check password or OAuth
res.json({ success: true });
});
// Issue Stream token
router.post('/stream/token', (req, res) => {
const { userId } = req.body;
if (!userId) return res.status(400).json({ error: 'Missing userId' });
const token = streamServerClient.createToken(userId);
res.json({ token });
});
These routes fetch all registered users so a logged-in user can choose someone to chat with.
// Get users
router.get('/users', async (req, res) => {
try {
const result = await streamServerClient.queryUsers({});
// Ensure public_key is properly formatted
const users = result.users.map(u => ({
id: u.id,
name: u.name,
public_key: u.public_key || null,
}));
res.json({ users });
} catch (err) {
console.error('Error fetching users:', err);
res.status(500).json({ error: err.message });
}
});
Frontend Setup
To get started, create a new React Native project using Expo with the navigation (TypeScript) template:
# Create new React Native project
npx create-expo-app --template # and then select Navigation(Typescript)
After running the command above, you will have a fully set up React Native application with React Navigation configured for routing.
The structure of the application will look like this:
my-chat-app/
βββ app/
β βββ _layout.tsx
β βββ index.tsx
βββ assets/
βββ app.json
βββ package.json
βββ tsconfig.json
βββ node_modules/
Now, let's install these packages, which you will need throughout the tutorial.
What do these dependencies do? The @nobles
, expo-crypto
, and expo-secure-store
packages implement end-to-end encryption. The chat functionalities require stream-chat-expo
. React Native's react-native-gesture-handler
handles complex gestures (swipes, pans, taps, etc.).
npm install stream-chat-expo @noble/ciphers @noble/curves @noble/hashes expo-crypto expo-secure-store react-native-gesture-handler
To start building our chat app, delete the default app
folder that comes with the code, then create an App.tsx
file in the root directory and reference it in your package.json
as shown below.
{
"name": "navigation",
"main": "App.tsx",
"version": "1.0.0",
}
The App.tsx
will work as the root file for our React Native application.
// App.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { UserProvider } from './context/UserContext';
import { OverlayProvider } from 'stream-chat-expo';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import AuthScreen from './screens/AuthScreen';
import UsersScreen from './screens/UsersScreen';
import ChatScreen from './screens/ChatScreen';
import { registerRootComponent } from 'expo';
export type RootStackParamList = {
Auth: undefined;
Users: undefined;
Chat: {
recipientId: string;
recipientName: string;
};
};
const Stack = createNativeStackNavigator<RootStackParamList>();
function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<NavigationContainer>
<OverlayProvider>
<UserProvider>
<Stack.Navigator
initialRouteName="Auth"
screenOptions={{
headerStyle: {
backgroundColor: '#007AFF',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
<Stack.Screen
name="Auth"
component={AuthScreen}
options={{
title: "π Secure Login",
headerStyle: {
backgroundColor: '#2d7d2d',
},
}}
/>
<Stack.Screen
name="Users"
component={UsersScreen}
options={{
title: "π₯ Select User",
headerBackVisible: false, // Prevent going back without proper logout
}}
/>
<Stack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: `π ${route.params.recipientName}`,
headerStyle: {
backgroundColor: '#2d7d2d',
},
})}
/>
</Stack.Navigator>
</UserProvider>
</OverlayProvider>
</NavigationContainer>
</GestureHandlerRootView>
);
}
export default registerRootComponent(App);
Setting Up the Initial Configuration
First, let's set up the Stream client.
//utils/streamClient.ts
import { StreamChat } from 'stream-chat';
import { STREAM_API_KEY } from '../config/constants';
export const streamClient = StreamChat.getInstance(STREAM_API_KEY);
Next, we'll create a config file containing our API key and backend URL logic.
//config/constants.ts
export const STREAM_API_KEY = 'Your_stream_api_key';
// Helper to get the correct backend URL based on environment
export function getBackendUrl() {
// Uncomment ONE of the following as needed:
// 1. For Android emulator:
// return 'http://10.0.2.2:5000/api';
// 2. For iOS simulator or web:
// return 'http://localhost:5000/api';
// 3. For real device (Android or iOS) on same Wi-Fi as your computer:
// return 'http://192.168.118.229:5000/api';
// Default: fallback to IP Address
return 'http://Your_IP_Address:5000/api';
}
export const BACKEND_URL = getBackendUrl();
Managing User Authentication with Context
To avoid passing user authentication details (like userId
and token
) through props in every component, we'll use React Context. This allows us to store and share authentication state globally across the app.
// context/UserContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface UserContextType {
userId: string | null;
token: string | null;
setUser: (userId: string, token: string) => void;
clearUser: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [userId, setUserId] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const setUser = (newUserId: string, newToken: string) => {
setUserId(newUserId);
setToken(newToken);
};
const clearUser = () => {
setUserId(null);
setToken(null);
};
return (
<UserContext.Provider value={{ userId, token, setUser, clearUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}
Creating Screens
Now, create a screens
folder. This will hold the main pages of our app:
-
AuthScreen.tsx
: Handles user registration and login -
UsersScreen.tsx
: Lists all available users and allows logout -
ChatScreen.tsx
: Handles one-to-one messaging
This is the present structure of our project files:
my-chat-app/
βββ App.tsx
βββutils/
β βββ streamClient.ts
βββ context/
β βββ UserContext.tsx
βββ config/
β βββ constants.ts
βββ screens/
β βββ AuthScreen.tsx
β βββ UserListScreen.tsx
β βββ ChatScreen.tsx
βββ assets/
βββ app.json
βββ package.json
βββ tsconfig.json
βββ node_modules/
I will highlight each of these screens below.
1. AuthScreen.tsx
(Login & Registration)
//screens/AuthScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
Alert,
StyleSheet,
ScrollView
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import axios from 'axios';
import { streamClient } from '../utils/streamClient';
import { useUser } from '../context/UserContext';
import { BACKEND_URL } from '../config/constants';
import { RootStackParamList } from '../App';
type AuthScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
interface Props {
navigation: AuthScreenNavigationProp;
}
export default function AuthScreen({ navigation }: Props) {
const [userId, setUserId] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const { setUser } = useUser();
const handleRegister = async () => {
if (!userId || !name) {
Alert.alert('Error', 'Please enter user ID and name');
return;
}
setLoading(true);
try {
await axios.post(`${BACKEND_URL}/register-simple`, { userId, name });
Alert.alert('Success', 'Registration complete! You can now log in.');
} catch (e) {
console.log('Registration error:', e);
Alert.alert('Error', 'Registration failed. Please try again.');
}
setLoading(false);
};
const handleLogin = async () => {
if (!userId) {
Alert.alert('Error', 'Please enter user ID');
return;
}
setLoading(true);
try {
await axios.post(`${BACKEND_URL}/login`, { userId });
const { data } = await axios.post(`${BACKEND_URL}/stream/token`, { userId });
await streamClient.connectUser(
{ id: userId, name: userId },
data.token
);
setUser(userId, data.token);
navigation.navigate('Users');
} catch (error) {
console.error('Login error:', error);
Alert.alert('Error', 'Login failed. Please try again.');
}
setLoading(false);
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>Chat App</Text>
<TextInput
placeholder="User ID"
value={userId}
onChangeText={setUserId}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
placeholder="Name (for registration)"
value={name}
onChangeText={setName}
style={styles.input}
autoCorrect={false}
editable={!loading}
/>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Please wait...</Text>
</View>
) : (
<View style={styles.buttonContainer}>
<TouchableOpacity style={styles.primaryButton} onPress={handleRegister}>
<Text style={styles.primaryButtonText}>Register</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.primaryButton} onPress={handleLogin}>
<Text style={styles.primaryButtonText}>Login</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
}
2. UsersScreen.tsx
(User List and Logout)
// src/screens/UsersScreen.tsx -
import React, { useEffect, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
StyleSheet
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useUser } from '../context/UserContext';
import { streamClient } from '../utils/streamClient';
import { BACKEND_URL } from '../config/constants';
import { RootStackParamList } from '../App';
type UsersScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Users'>;
interface Props {
navigation: UsersScreenNavigationProp;
}
interface User {
id: string;
name: string;
}
export default function UsersScreen({ navigation }: Props) {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [loggingOut, setLoggingOut] = useState(false);
const { userId, clearUser } = useUser();
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${BACKEND_URL}/users`, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Unknown error');
}
const data = await res.json();
setUsers(data.users.filter((u: User) => u.id !== userId));
} catch (e: any) {
let message = 'Failed to fetch users';
if (e.name === 'AbortError') {
message = 'Request timed out. Please try again.';
} else if (e.message) {
message = e.message;
}
Alert.alert('Error', message);
}
setLoading(false);
};
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
setLoggingOut(true);
// Disconnect from Stream
if (streamClient.user) {
await streamClient.disconnectUser();
}
// Clear user and navigate to auth
clearUser();
navigation.reset({
index: 0,
routes: [{ name: 'Auth' }],
});
} catch (error) {
console.error('Logout error:', error);
Alert.alert('Error', 'Logout failed');
} finally {
setLoggingOut(false);
}
}
}
]
);
};
const handleSelectUser = (recipientId: string, recipientName: string) => {
navigation.navigate('Chat', { recipientId, recipientName });
};
return (
<View style={styles.container}>
{/* Header Section with User Info and Logout */}
<View style={styles.headerSection}>
<View style={styles.userInfoSection}>
<Text style={styles.welcomeText}>Welcome, {userId}</Text>
</View>
<TouchableOpacity
style={[styles.logoutButton, loggingOut && styles.logoutButtonDisabled]}
onPress={handleLogout}
disabled={loggingOut}
>
{loggingOut ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.logoutButtonText}>πͺ Logout</Text>
)}
</TouchableOpacity>
</View>
{/* Main Content */}
<Text style={styles.header}>Select a user to chat with:</Text>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Loading users...</Text>
</View>
) : (
<FlatList
data={users}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.userCard}
onPress={() => handleSelectUser(item.id, item.name)}
activeOpacity={0.7}
>
<View style={styles.userInfo}>
<Text style={styles.userName}>{item.name}</Text>
<Text style={styles.userId}>{item.id}</Text>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No other users found.</Text>
<TouchableOpacity style={styles.refreshButton} onPress={fetchUsers}>
<Text style={styles.refreshButtonText}>π Refresh</Text>
</TouchableOpacity>
</View>
}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}
3. ChatScreen.tsx
(Chat Interface)
// ChatScreen.tsx - Plaintext chat (no encryption)
import React, { useEffect, useState, useCallback } from 'react';
import {
ActivityIndicator,
View,
TextInput,
TouchableOpacity,
Text,
StyleSheet,
Alert,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RouteProp } from '@react-navigation/native';
import { Chat, Channel, MessageList } from 'stream-chat-expo';
import { streamClient } from '../utils/streamClient';
import { useUser } from '../context/UserContext';
import { RootStackParamList } from '../App';
type ChatScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Chat'>;
type ChatScreenRouteProp = RouteProp<RootStackParamList, 'Chat'>;
interface Props {
navigation: ChatScreenNavigationProp;
route: ChatScreenRouteProp;
}
export default function ChatScreen({ navigation, route }: Props) {
const { recipientId, recipientName } = route.params;
const { userId, token } = useUser();
const [channel, setChannel] = useState<any>(null);
const [loading, setLoading] = useState(true);
const channelId = [userId, recipientId].sort().join('_');
useEffect(() => {
if (!userId || !token) {
navigation.navigate('Auth');
return;
}
(async () => {
try {
if (!streamClient.user) {
await streamClient.connectUser({ id: userId!, name: userId! }, token!);
}
const chatChannel = streamClient.channel('messaging', channelId, {
members: [userId!, recipientId],
name: `Chat with ${recipientName}`,
} as any);
await chatChannel.watch();
setChannel(chatChannel);
} catch (e) {
console.error('Chat setup failed:', e);
Alert.alert('Error', 'Failed to open chat');
} finally {
setLoading(false);
}
})();
}, [userId, token, recipientId]);
const handleSendMessage = useCallback(
async (text: string) => {
if (!text.trim() || !channel) return;
try {
await channel.sendMessage({ text }); // plaintext
} catch (e) {
console.error('Send failed:', e);
Alert.alert('Error', 'Failed to send');
}
},
[channel]
);
if (loading || !channel) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Connecting...</Text>
</View>
);
}
return (
<Chat client={streamClient}>
<Channel channel={channel}>
<View style={styles.header}>
<Text style={styles.headerText}>{recipientName}</Text>
</View>
<MessageList />
<CustomMessageInput onSendMessage={handleSendMessage} />
</Channel>
</Chat>
);
}
const CustomMessageInput = ({ onSendMessage }: { onSendMessage: (text: string) => void }) => {
const [inputText, setInputText] = useState('');
const [sending, setSending] = useState(false);
const handleSend = async () => {
if (!inputText.trim() || sending) return;
setSending(true);
await onSendMessage(inputText);
setInputText('');
setSending(false);
};
return (
<View style={styles.inputContainer}>
<TextInput
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
multiline
maxLength={2000}
editable={!sending}
/>
<TouchableOpacity
style={[styles.sendButton, (!inputText.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!inputText.trim() || sending}
>
<Text style={styles.sendButtonText}>π</Text>
</TouchableOpacity>
</View>
);
};
Message Security in Stream
When you send a message using Stream, it is encrypted by default at two levels:
- In transit (TLS 1.2): Messages are transmitted securely over the internet using TLS, which creates a secure tunnel between your app and Stream's servers. This prevents attackers from snooping on your messages while they travel over Wi-Fi or mobile networks.
- At rest (AES-256): Once stored on Stream's servers, messages are encrypted using AES-256, a tough encryption standard also used by banks and governments. This ensures that even if someone gained access to Stream's storage, they couldn't easily read your messages.
Important: This is not end-to-end encryption (E2EE). Stream's default setup still makes messages accessible to Stream's servers. This is necessary for features like moderation, search, and translations. In true E2EE, only the sender and receiver can read the messages, not even the service provider.
Here's an example of a message sent in our current app. Notice that the message data is available on Stream's servers:
Understanding End-to-End Encryption: The Why and How
End-to-end encryption (E2EE) is the gold standard for secure communication, ensuring that only the intended parties can read the exchanged messages. In this React Native chat application, E2EE is implemented using a hybrid cryptographic approach that combines the security of public-key cryptography with the efficiency of symmetric encryption.
The sections below detail the cryptographic foundation, key management, and message flow.
Cryptographic Foundation
Elliptic Curve Cryptography (ECC)
This application uses the secp256k1
elliptic curve, which is employed by Bitcoin and other major cryptocurrencies.
Why secp256k1
?
- Security: 256-bit keys provide equivalent security to 3072-bit RSA keys
- Efficiency: Smaller key sizes result in faster computations and reduced bandwidth
- Proven Track Record: It's been extensively tested and analysed by the cryptographic community
//utils/encryption.ts -
import { secp256k1 } from '@noble/curves/secp256k1';
import { getRandomBytes } from 'expo-crypto';
import { gcm } from '@noble/ciphers/aes';
import { sha256 } from '@noble/hashes/sha256';
import { hkdf } from '@noble/hashes/hkdf';
import * as SecureStore from 'expo-secure-store';
React Native lacks built-in crypto APIs, so we rely on:
-
Polyfills:
react-native-get-random-values
for Web Crypto API support -
Crypto libraries:
@noble/curves
for ECC operation -
Secure storage:
expo-secure-store
for persisting private keys
Key Generation Process
Each user's cryptographic identity is established through a secure key pair generation process:
export interface KeyPair {
publicKey: string; // Hex-encoded public key
privateKey: string; // Hex-encoded private key
}
The private key is a 256-bit random number, while the public key is derived mathematically from the private key using elliptic curve point multiplication. For maximum compatibility, the uncompressed format (65 bytes) is used.
// Generate a secure key pair using secp256k1 (same curve as Bitcoin)
export async function generateKeyPair(): Promise<KeyPair> {
try {
console.log('π Generating new cryptographic key pair...');
// Generate a random private key using Expo's crypto
const privateKeyBytes = getRandomBytes(32);
// Ensure the private key is valid for secp256k1
let attempts = 0;
let validPrivateKey = privateKeyBytes;
while (attempts < 10) {
try {
// Check if this private key is valid
const publicKeyPoint = secp256k1.getPublicKey(validPrivateKey, false);
// If we get here, the key is valid
return {
privateKey: bytesToHex(validPrivateKey),
publicKey: bytesToHex(publicKeyPoint)
};
} catch (error) {
// Invalid key, generate a new one
validPrivateKey = getRandomBytes(32);
attempts++;
}
}
throw new Error('Could not generate valid private key after 10 attempts');
} catch (error) {
console.error('Key generation error:', error);
throw new Error('Failed to generate cryptographic keys');
}
}
Key Storage Security
Private keys are never stored in plaintext; instead, they are stored using expo-secure-store
, which leverages:
- iOS: Keychain Services with hardware security module integration when available
- Android: EncryptedSharedPreferences with Android Keystore backing
// Store keys securely using Expo SecureStore
export async function storeKeyPair(userId: string, keyPair: KeyPair): Promise<void> {
try {
const keyData = JSON.stringify(keyPair);
await SecureStore.setItemAsync(`crypto_keys_${userId}`, keyData);
console.log(`β
Keys securely stored for user: ${userId}`);
} catch (error) {
console.error('Key storage error:', error);
throw new Error('Failed to store encryption keys');
}
}
This ensures private keys remain safe even if the app's storage is compromised.
Note: Avoid AsyncStorage
for keys; it's not secure. Always use expo-secure-store
or a native wrapper.
Key Exchange Protocol
Elliptic Curve Diffie-Hellman (ECDH)
The application implements ECDH key exchange to establish shared secrets between communicating parties:
- Alice has a key pair
(a, A)
wherea
= private key,A
= public key - Bob has a key pair
(b, B)
whereb
= private key,B
= public key - Shared Secret =
a Γ B = b Γ A
(due to elliptic curve mathematics)
Key Derivation Function
The raw ECDH secret isn't used directly. Instead, it's expanded into a strong AES key using HKDF (HMAC-based Key Derivation Function) with SHA-256.
const derivedKey = hkdf(sha256, sharedSecret, salt, info, 32);
This step:
- Strengthens entropy
- Ensures forward secrecy
- Produces a consistent 256-bit key
// Perform ECDH key exchange to derive shared secret
export function deriveSharedSecret(privateKey: string, publicKey: string): Uint8Array {
try {
const privateKeyBytes = hexToBytes(privateKey);
const publicKeyBytes = hexToBytes(publicKey);
// Perform ECDH
const sharedPoint = secp256k1.getSharedSecret(privateKeyBytes, publicKeyBytes, false);
// Use HKDF to derive a proper encryption key from the shared secret
// This adds forward secrecy and proper key derivation
const salt = new Uint8Array(32); // Zero salt for simplicity, could be random
const info = new TextEncoder().encode('ChatAppE2E');
const derivedKey = hkdf(sha256, sharedPoint.slice(1, 33), salt, info, 32);
return derivedKey;
} catch (error) {
console.error('Shared secret derivation error:', error);
throw new Error('Failed to derive shared secret');
}
}
Message Encryption Process
AES-256-GCM Symmetric Encryption
Once the shared key is established, messages are encrypted using AES-256 in Galois/Counter Mode (GCM):
AES-256: Advanced Encryption Standard with 256-bit keys.
GCM Mode: Provides both confidentiality and authenticity.
Encryption flow
For each message, the following process occurs:
- Shared Secret Derivation: ECDH between the sender's private key and the recipient's public key
- Key Derivation: HKDF transforms the shared secret into an AES key
- IV Generation: A random 96-bit initialisation vector is generated
- Encryption: AES-GCM encrypts the plaintext message
- Authentication: GCM automatically generates a 128-bit authentication tag
- Packaging: All cryptographic components are combined into a structured JSON format for transmission
Example JSON output:
{
"ciphertext": "hex-encoded encrypted data",
"iv": "hex-encoded initialization vector",
"authTag": "hex-encoded authentication tag"
}
export interface EncryptedMessage {
ciphertext: string; // Hex-encoded encrypted data
iv: string; // Hex-encoded initialization vector
authTag: string; // Hex-encoded authentication tag
}
// Encrypt a message using AES-GCM
export async function encryptMessage(
message: string,
senderPrivateKey: string,
recipientPublicKey: string
): Promise<string> {
try {
console.log('π Starting message encryption...');
// Derive shared secret
const sharedSecret = deriveSharedSecret(senderPrivateKey, recipientPublicKey);
// Generate random IV (12 bytes for GCM)
const iv = getRandomBytes(12);
// Convert message to bytes
const messageBytes = new TextEncoder().encode(message);
// Create AES-GCM cipher
const aes = gcm(sharedSecret, iv);
// Encrypt the message
const encrypted = aes.encrypt(messageBytes);
// Extract the ciphertext and auth tag
const ciphertext = encrypted.slice(0, -16); // All but last 16 bytes
const authTag = encrypted.slice(-16); // Last 16 bytes
// Package the result
const result: EncryptedMessage = {
ciphertext: bytesToHex(ciphertext),
iv: bytesToHex(iv),
authTag: bytesToHex(authTag)
};
console.log('β
Message encrypted successfully');
return JSON.stringify(result);
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt message');
}
}
This guarantees:
- Confidentiality: Only intended parties can decrypt
- Integrity: Tampering is detected
- Authenticity: Sender identity is verifiable
- Forward secrecy: Every message gets a fresh key
Message Decryption Process
Decryption Workflow
The reverse process recovers the plaintext:
- Parse encrypted JSON
- Derive shared secret (ECDH)
- Run HKDF β AES key
- Validate the auth tag
- Decrypt ciphertext β UTF-8 message
// Decrypt a message using AES-GCM
export async function decryptMessage(
encryptedData: string,
recipientPrivateKey: string,
senderPublicKey: string
): Promise<string> {
try {
console.log('π Starting message decryption...');
// Parse encrypted data
const data: EncryptedMessage = JSON.parse(encryptedData);
// Derive shared secret
const sharedSecret = deriveSharedSecret(recipientPrivateKey, senderPublicKey);
// Convert hex data back to bytes
const ciphertext = hexToBytes(data.ciphertext);
const iv = hexToBytes(data.iv);
const authTag = hexToBytes(data.authTag);
// Combine ciphertext and auth tag for decryption
const encryptedWithTag = new Uint8Array(ciphertext.length + authTag.length);
encryptedWithTag.set(ciphertext, 0);
encryptedWithTag.set(authTag, ciphertext.length);
// Create AES-GCM cipher and decrypt
const aes = gcm(sharedSecret, iv);
const decrypted = aes.decrypt(encryptedWithTag);
// Convert back to string
const decryptedMessage = new TextDecoder().decode(decrypted);
console.log('β
Message decrypted successfully');
return decryptedMessage;
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt message');
}
}
Key Management Architecture
Key Lifecycle
Generate: Random key pairs per user
Store: Securely in Keychain / Keystore
Exchange: Public keys shared via backend
Use: Private keys never leave device
Delete: Secure wipe on logout or reset
Key Fingerprinting
Each public key is fingerprinted using SHA-256 to prevent impersonation or tampering.
// Generate a fingerprint for a public key (for verification)
export function generateKeyFingerprint(publicKey: string): string {
try {
const pubKeyBytes = hexToBytes(publicKey);
const hash = sha256(pubKeyBytes);
return bytesToHex(hash.slice(0, 8)); // First 8 bytes as hex
} catch (error) {
console.error('Fingerprint generation error:', error);
return 'invalid';
}
}
Fingerprints give users a human-verifiable way to confirm they're communicating with the right person.
Integrating E2EE Into the Chat App
In the typical chat application we built earlier, the text sent between users is visible to the Stream server in plaintext. With E2EE applied, the server only relays encrypted payloads; it never sees message contents.
After implementing the encryption process, coding it, and storing the logic in encryption.ts
, the functions are exported into a React Context. This centralizes all end-to-end encryption states and operations, so UI screens can call simple methods without handling cryptographic details or private keys directly.
// context/RealEncryptionContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
import {
KeyPair,
generateKeyPair,
storeKeyPair,
loadKeyPair,
deleteKeyPair,
encryptMessage,
decryptMessage,
isValidPublicKey,
generateKeyFingerprint,
checkKeysExist
} from '../utils/encryption';
interface EncryptionContextType {
keyPair: KeyPair | null;
isKeysLoaded: boolean;
generateKeys: (userId: string) => Promise<void>;
loadKeys: (userId: string) => Promise<boolean>;
encryptMessage: (message: string, recipientPublicKey: string) => Promise<string>;
decryptMessage: (encryptedMessage: string, senderPublicKey: string) => Promise<string>;
getPublicKeyString: () => string | null;
getKeyFingerprint: () => string | null;
validatePublicKey: (publicKey: string) => boolean;
clearKeys: () => void;
deleteUserKeys: (userId: string) => Promise<void>;
checkUserHasKeys: (userId: string) => Promise<boolean>;
}
const EncryptionContext = createContext<EncryptionContextType | undefined>(undefined);
export function RealEncryptionProvider({ children }: { children: ReactNode }) {
// The necessary functions are to be here, which can be found in the complete codebase
return (
<EncryptionContext.Provider value={{
keyPair,
isKeysLoaded,
generateKeys,
loadKeys,
encryptMessage: encryptMessageWrapper,
decryptMessage: decryptMessageWrapper,
getPublicKeyString,
getKeyFingerprint,
validatePublicKey,
clearKeys,
deleteUserKeys,
checkUserHasKeys
}}>
{children}
</EncryptionContext.Provider>
);
}
export function useRealEncryption() {
const context = useContext(EncryptionContext);
if (!context) {
throw new Error('useRealEncryption must be used within a RealEncryptionProvider');
}
return context;
}
Wrapping the App in the Encryption Provider
To integrate E2EE into the chat:
- Remember that each user has two keys:
- The private key, stored securely on the device.
- The public key, generated during registration and stored in Stream for others.
- Wrap your screens in the
RealEncryptionProvider
. - Use
react-native-get-random-values
as a polyfill for generating secure random values (must be imported first).
// in App.tsx
import 'react-native-get-random-values'; // Must be first import
import { RealEncryptionProvider } from './context/EncryptionContext';
<RealEncryptionProvider>
{/* your navigators/screens */}
</RealEncryptionProvider>
User Registration with Keys
After registration, the user logs in using the normal process, after checking for the keys. When registering a user, keys must be generated before connecting to the backend (/register
). If keys already exist, they are loaded instead of regenerated.
// AuthScreen.tsx
const handleRegister = async () => {
if (!userId || !name) {
Alert.alert('Error', 'Please enter user ID and name');
return;
}
setLoading(true);
try {
// Clear any existing keys first
clearKeys();
// Check if user already has keys
const hasExistingKeys = await checkUserHasKeys(userId);
if (hasExistingKeys) {
// Load existing keys instead of generating new ones
const keysLoaded = await loadKeys(userId);
if (!keysLoaded) {
throw new Error('Failed to load existing keys');
}
Alert.alert(
'Existing Keys Found',
'This user already has encryption keys. Using existing keys for registration.',
[{ text: 'Continue', onPress: () => proceedWithRegistration() }]
);
return;
}
// Generate new encryption keys for this user
await generateKeys(userId);
await proceedWithRegistration();
} catch (error) {
console.error('Registration error:', error);
Alert.alert('Error', 'Registration failed. Please try again.');
}
setLoading(false);
};
Encrypting Messages Before Sending
ChatScreen
fetches the partnerβs public key, then calls encryptMessage
before sending and decryptMessage
on receive.
When sending a message, the ChatScreen.tsx
fetches the recipient's public key, encrypts the message locally, and sends only the encrypted payload to Stream.
// Add to ChatScreen.tsx
const handleSendMessage = useCallback(async (messageText: string) => {
if (!messageText.trim() || !channel || !recipientPublicKey) return;
try {
console.log('π Encrypting message...');
// Encrypt the message using real E2E encryption
const encryptedMessage = await encryptMessage(messageText, recipientPublicKey);
console.log('π€ Sending encrypted message...');
// Send the encrypted message
const messageData = {
text: encryptedMessage, // Only encrypted JSON goes to Stream
encrypted: true,
};
const sentMessage = await channel.sendMessage(messageData);
// Cache the original text for immediate display
if (sentMessage.message?.id) {
decryptedMessagesRef.current.set(sentMessage.message.id, messageText);
// Update the display immediately for the sender
sentMessage.message.text = messageText;
sentMessage.message.processed = true;
processedMessagesRef.current.add(sentMessage.message.id);
}
console.log('β
Message sent and encrypted successfully');
} catch (error) {
console.error('β Failed to send encrypted message:', error);
Alert.alert('Error', 'Failed to send message. Please try again.');
}
}, [channel, recipientPublicKey, encryptMessage]);
Decrypting Messages Upon Receiving
Messages received from Stream are decrypted before being displayed. If decryption fails, the user sees a placeholder.
// Add to ChatScreen.tsx
const processMessageForDisplay = async (message: any, senderPublicKey: string) => {
// Add comprehensive null/undefined checks
if (!message || !message.user || !message.id) {
console.log('Skipping invalid message:', message);
return;
}
// Skip if already processing or processed
if (isProcessingRef.current.has(message.id) || processedMessagesRef.current.has(message.id)) {
return;
}
// Ensure message has required properties
if (!message.text || typeof message.text !== 'string') {
console.log('Message missing text property:', message);
processedMessagesRef.current.add(message.id);
return;
}
console.log('π Processing message:', {
id: message.id,
userId: message.user?.id,
encrypted: message.encrypted,
textLength: message.text.length
});
// Mark as processing
isProcessingRef.current.add(message.id);
try {
// If it's an encrypted message, decrypt it
if (message.encrypted) {
// Check if we already have a decrypted version cached
if (decryptedMessagesRef.current.has(message.id)) {
message.text = decryptedMessagesRef.current.get(message.id)!;
console.log('β
Using cached decrypted message');
} else {
// Only decrypt if this looks like encrypted JSON data
const isEncryptedData = message.text.startsWith('{"ciphertext"');
if (isEncryptedData) {
let decryptedText: string;
if (message.user.id === userId) {
// Our own message - decrypt using recipient's public key
console.log('π Decrypting own message...');
decryptedText = await decryptMessage(message.text, senderPublicKey!);
} else {
// Message from other person - decrypt using their public key
console.log('π Decrypting received message...');
decryptedText = await decryptMessage(message.text, senderPublicKey);
}
// Cache the decrypted text
decryptedMessagesRef.current.set(message.id, decryptedText);
message.text = decryptedText;
console.log('β
Message decrypted and cached successfully');
} else {
// This might be a message that was already processed but lost its decrypted state
message.text = '[π Message needs refresh - reopen chat]';
}
}
}
message.processed = true;
processedMessagesRef.current.add(message.id);
} catch (error) {
console.error('β Failed to decrypt message:', error);
message.text = '[π Failed to decrypt - may be corrupted]';
processedMessagesRef.current.add(message.id);
} finally {
// Remove from processing set
isProcessingRef.current.delete(message.id);
}
};
App in Action
When two users (Momo12
and Spice12
) chat, the messages are displayed differently depending on where theyβre viewed:
- On their devices: messages appear as normal plaintext
- On Stream's servers: only ciphertext is visible, in the following format:
{ "ciphertext":"3d82a8cf09", "Iv":"dda8821c49bd8e945fdac36f", "authTag":"15d6aa9921245c21d765f84f8ec47e99" }
The terminal output below shows the encrypted messages being exchanged between two users during a chat session:
This is the final structure of the app:
my-chat-app/
βββ App.tsx
βββutils/
β βββ encryption.ts
β βββ streamClient.ts
βββ context/
β βββ EncryptionContext.tsx
β βββ UserContext.tsx
βββ config/
β βββ constants.ts
βββ screens/
β βββ AuthScreen.tsx
β βββ UserListScreen.tsx
β βββ ChatScreen.tsx
βββ assets/
βββ app.json
βββ package.json
βββ tsconfig.json
βββ node_modules/
You can explore the full implementation of the chat app in this repository.
Conclusion
This React Native chat application demonstrates a robust end-to-end encryption (E2EE) design built with well-established cryptographic primitives. The system keeps messages private and secure by using ECDH for key exchange and AES-GCM for encryption, while maintaining ease of use on mobile.
With a zero-knowledge setup, no third party (not even the service provider) can read messages. Keys are stored securely and managed carefully to prevent leaks or misuse.
However, E2EE impacts moderation and observability:
- Since payloads are encrypted, the server cannot inspect them.
- This disables standard tools such as:
- Block lists (preventing specific words/phrases)
- Advanced filters (domains, emails, regex rules)
- AI moderation (behavioral analysis)
- Pre-send hooks (no access to plaintext)
Instead, developers must adopt new approaches such as client-side filtering, metadata-based heuristics, or opt-in reporting flows.
Despite some trade-offs, end-to-end encryption gives mobile apps bank-level privacy while limiting moderation optionsβa balance developers must account for when building real-world chat.
Top comments (0)