Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Encrypting the offline database

The offline database is a local SQLite cache of channels, messages, members, drafts and reminders. By default it is stored unencrypted. If your app has a compliance requirement for encryption at rest, the SDK can open the database through SQLCipher instead, with a key you supply.

Encryption is opt-in and needs two things: a native build of @op-engineering/op-sqlite that includes SQLCipher, and a getOfflineDbEncryptionKey prop on Chat.

Warning:

Without the native build flag, op-sqlite accepts an encryption key and then ignores it, writing the database in plaintext with no error. The SDK therefore refuses to open the database at all in that situation. See Handling failures.

Best practices

  • Return the same key on every launch. There is no rekey, so a key that changes costs you the cache.
  • Read the key from the iOS Keychain or the Android Keystore, never from a constant in your bundle.
  • To rotate keys, rotate a key-encryption key and keep the database key it protects unchanged (envelope encryption).
  • Wrap Chat in an error boundary. The SDK reports failures by throwing, and never deletes your data on its own.
  • Treat the cache as disposable. To recover from an unreadable database, delete it and let it rebuild from the server.

Enable the SQLCipher build

Add the following to your application's package.json, not to a library's, and rebuild the native app:

package.json
{
  "op-sqlite": {
    "sqlcipher": true
  }
}
Terminal
npx pod-install

This links OpenSSL into your binary and compiles op-sqlite against SQLCipher. It is a native build-time flag, so a JavaScript reload isn't enough: rebuild the app.

Supply the key

import type { PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat } from "stream-chat-react-native";

type EncryptedChatProps = PropsWithChildren<{
  client: StreamChat;
  // Reads the key from the iOS Keychain or the Android Keystore. Keep it stable,
  // for example by declaring it at module scope.
  getOfflineDbEncryptionKey: () => Promise<string | undefined>;
}>;

export const EncryptedChat = ({
  children,
  client,
  getOfflineDbEncryptionKey,
}: EncryptedChatProps) => (
  <Chat
    client={client}
    enableOfflineSupport
    getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
  >
    {children}
  </Chat>
);

getOfflineDbEncryptionKey is called each time the database is opened, which happens once per launch and again after a sign-out. When it returns undefined or throws, the SDK treats the key as not available yet, not as wrong, and deletes nothing.

Note:

The key must stay the same for the lifetime of the database file. SQLCipher encrypts each page, and the SDK never calls PRAGMA rekey, so a database written with one key can't be read with another.

Handling failures

When the database can't be opened with the encryption you asked for, Chat throws a SqliteClientError from render. It doesn't fall back to an unencrypted cache or to no cache, because that would run the app with a different security posture than the one you configured.

Wrap Chat in an error boundary, check that the error is a SqliteClientError, and handle it by its code:

code Meaning Example recovery
OFFLINE_DB_UNREADABLE The file exists but can't be read: the key changed, or the file is corrupted SqliteClient.deleteDatabase(), then mount Chat again
ENCRYPTION_KEY_UNAVAILABLE The key getter threw or resolved without a key Mount Chat again later, for example when the app next comes to the foreground
SQLCIPHER_BUILD_MISSING The native build has no SQLCipher, so the key would be ignored Not recoverable at runtime. Mount Chat with enableOfflineSupport={false}

Deleting the database discards the cache, which is fetched again from the server. What's lost for good are the actions queued while offline, so consider asking the user first if they matter to your app.

Here is one way to handle these failures:

import React from "react";
import {
  SqliteClient,
  SqliteClientError,
  type SqliteClientErrorCode,
} from "stream-chat-react-native";

type BoundaryProps = React.PropsWithChildren<{
  onGiveUp: () => void;
  onRetry: () => void;
}>;

export class OfflineDbBoundary extends React.Component<
  BoundaryProps,
  { code?: SqliteClientErrorCode }
> {
  state: { code?: SqliteClientErrorCode } = {};

  static getDerivedStateFromError(error: unknown) {
    if (!(error instanceof SqliteClientError)) {
      // Not an offline database failure. Rethrow it so it reaches the app's own boundary.
      throw error;
    }
    // Returning state stops `render` from rendering the subtree that just threw.
    return { code: error.code };
  }

  componentDidCatch(error: unknown) {
    if (!(error instanceof SqliteClientError)) {
      return;
    }

    if (error.code === "OFFLINE_DB_UNREADABLE") {
      // The key is usable, but the file can't be read with it. The file is a cache, so
      // delete it and let the retry rebuild it from the server.
      try {
        SqliteClient.deleteDatabase();
      } catch (deleteError) {
        console.warn("Could not delete the offline database", deleteError);
      }
      this.props.onRetry();
      return;
    }

    // SQLCIPHER_BUILD_MISSING or ENCRYPTION_KEY_UNAVAILABLE: there is no usable key, so a
    // new database would be plaintext. Run online only, so nothing lands on disk unencrypted.
    this.props.onGiveUp();
  }

  render() {
    // Stop rendering the failing subtree, or it throws again on the next render.
    return this.state.code ? null : this.props.children;
  }
}

