DEV Community

Cover image for How to Build a Secure React Native Chat App with End-to-End Encryption
Quincy Oghenetejiri for Stream

Posted on • Originally published at getstream.io

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

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

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
Enter fullscreen mode Exit fullscreen mode

Now, create a .env file there:

STREAM_API_KEY=Your_stream_api_key
STREAM_API_SECRET=Your_stream_api_secret_key
PORT=5000
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Starting Template

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

Sending Message in Stream without encryption

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';
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

const derivedKey = hkdf(sha256, sharedSecret, salt, info, 32);
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

Message Encryption Process

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:

{
  "ciphertext": "hex-encoded encrypted data",
  "iv": "hex-encoded initialization vector",
  "authTag": "hex-encoded authentication tag"
}
Enter fullscreen mode Exit fullscreen mode
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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  };
Enter fullscreen mode Exit fullscreen mode

Authentication Screen

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]);
Enter fullscreen mode Exit fullscreen mode

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);
    }
  };
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

Encrypted Chat 1

Encrypted Chat 2
The terminal output below shows the encrypted messages being exchanged between two users during a chat session:

Terminal Showing Successful Encryption

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/
Enter fullscreen mode Exit fullscreen mode

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)