How To Secure a Vite-Powered React App With Seald End-to-End Encryption

With data breaches becoming increasingly common globally, end-to-end encryption has become essential. This article provides a step-by-step guide on implementing robust security measures by encrypting a chat application end-to-end, ensuring that private information is shielded from unauthorized access.

Amos G.
Amos G.
Published August 1, 2024
Header image

When sending messages across a chat app, one way to ensure the data and conversations are not intercepted is to build proper security to shield people who use the app. This article dives into how to implement strong end-to-end (E2EE) encryption in your app to protect users. We will integrate Seald’s E2EE into Stream’s Chat to provide a secure chat messaging environment for all participants.

Refer to this introductory tutorial to learn more about general concepts of E2EE.

Project Goal

The objective of the demo project involved in this tutorial is to ensure that chat conversations are only visible to channel members using the Stream Chat React SDK and the Seald end-to-end encryption SDK. That means these messages are not sent in plain text and can not be read server-side, as this might be an attack vector for a malign party. The example above demonstrates an encrypted chat using the Seald SDK. You have observed that sending a plain text message or an attachment converts to a cipher text. That is our main task for this tutorial.

The final end-to-end encrypted project is on GitHub. You can follow the steps in this tutorial and set up your own Next.js application. Or you can also create a new GitHub Code Space to run the app. The .env file in the GitHub project requires your Seald credentials. For a sample of how the .env file should look, you can check the .env.sample file in the project. In the following sections, we’ll dive into creating Seald and Stream accounts.

Setting Up A Stream Account

This section focuses on signing up for a Stream Chat account to get user credentials for the app.

Create a Stream account

In the chat messaging part of the app, we should create a new app on Stream Dashboard. Then, create a new user and a chat channel. We can now generate a token using the account’s API Secret. Head to the Stream website, click Start Coding Free, and fill out your information to get an account.

After logging in to the account, you should do the following.

  • Create a new app by clicking the button Create App on the top right of the page.
  • To add a new channel, navigate to Chat Messaging -> Explorer -> Channels -> Create new channel. For this demo, let's use EncryptedChannel.
Create a new channel
  • Select Chat Messaging -> Explorer -> Users -> Create new user to add a new one. Let's call our new user TestUser.
Create a new user

We will shortly use the EncryptedChannel, and the TestUser to connect to the chat SDK's backend infrastructure.

Initiate a Next.js App

Unencrypted Chat

In this section, we create a new Next.js project and install all the necessary chat dependencies. We will add the end-to-end encryption dependencies when the unencrypted chat messaging functionality works successfully. Ensure you have npm or yarn installed, and create a new Next.js project with the following command:

bash
1
npx create-next-app@latest

We follow the setup process to make sure the project is correctly configured:

bash
1
2
3
4
5
6
7
8
9
What is your project named? end-to-end-encrypted-chat Would you like to use TypeScript? Yes Would you like to use ESLint? Yes Would you like to use Tailwind CSS? Yes Would you like your code inside a `src/` directory? No Would you like to use App Router? (recommended) Yes Would you like to use Turbopack for `next dev`? Yes Would you like to customize the import alias (`@/*` by default)? No What import alias would you like configured? @/*

We have end-to-end-encrypted-chat as the project name and use TypeScript, ESLint, and TailwindCSS. The rest of the setup follows the recommended steps.

Next, launch the project in VS Code or your favorite IDE, cd into the end-to-end-encrypted-chat directory and use yarn or npm i stream-chat stream-chat-react to fetch and install:

  • Stream Chat stream-chat: The core chat SDK without UI components.
  • Stream Chat React stream-chat-react: React chat SDK consisting of reusable UI components.

Note: In VS Code, you can press the keyboard shortcut ⌃` to bring the integrated Terminal and run the above command.

Configure the Chat SDK

In this section, we set up the chat SDK to provide basic chat messaging functionality. All the files can also be found in the GitHub repository.
Go to the app's app directory, open page.tsx, and start by defining a type for the HomeState at the top of the file:

typescript
1
2
3
4
5
type HomeState = { apiKey: string; user: User; token: string; };

We use this type to ensure that we initialize the chat only when we have received an authentication token (we will create the logic for this in the next step). The token is received using an API call to the route api/token.
We create a getUserToken function (wrapped in a useCallback hook) that we call in a useEffect hook. Here is the code for the page.tsx file:

typescript
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
export default function Home() { const [homeState, setHomeState] = useState<HomeState | undefined>(); const userId = 'TestUser'; const userName = 'TestUser'; const getUserToken = useCallback(async (userId: string, userName: string) => { const response = await fetch('/api/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: userId }), }); const responseBody = await response.json(); const token = responseBody.token; const user: User = { id: userId, name: userName, image: `https://getstream.io/random_png/?id=${userId}&name=${userName}`, }; const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY; if (apiKey) { setHomeState({ apiKey: apiKey, user: user, token: token }); } }, []); useEffect(() => { getUserToken(userId, userName); }, [userId, userName, getUserToken]); if (homeState) { return <MyChat {...homeState} /> } else { return <LoadingIndicator /> } }

We need to add two things. The first is to create a Route Handler that is executed server-side and generates a token for the current user (which is what we call inside the getUserToken function).
We create a new folder api, then inside a new folder called token and inside a file called route.ts. The code inside uses the Stream secret to issue a token for a user:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { StreamChat } from 'stream-chat'; export async function POST(request: Request) { const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY; const streamSecret = process.env.STREAM_SECRET; const serverClient = StreamChat.getInstance(apiKey, streamSecret); const body = await request.json(); const userId = body?.userId; const token = serverClient.createToken(userId); const response = { userId: userId, token: token, }; return Response.json(response); }

In the Route Handler we are using two environment variables. For that we create a new .env file in the route of our project and fill that with the values we get from the Stream Dashboard (see earlier section):

NEXT_PUBLIC_STREAM_API_KEY=<your-api-key>
STREAM_SECRET=<your-stream-secret>

Notably, the API key secret is prefixed with NEXT_PUBLIC_ because it will be available on the client side, which is safe from a security standpoint. However, the secret should never be exposed to the client and only be available on the server, so we omit the prefix here.

Now that the setup is done, we can focus on showing the UI. Inside page.tsx we used a component called MyChat, that we haven’t yet created. We create a new folder called components and inside create a file named MyChat.tsx.

We will need to add additional code later, but for now, we want to initialize a chat experience, and for that, we use the pre-built components that come with Stream’s React SDK.

Before we can use those, we need to initialize the SDK using the three properties we’ve defined in our HomeState earlier: an apiKey, an authentication token, and a user object. We hand those in as parameters to the component and then call the built-in useCreateChatClient hook.

The code for the entire MyChat.tsx file looks like this:

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
export default function MyChat({ apiKey, user, token, }: { apiKey: string; user: User; token: string; }) { const chatClient = useCreateChatClient({ apiKey: apiKey, tokenOrProvider: token, userData: user, }); if (!chatClient) { return <LoadingIndicator />; } // Chat sorting options const sort: ChannelSort = { last_message_at: -1 }; const filters: ChannelFilters = { type: 'messaging', members: { $in: [user.id] }, }; const options: ChannelOptions = { limit: 10, }; return ( <Chat client={chatClient}> <ChannelList filters={filters} sort={sort} options={options} /> <Channel Message={EncryptedMessage}> <Window> <ChannelHeader /> <MessageList /> <MessageInput /> </Window> <Thread /> </Channel> </Chat> ); }

In summary, we import the required chat components such as Chat, Channel, ChannelList, and others. We also add options for the channel list, such as sort, filters, and options. Then, we check if the chatClient has already been initialized. If not, we show a loading indicator; if yes, we show the whole chat experience.

Add Styling To The Chat Layout

We can run the app but we will see that the styling is off. We need to follow two more steps to fix this. The first is to import the CSS code that ships with the Stream SDK. This is a one-liner that we add to the top of the MyChat.tsx file:

tsx
1
import 'stream-chat-react/dist/css/v2/index.css';

What it does is make sure the necessary CSS classes and code are loaded that are used inside the custom components that we added to the MyChat.tsx file. There are many options to customize this; you can learn more in the Theming section of our docs.

The second step is to add the following CSS code to the globals.css file in our app folder. Add the following code below the existing code in globals.css:

css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
body { display: flex; } .str-chat__channel-list { width: 30%; } .str-chat__channel { width: 100%; } .str-chat__thread { width: 45%; }

Press control and backtick, ⌃ to launch the Terminal in VS Code and run npm run dev to start the development server. We will see Local: http://localhost:3000/` to preview the app. The output of the unencrypted chat interface is similar to the video preview below.

We can now see that sending and reading messages works for all members of a channel. However, these chats are not encrypted, meaning they are stored in plain text on the backend, which offers a vulnerability if a malign party achieves access to our servers. Let's fix that by securing messages to make them non-human readable with end-to-end encryption in the following sections.

Setup The Seald SDK For E2E-Encryption

Create a seald account

To use the Seald client-side encryption SDK for the first time, we need to create a staging environment account on their website. Once our account is ready, we can sign in to the dashboard to get started with our generated JSON Web Token secret or create a new JWT secret in the administration panel.

First, copy all the user credentials from your Seald administration dashboard. Open the .env file in the project's root folder and add the following code below the previous credentials (replace with the your own dashboard data):

VITE_SEALD_APP_ID="YOUR_SEALD_APP_ID"
VITE_API_URL="https://api.staging-0.seald.io/"
KEY_STORAGE_URL="https://ssks.staging-0.seald.io/"
VITE_JWT_SECRET="YOUR_JWT_SECRET"
VITE_JWT_SECRET_ID="YOUR_JWT_SECRET_ID"

To integrate the Seald encryption service directly into our app, we must install the NPM package @seald-io/sdk which is compatible with both mobile and web apps. In our project, we will navigate to the root directory and run one of the following command to install the SDK.

bash
1
2
3
npm i -S @seald-io/sdk #or yarn add @seald-io/sdk

Inside the SDK we need to create identities to manage the encryption and who has permission the decrypt certain messages. For this we can use passwort protection of the identities (there is also the alternative to use a 2-man-rule).

We can install the relevant password protection package like this:

bash
1
2
3
npm i -S @seald-io/sdk-plugin-ssks-password #or yarn add @seald-io/sdk-plugin-ssks-password

To verify if the above packages were installed successfully, open your project’s package.json and check the dependencies section.

json
1
2
3
4
5
6
7
8
9
"dependencies": { "@seald-io/sdk": "^0.30.0", "@seald-io/sdk-plugin-ssks-password": "^0.30.0", "next": "14.2.10", "react": "^18", "react-dom": "^18", "stream-chat": "^8.40.6", "stream-chat-react": "^11.23.9" },

Initializing The Seald SDK With A Context Object

After installing the Seald SDK, there are a few steps to follow to encrypt and decrypt an app. To do this we will create a Context object (see more info on the topic here) to inject the necessary data into the DOM tree.

To have a central way of handling all encryption-related matters, we inject a SealdContextProvider. This takes care of holding on to the relevant variables and provides the necessary functions for performing the encryption and decryption of messages.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Create a new folder called contexts and inside a new file called SealdContext.tsx. The first thing we do is to import the dependencies for the Seald SDKs:

typescript
1
2
import SealdSDK from '@seald-io/sdk import SealdSDKPluginSSKSPassword from '@seald-io/sdk-plugin-ssks-password

Then, we define a state called SealdState for this (check the code comments for what each variable does):

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type SealdState = { sealdClient: typeof SealdSDK | undefined; // necessary for calling further functions encryptionSession: EncryptionSession | undefined; // shared session to continuously encrypt and decrypt messages sealdId: string | undefined; // the user id necessary for encryption loadingState: 'loading' | 'finished'; initializeSeald: (userId: string, password: string) => void; createEncryptionSession: (sealdId: string, channelId: string) => void; encryptMessage: ( message: string, channelId: string, chatClient: StreamChat, customMessageData: Partial<Message<DefaultStreamChatGenerics>> | undefined, options: SendMessageOptions | undefined ) => Promise<void>; decryptMessage: (message: string, sessionId: string) => Promise<string>; };

Next, we define both the SealdContext and the SealdContextProvider objects:

typescript
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
export const SealdContext = createContext<SealdState>(initialValue); export const SealdContextProvider = ({ children, }: { children: React.ReactNode; }) => { const [myState, setMyState] = useState<SealdState>(initialValue); // More code to be inserted here const store: SealdState = { sealdClient: myState.sealdClient, encryptionSession: myState.encryptionSession, sealdId: myState.sealdId, loadingState: myState.loadingState, initializeSeald, createEncryptionSession, encryptMessage, decryptMessage, }; return ( <SealdContext.Provider value={store}>{children}</SealdContext.Provider> );

The skeleton is set up, but we still need to add the functions, so let's do this now.

We start off with the initializeSeald function. First, we must load the variables from our .env file. Then, we create two variables for the databaseKey (we will define the function for that in a moment) and the databasePath. We use these to initialize the SealdSDK object and call the initialize function afterward:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const appId = process.env.NEXT_PUBLIC_SEALD_APP_ID; const apiURL = process.env.NEXT_PUBLIC_SEALD_API_URL; const storageURL = process.env.NEXT_PUBLIC_KEY_STORAGE_URL; const databaseKey = await getDatabaseKey(userId); const databasePath = `seald-e2e-encrypted-chat-${userId}`; const seald = SealdSDK({ appId, apiURL, databaseKey, databasePath, plugins: [SealdSDKPluginSSKSPassword(storageURL)], }); await seald.initialize();

We create the database key and path to save the identity we create locally for the client. We can load it and avoid re-invoking identities whenever a new tab or something similar is opened.

To read more about this, we recommend following this documentation about using a persistent local database that stores the identity in a safe and encrypted way.

The third step is to get the identity we can use for encryption and decryption. For this we have to distinguish two cases. Here's the code first and we'll explain what we do afterwards:

typescript
1
2
3
4
5
6
7
8
9
let mySealdId: string | undefined = undefined; try { const accountInfo = await seald.getCurrentAccountInfo(); mySealdId = accountInfo.sealdId; } catch (error) { // Identity not found, we need to register the user mySealdId = await registerUser(seald, userId, password); }

The first one is that we have already created an identity prior and with the initialization of the SealdSDK object it is automatically retrieved. This is possible because of the databaseKey and databasePath paramenters that we have passed upon initialization. For this we can use the getCurrentAccountInfo function to get the identity (which will succeed and we won't jump into the catch clause we defined in code).

If that is not the case (and an exception is thrown, leading us to the catch statement) we need to register the user first. For that we need a JWT, that needs to be generated server-side (find a code sample here). With that we can first call the initiateIdentity function that we can then save using ssksPassword's saveIdentity function.

The final step is to update the current state with the updated values:

typescript
1
2
3
4
5
6
7
8
setMyState((myState) => { return { ...myState, sealdClient: seald, sealdId: mySealdId, loadingState: 'finished', }; });

Now, we’ve used two functions here that we have not yet defined, specifically the getDatabaseKey and the registerUser functions. For them, we create a new folder in the project’s root and call it lib.

First, let’s create a getDatabaseKey.ts file. This should technically create a unique token for a user that identifies them in the browser. Since we don’t cover the database aspect of user handling, we will use a simple hash function. Here’s the code to create a unique database key for each user:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use server'; export async function getDatabaseKey(userId: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(userId); const databaseKey = await crypto.subtle .digest('SHA-256', data) .then((hashBuffer) => { const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); return `seald_database_key_${hashHex}`; }); return databaseKey; }

Notice that the ’use server’ directive makes sure that the function gets called on the server-side for security reasons. That makes use of React’s new Server Actions that ship with Next.js (details here).

The other function we haven’t added yet is the registerUser function, so let’s create a file called registerUser.ts inside the lib folder. This should run client-side, so we add the ’use client’ directive at the top of the function to ensure this. It will initiate the identity and save it using the SSKSPassword plugin.

However, for that to be possible, it will need a unique signup JWT. This one, on the other hand, needs to be created server-side, as it needs access to the JWT_SECRET and the JWT_SECRET_ID that we defined earlier in our .env file. To create the signup JWT we need to add three more dependencies to our project, so please run one of the following commands for this:

bash
1
2
3
npm i -S jose buffer uuid #or yarn add jose buffer uuid

Then, create a new file called createSignupJWT.ts inside the lib folder and add the following code (for more info on the functionality, see this link):

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use server'; import { SignJWT } from 'jose'; import { v4 as uuidv4 } from 'uuid'; import { Buffer } from 'buffer'; export async function createSignupJWT() { const jwtSecret = process.env.JWT_SECRET; const jwtSecretId = process.env.JWT_SECRET_ID; const token = new SignJWT({ iss: jwtSecretId, jti: uuidv4(), iat: Math.floor(Date.now() / 1000), scopes: [3], join_team: true, }).setProtectedHeader({ alg: 'HS256' }); const signupJWT = await token.sign(Buffer.from(jwtSecret, 'ascii')); return signupJWT; }

With that, the content of registerUser.tsx becomes straightfoward:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use client'; import type { SealdSDK } from '@seald-io/sdk/browser'; import { createSignupJWT } from './createSignupJWT'; export async function registerUser( seald: SealdSDK, userId: string, password: string ): Promise<string> { const signUpJWT = await createSignupJWT(); const identity = await seald.initiateIdentity({ signupJWT: signUpJWT, }); await seald.ssksPassword.saveIdentity({ userId, password }); return identity.sealdId; }

We have finished the code for initializing the SealdSDK.

Encrypting and Decrypting Chat Messages

After executing the initialization logic, we assigned all the necessary variables, specifically the sealdClient and sealdId , and updated the loading state to be finished. We need to have an Encryption Session ready to encrypt and decrypt messages.

The encryption session should be updated whenever a new channel is active because each channel can have different members. If we previously created this, we can also retrieve a session but at some point, we first need to make these.

So, inside SealdContext.tsx we create a new createEncryptionSession function. It gets the sealdId and the channelId of the currently active channel as parameters. After creating a new encryption session, it updates the state with it (always wrapping it in a useCallback hook to avoid unnecessary re-renders):

typescript
1
2
3
4
5
6
7
8
9
10
11
12
const createEncryptionSession = useCallback( async (sealdId: string, channelId: string) => { const session = await myState.sealdClient.createEncryptionSession( { sealdIds: [sealdId], }, { metadata: channelId } ); return session; }, [myState.sealdClient] );

Thanks to all the work we've already done, the encryption and decryption parts are becoming very straightforward.

We can use the previously created encryptionSession to obtain a message and encrypt it with a single function call:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const encryptMessage = useCallback( async (message: string) => { if (myState.sealdId && myState.encryptionSession) { const encryptedMessage = await myState.encryptionSession.encryptMessage( message ); return encryptedMessage; } return message; }, [myState.sealdId, myState.encryptionSession] );

The same goes for the decrypting of a message. Here, we additionally check if there's a mismatch between the current encryption session and the one used for the specific message. If that is the case we update our state's encryption session because it's likely that there are more messages to come in the same channel.

Here's the full code for our decryptMessage function:

typescript
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
const decryptMessage = useCallback( async (message: string, sessionId: string) => { let encryptionSession = myState.encryptionSession; if (!encryptionSession || encryptionSession.sessionId !== sessionId) { // Either there is no session, or it doesn't match with the session id encryptionSession = await myState.sealdClient.retrieveEncryptionSession( { sessionId, } ); setMyState((myState) => { return { ...myState, encryptionSession: encryptionSession, }; }); } const decryptedMessage = (await encryptionSession?.decryptMessage(message)) || message; return decryptedMessage; }, [myState.encryptionSession, myState.sealdClient] );

With this we have all the logic necessary for end-to-end encryption. The only thing left to do is combine this with Stream's Chat SDK.

Sending And Receiving E2E-Encrypted Messages

Before we start with the sending and receiving of encrypted messages, we need to make sure the SealdContext object is initialized correctly. For this, we open up the page.tsx file and wrap our MyChat component in a SealdContextProvider like this:

tsx
1
2
3
<SealdContextProvider> <MyChat {...homeState} /> </SealdContextProvider>

Then, inside MyChat.tsx, we need to call the initializeSeald function from the SealdContext upon initialization using a useEffect hook. We add the following code below the call to the useCreateChatClient hook from earlier:

tsx
1
2
3
4
5
6
7
8
9
const { initializeSeald, loadingState, encryptMessage } = useSealdContext(); const initializationRef = useRef(false); useEffect(() => { if (!initializationRef.current && loadingState === 'loading') { initializationRef.current = true; initializeSeald(user.id, 'password'); } }, [initializeSeald, user.id, loadingState]);

The initializationRef object ensures that this code is only called once (see useRef documentation).

We need to inject additional code into the previously created chat setup in two places. First, whenever a user sends a message, we need to make sure that we encrypt it before sending it. Second, before a message is displayed, it needs to be decrypted.

Let's start with the encryption and open up MyChat.tsx. The component responsible for this is the MessageInput component. It has a parameter called overrideSubmitHandler which is just what we need.

We can take the message.text parameter, hand it to our previously defined encryptMessage function from the SealdContext and then manually call the sendMessage function on the channel object. The code for this is straightforward:

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
<MessageInput overrideSubmitHandler={async ( message, channelId, customMessageData, options) => { let messageToSend = message.text; if (messageToSend) { messageToSend = await encryptMessage(message.text); } try { const channel = client.channel('messaging', channelId); const sendResult = await channel.sendMessage({ text: messageToSend, customMessageData, options, }); console.log('sendResult', sendResult); } catch (error) { console.error('Error encrypting message: ', error); } }} />

All messages sent are now being encrypted. For the decryption we need to define a custom component. We create a new file inside the src folder and call it ExcryptedMessage.tsx.

We use a dependency injection mechanism to be able to access the message we want to display using the useMessageContext hook. Then we use a useEffect hook to call decryptMessage (from the SealdContext) to be run whenever the message.text variable changes. Normally, this should only run once, but if someone updates the message we make sure it gets re-run as well.

We use a state variable called displayedMessage to show and the CSS class messageBubble from the Stream Chat SDK to have the built-in styling applied.

Here's the code for the entire component:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function EncryptedMessage(): JSX.Element { const { message } = useMessageContext(); const { decryptMessage } = useSealdContext(); const [displayedMessage, setDisplayedMessage] = useState<string | undefined>( message.text ); useEffect(() => { if (message.text) { const sessionId = JSON.parse(message.text).sessionId; decryptMessage(message.text, sessionId).then( (decryptedMessage: string) => { setDisplayedMessage(decryptedMessage); } ); } }, [message.text, decryptMessage]); return ( <div className='messageBubble'> <p>{displayedMessage}</p> </div> ); }

The last step is to replace the default Message component of the SDK with our newly created custom one. For this, we go back to App.tsx and replace the default <Channel> object to have a custom Message component like this:

typescript
1
2
3
// previous code <Channel Message={EncryptedMessage}> // following code

That will display our EncryptedMessage component for all messages, therefore decrypting all messages per default. With this we finished the implementation.

Test the End-to-End Encrypted Messages

In the project's root directory, launch the Terminal in VS Code, enter this command, and click the link to the localhost to see the chat interface in the browser.

npm run dev

Since the chat has been decrypted in the previous section, sending a plain text message or adding an attachment (image, photo, files, and video) will remain human-readable.

Decrypted chat

Conclusion

Well done! 👍 You now know how to integrate the safety and privacy-preserving technology E2EE into web and mobile applications. We built a fully functioning React chat app with end-to-end encryption similar to WhatsApp Web. This article covered a basic working implementation of end-to-end encrypted messaging. Head to the chat and encryption SDKs to learn about advanced features and capabilities.

Want to learn fundamental E2EE concepts and how it works? Check out Encrypting an App End-to-End.

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