Key the boundary on what your recovery changes, so that it mounts again and clears its own error state:

import { useState, type PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat } from "stream-chat-react-native";
import { OfflineDbBoundary } from "./OfflineDbBoundary";

type OfflineChatProps = PropsWithChildren<{
  client: StreamChat;
  getOfflineDbEncryptionKey: () => Promise<string | undefined>;
}>;

export const OfflineChat = ({
  children,
  client,
  getOfflineDbEncryptionKey,
}: OfflineChatProps) => {
  const [attempt, setAttempt] = useState(0);
  const [offlineSupport, setOfflineSupport] = useState(true);

  return (
    <OfflineDbBoundary
      key={`${attempt}-${offlineSupport}`}
      onGiveUp={() => setOfflineSupport(false)}
      onRetry={() => setAttempt((value) => value + 1)}
    >
      <Chat
        client={client}
        enableOfflineSupport={offlineSupport}
        getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
      >
        {children}
      </Chat>
    </OfflineDbBoundary>
  );
};

The boundary treats the three codes as two situations.

With OFFLINE_DB_UNREADABLE, there is a usable key, and the file just can't be read with it. Everything in the file is a cache, so the boundary deletes it and rebuilds:

  1. Chat throws during render. getDerivedStateFromError stores the code and makes render return null, so the failing subtree stops rendering. Otherwise it would throw again on the next render and React would unmount the whole app.
  2. componentDidCatch calls SqliteClient.deleteDatabase(), which closes the handle and deletes the file. It works on a database that can't be decrypted, because deleting never reads it.
  3. It then increments attempt, which changes the boundary's key. The boundary mounts again with fresh state, and renders its children again.
  4. Chat mounts, finds no database, creates a new one with the current key, and fills it from the server.

SQLCIPHER_BUILD_MISSING and ENCRYPTION_KEY_UNAVAILABLE both mean there is no usable key right now, so a new database would be written in plaintext, and deleting anything would only lose data. The boundary sets enableOfflineSupport to false instead, and the app carries on without a local cache. Nothing is stored, so nothing is stored unencrypted. Chat still works, with every request going to the API.

The boundary stops rendering its children once it has caught an error, and only a new key clears that. Without a key that changes, it renders nothing from then on.

Note:

In development, LogBox still shows an error when the boundary catches. React reports every caught error through console.error, and LogBox shows those. Release builds don't have LogBox.

Why the SDK doesn't recover on its own

Each recovery the SDK could make for you is a policy decision with a security consequence:

  • Falling back to an unencrypted database would write the data you asked to protect in plaintext, and nothing would tell you.
  • Turning offline support off would change how the app behaves, and decide for you whether chat data may be on the disk.
  • Deleting the database would destroy the actions queued while offline, which only your app can decide to give up.

The SDK also can't tell what a failure means for your app. Whether ENCRYPTION_KEY_UNAVAILABLE means that the Keystore isn't unlocked yet or that the device should be signed out depends on how your app provisions keys. The SDK only sees that getOfflineDbEncryptionKey returned no key.

So the SDK detects the failure, classifies it, logs it, and refuses to open a database that wouldn't honor your configuration. Your app decides what to do next. This matters most for SQLCIPHER_BUILD_MISSING: op-sqlite accepts a key on a build without SQLCipher and ignores it, so without the error, nothing would show that the database isn't encrypted.

Turning encryption on or off for existing installs

Switching in either direction leaves a database from the other mode on disk, which raises OFFLINE_DB_UNREADABLE once:

  • turning encryption on, on an install that has a plaintext database;
  • turning it off, on an install that has an encrypted one.

In both cases, the boundary above deletes the database and lets it rebuild.

Note:

OFFLINE_DB_UNREADABLE isn't only about encryption. A corrupted database raises it too, so an error boundary is worth having whenever enableOfflineSupport is on.

Signing out and switching users

The encryption key belongs to the device, not to the signed-in user. SqliteClient.resetDB(), the call to make on sign-out, clears the tables and opens the same file again with the same key, so switching users needs nothing more. See Reset the database on sign-out.

Checking that it worked

An encrypted database doesn't begin with the SQLite header. On an iOS simulator, you can check the file:

Terminal
xcrun simctl get_app_container <device-udid> <your.bundle.id> data
# then, in Library/databases/
head -c 16 stream-chat-react-native | xxd

A plaintext database starts with SQLite format 3. An encrypted one starts with random bytes, and opening it with the sqlite3 command-line tool fails with file is not a database.

Costs

  • Every page the database reads and writes is encrypted and authenticated, which slows down offline database operations.
  • The build flag links OpenSSL into your app, whether or not you supply a key, which increases its size.