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 that database through SQLCipher instead, using a key you supply.

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

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 rather than give you a false guarantee. See Handling failures.

Best Practices

  • Return the same key on every launch. There is no rekey path, so a key that changes costs you the cache.
  • Read the key from the iOS Keychain or Android Keystore, never from a constant in your bundle.
  • To rotate, 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: the recommended recovery from an unreadable database is to 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:

{
  "op-sqlite": {
    "sqlcipher": true
  }
}
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 is not enough; the app must be rebuilt.

Supply the key

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

const getOfflineDbEncryptionKey = async () => {
  // Read from the iOS Keychain / Android Keystore.
  return await loadDatabaseKeyFromSecureStorage();
};

<Chat
  client={client}
  enableOfflineSupport
  getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
>
  {/* ... */}
</Chat>;

getOfflineDbEncryptionKey is called each time the database is opened, which happens once per launch and again after a sign-out. Returning undefined or throwing is treated as "the key is not available yet", not as "the key is wrong" — nothing is deleted.

The key must remain stable for the lifetime of the database file. SQLCipher encrypts at the page level and the SDK never calls PRAGMA rekey, so a database written with one key cannot be read with another.

Handling failures

When the database cannot be opened with the encryption you asked for, Chat throws a SqliteClientError from render. It does not fall back to an unencrypted or absent cache — continuing would mean running with a different security posture than the one you configured, and that is your decision to make, not the SDK's.

Wrap Chat in an error boundary, check the error is a SqliteClientError, and discriminate on its code:

codeMeaningExample recovery
OFFLINE_DB_UNREADABLEThe file exists but cannot be read — key changed, or corruptionSqliteClient.deleteDatabase(), then re-mount Chat
ENCRYPTION_KEY_UNAVAILABLEThe key getter threw or resolved without a keyThe easiest is to remount to retry, for example when the app next foregrounds
SQLCIPHER_BUILD_MISSINGThe native build has no SQLCipher, so the key would be ignoredNot recoverable at runtime - remount with enableOfflineSupport={false}

Deleting the database discards the cache, which is refetched from the server. The one real loss is actions that were queued while offline, so consider confirming with the user first or doing something non-destructive if this one time occurrence is important.

Provided below is one example on how you could handle these failure modes:

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

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

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

  static getDerivedStateFromError(error: unknown) {
    if (!(error instanceof SqliteClientError)) {
      // Not one of ours. Re-throw so it reaches whatever boundary you already
      // have - this boundary deliberately only owns offline-database failures.
      throw error;
    }
    // Returning state is what stops `render` from re-rendering the subtree that
    // just threw. Returning null here would throw again and unmount the app.
    return { code: error.code };
  }

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

    if (error.code === "OFFLINE_DB_UNREADABLE") {
      // There is a usable key, the file just is not readable with it. The file
      // is a cache, so drop it and let the retry below rebuild 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: no usable key, so
    // a fresh database would be plaintext. Run online-only instead - no local
    // cache means nothing lands on disk unencrypted.
    this.props.onGiveUp();
  }

  render() {
    // Must stop rendering the failing subtree, or it throws again on the retry
    // render and React unmounts the whole tree.
    return this.state.code ? null : this.props.children;
  }
}

Use it by keying the boundary on whatever your recovery changes, so that it re-mounts and clears its own error state:

const OfflineChat = ({ client, children }) => {
  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 in this recipe treats the three codes as two distinct situations.

OFFLINE_DB_UNREADABLE — throw the database away and start over. There is a usable key, the file just cannot be read with it. Since everything in the file is a cache, the recipe deletes it and rebuilds:

  1. Chat throws during render. getDerivedStateFromError stores the code and, critically, makes render return null — the failing subtree must stop rendering, or it throws again on the retry render and React unmounts your whole app.
  2. componentDidCatch calls SqliteClient.deleteDatabase(), which closes the handle and unlinks the file. It works on a database that cannot be decrypted, because deleting never reads it.
  3. It then bumps attempt, which changes the boundary's key. That re-mounts the boundary with fresh state, so it renders its children again.
  4. Chat mounts, finds no database, creates a new one with the current key, and refills it from the server.

The other two codes — run online-only. SQLCIPHER_BUILD_MISSING and ENCRYPTION_KEY_UNAVAILABLE both mean there is no usable key right now, so creating a fresh database would write it in plaintext. Deleting anything would be pointless and destructive. Instead the recipe flips enableOfflineSupport to false and carries on without a local cache — nothing is persisted, so nothing is persisted unencrypted. Chat still works; it just talks to the API directly.

That is the whole reason these are separate callbacks: one recovery keeps encryption and sacrifices the cache, the other keeps your data off the disk entirely.

As previously stated, the code above is just one way of handling the issues that should be consistent. The implementation details of your own integration depend purely on your business logic.

The key is load-bearing. The boundary stops rendering its children once it has caught, and nothing else clears that state — without a key that changes, it renders nothing forever, having already deleted the database.

In development you will still see a LogBox error overlay when the boundary catches. React reports every boundary-caught error through console.error, and LogBox renders those. It is a development-only reporter and does not exist in release builds - the boundary has handled the error.

Why the SDK does not recover on its own

Every recovery the SDK could perform on your behalf is a policy decision with a security consequence and none of them has a safe default:

  • Falling back to an unencrypted database would write the data you asked to protect in plaintext and nothing would tell you it had happened.
  • Silently disabling offline support would answer a compliance question - is any chat data on this disk?; on your behalf and quietly change how the app behaves.
  • Deleting the database would destroy data that is not the SDK's to destroy. The cache is disposable, but actions queued while offline are not and only you know whether losing them warrants asking the user first or perhaps deciding what you'd like to do in these scenarios.

The SDK also lacks the context to choose well. Whether ENCRYPTION_KEY_UNAVAILABLE means the Keystore is not unlocked yet, try again shortly or this device should be signed out depends entirely on how your app provisions keys. All the SDK observes is that getOfflineDbEncryptionKey produced nothing.

Failing loudly is itself the security property here, most of all for SQLCIPHER_BUILD_MISSING. op-sqlite accepts an encryption key on a non-SQLCipher build and then ignores it, so without this check there is no signal whatsoever — you would ship believing the database is encrypted and discover otherwise during an audit. A thrown error surfaces it the first time you run the app.

So the division of labour is deliberate> the SDK detects the failure, classifies it, reports it through its logger and refuses to open a database that would not honour the configuration you asked for. How we react to the errors being thrown can depend on an integration by integration basis.

Turning encryption on or off for existing installs

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

  • Enabling encryption on an install that already has a plaintext database.
  • Disabling it on an install that has an encrypted one.

In both cases the boundary above resolves it by deleting the database and letting it rebuild.

OFFLINE_DB_UNREADABLE is not exclusive to 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 is a property of the device, not of the signed-in user. SqliteClient.resetDB() - the recommended call on sign-out — clears the tables and reopens the same file with the same key, so switching users needs no special handling.

Verifying it worked

An encrypted database does not begin with the SQLite magic string. On a simulator you can check the file directly:

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 standard sqlite3 CLI fails with file is not a database.

Costs

  • Runtime: every page read and write is encrypted and authenticated, which adds overhead to offline database operations.
  • Binary size: enabling the build flag links OpenSSL into your app whether or not a key is supplied.