Build multi-modal AI applications using our new open-source Vision AI SDK.

How to Build a Secure React Native Chat App with End-to-End Encryption

Learn how to bring enterprise-grade security to your React Native chat apps with a practical end-to-end encryption walkthrough.

Quincy O.
Quincy O.
Published October 7, 2025
Build a Secure Chat App cover image

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:

bash
1
2
npm init npm install express dotenv stream-chat nodemon cors

Now, create a .env file there:

bash
1
2
3
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:

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

bash
1
2
3
4
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.

js
1
2
3
4
5
6
7
8
9
10
11
12
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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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:

bash
1
2
3
# 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:

bash
1
2
3
4
5
6
7
8
9
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.).

bash
1
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.

json
1
2
3
4
{ "name": "navigation", "main": "App.tsx", "version": "1.0.0",

The App.tsx will work as the root file for our React Native application.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 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(); function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <UserProvider> <OverlayProvider> <NavigationContainer> <Stack.Navigator initialRouteName="Auth"> <Stack.Screen name="Auth" component={AuthScreen} options={{ headerShown: false }} /> <Stack.Screen name="Users" component={UsersScreen} options={{ title: "Users" }} /> <Stack.Screen name="Chat" component={ChatScreen} options={({ route }) => ({ title: `🔒 ${route.params.recipientName}`, headerStyle: { backgroundColor: "#2d7d2d", }, })} /> </Stack.Navigator> </NavigationContainer> </OverlayProvider> </UserProvider> </GestureHandlerRootView> ); } export default registerRootComponent(App);

Setting Up the Initial Configuration

First, let's set up the Stream client.

js
1
2
3
4
5
//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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 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(undefined); export function UserProvider({ children }: { children: ReactNode }) { const [userId, setUserId] = useState(null); const [token, setToken] = useState(null); const setUser = (newUserId: string, newToken: string) => { setUserId(newUserId); setToken(newToken); }; const clearUser = () => { setUserId(null); setToken(null); }; return { children }; } 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:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//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; 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 contentContainerStyle={styles.container}> <Text style={styles.title}>Chat App</Text> <TextInput style={styles.input} placeholder="User ID" value={userId} onChangeText={setUserId} autoCapitalize="none" /> <TextInput style={styles.input} placeholder="Name" value={name} onChangeText={setName} /> {loading ? ( <ActivityIndicator /> ) : ( <View> <TouchableOpacity style={styles.button} onPress={handleRegister}> <Text style={styles.buttonText}>Register</Text> </TouchableOpacity> <TouchableOpacity style={styles.button} onPress={handleLogin}> <Text style={styles.buttonText}>Login</Text> </TouchableOpacity> </View> )} </ScrollView> ); }
  1. UsersScreen.tsx (User List and Logout)
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// 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.header}> <Text style={styles.welcome}>Welcome, {userId}</Text> <TouchableOpacity style={styles.logoutButton} onPress={handleLogout}> {loggingOut ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.logoutText}>Logout</Text> )} </TouchableOpacity> </View> {/* Main Content */} <Text style={styles.subtitle}>Select a user to chat with:</Text> {loading ? ( <View style={styles.center}> <ActivityIndicator /> <Text>Loading users...</Text> </View> ) : ( <FlatList data={users} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <TouchableOpacity style={styles.userRow} onPress={() => handleSelectUser(item.id, item.name)} activeOpacity={0.7} > <Text style={styles.userName}>{item.name}</Text> <Text style={styles.userId}>{item.id}</Text> </TouchableOpacity> )} ListEmptyComponent={ <View style={styles.center}> <Text>No other users found.</Text> <TouchableOpacity onPress={fetchUsers}> <Text>Refresh</Text> </TouchableOpacity> </View> } showsVerticalScrollIndicator={false} /> )} </View> ); }
  1. ChatScreen.tsx (Chat Interface)
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// 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.center}> <ActivityIndicator /> <Text>Connecting...</Text> </View> ); } return ( <Chat client={streamClient}> <Channel channel={channel}> <View style={styles.header}> <Text style={styles.headerTitle}>{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.inputRow}> <TextInput style={styles.messageInput} value={inputText} onChangeText={setInputText} placeholder="Type a message..." /> <TouchableOpacity style={styles.sendButton} onPress={handleSend} disabled={sending} > {sending ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.sendText}>Send</Text> )} </TouchableOpacity> </View> ); };

Message Security in Stream

When you send a message using Stream, it is encrypted by default at two levels:

  1. 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.

  2. 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
tsx
1
2
3
4
5
6
7
//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:

tsx
1
2
3
4
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.

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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"); } }
Get started! Activate your free Stream account today and start prototyping your chat app.

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
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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:

  1. Alice has a key pair (a, A) where a = private key, A = public key
  2. Bob has a key pair (b, B) where b = private key, B = public key
  3. 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.

tsx
1
const derivedKey = hkdf(sha256, sharedSecret, salt, info, 32);

This step:

  • Strengthens entropy
  • Ensures forward secrecy
  • Produces a consistent 256-bit key
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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:

  1. Shared Secret Derivation: ECDH between the sender's private key and the recipient's public key
  2. Key Derivation: HKDF transforms the shared secret into an AES key
  3. IV Generation: A random 96-bit initialisation vector is generated
  4. Encryption: AES-GCM encrypts the plaintext message
  5. Authentication: GCM automatically generates a 128-bit authentication tag
  6. Packaging: All cryptographic components are combined into a structured JSON format for transmission

Example JSON output:

json
1
2
3
4
5
{ "ciphertext": "hex-encoded encrypted data", "iv": "hex-encoded initialization vector", "authTag": "hex-encoded authentication tag" }
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 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.

tsx
1
2
3
4
5
6
7
8
9
10
11
// 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.

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 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={{} as EncryptionContextType}> {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).
tsx
1
2
3
4
5
6
7
8
// 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.

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 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.

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 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.

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 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 (as seen in the images below), 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:
json
1
2
3
4
5
{ "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:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more