# 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](https://www.zetetic.net/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`.

<Admonition type="caution">

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](#handling-failures).

</Admonition>

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

```json
{
  "op-sqlite": {
    "sqlcipher": true
  }
}
```

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

```tsx
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.

<Admonition type="note">

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.

</Admonition>

## 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`:

| `code`                       | Meaning                                                         | Example recovery                                                              |
| ---------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `OFFLINE_DB_UNREADABLE`      | The file exists but cannot be read — key changed, or corruption | `SqliteClient.deleteDatabase()`, then re-mount `Chat`                         |
| `ENCRYPTION_KEY_UNAVAILABLE` | The key getter threw or resolved without a key                  | The easiest is to remount to retry, for example when the app next foregrounds |
| `SQLCIPHER_BUILD_MISSING`    | The native build has no SQLCipher, so the key would be ignored  | Not 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:

```tsx
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:

```tsx
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.

<Admonition type="caution">

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.

</Admonition>

<Admonition type="note">

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.

</Admonition>

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

<Admonition type="note">

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

</Admonition>

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

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


---

This page was last updated at 2026-08-21T10:37:14.269Z.

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v8/basics/encrypting-the-offline-database/](https://getstream.io/chat/docs/sdk/react-native/v8/basics/encrypting-the-offline-database/).