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
const PORT = process.env.PORT || 5000; app.listen(PORT, () => { console.log(

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

bash
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
// 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

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
// 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

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
//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

2. `UsersScreen.tsx` (User List and Logout)

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

3. `ChatScreen.tsx` (Chat Interface)

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

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
js
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:

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

Get started! Activate your free Stream account today and start prototyping with chat.
js
1
2
// Generate a secure key pair using secp256k1 (same curve as Bitcoin) export async function generateKeyPair(): Promise

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
js
1
2
// Store keys securely using Expo SecureStore export async function storeKeyPair(userId: string, keyPair: KeyPair): Promise

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.

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

This step:

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

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

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
js
1
2
3
4
5
6
// Decrypt a message using AES-GCM export async function decryptMessage( encryptedData: string, recipientPrivateKey: string, senderPublicKey: string ): Promise

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.

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

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

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).
js
1
2
3
// in App.tsx import 'react-native-get-random-values'; // Must be first import import { RealEncryptionProvider } from './context/EncryptionContext';

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.

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

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

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
// 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:
bash
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 ->