Set Up Your Server

Your server hands out channel keys to your app and changes a channel's key when someone leaves or is banned. It doesn't store any keys: it derives each one when it's needed from three inputs:

  • a master secret that only your server knows
  • the channel's id
  • a version number

The same inputs always give the same key. A key id like 2.5 means "master secret version 2, channel key version 5".

The examples use Node.js with Express 5 and the stream-chat package, version 9.50.2 or later. They use import and top-level await, so set "type": "module" in your server's package.json.

Store the master secret

Each master secret is 32 random bytes. Store them in a secret manager, such as AWS Secrets Manager or Google Secret Manager, never in app config or source code.

To replace the master secret, add a new version and use it for new keys. Keep the old versions, so older messages can still be decrypted. Apps use the new version for a channel the next time its key version changes, or after they restart.

Your server stores each channel's key version, a number that starts at 1, in its own database. It also copies the number to the channel's custom data as key_version, so apps notice when it changes. Neither your server nor the app trusts that copy: channel owners and moderators can change a channel's custom data from their apps, and a channel update that leaves out key_version removes it. The key endpoint always returns the version from your database, and the app encrypts with that.

Add a key endpoint

Protect the endpoint with your app's own login, for example the session your server already checks before it creates a Stream token. Then let Stream's member list decide who gets a key: the endpoint only answers for active members of the channel. A member who is banned from the channel or the whole app, or who was invited and hasn't accepted, gets no keys. Shadow-banned members still get keys: a shadow ban is meant to go unnoticed, and Stream already hides their messages from everyone else. If Stream can't be reached, the endpoint answers with an error rather than a 403, so outages don't look like refusals. Removing someone from a channel then stops both their messages and their keys. Keys they fetched before stay in their app's memory, so your server also changes the key when they leave or are banned.

Without a kid parameter, the endpoint returns every key version of the current master secret, so the app needs one request per channel. With kid, it returns that one key, which the app uses for messages encrypted with an older master secret.

Replace requireAppUser with your own login check. The example assumes your app's user ids are the same as their Stream user ids.

import crypto from "node:crypto";
import express from "express";
import { StreamChat } from "stream-chat";

const serverClient = StreamChat.getInstance(
  process.env.STREAM_API_KEY,
  process.env.STREAM_API_SECRET,
);

// Master secrets by version, loaded from your secret manager. Each is 32 random bytes.
const masterSecrets = await loadMasterSecrets(); // for example { 1: Buffer, 2: Buffer }
const currentMaster = Math.max(...Object.keys(masterSecrets).map(Number));

function deriveChannelKey(cid, master, version) {
  const info = Buffer.from(`chat-channel-key:m${master}:v${version}`);
  const key = crypto.hkdfSync(
    "sha256",
    masterSecrets[master],
    Buffer.from(cid),
    info,
    32,
  );
  return Buffer.from(key).toString("base64");
}

// Each channel's key version, in your own database. For example, a table with the columns
// cid and version: get(cid) returns 1 for a channel it doesn't have yet, and increment(cid)
// adds 1 in one atomic update and returns the new version.
const keyVersions = await connectKeyVersionStore();

// Whether the user is an active member of the channel: not banned from the channel or the
// app, and not only invited. Shadow bans are left out on purpose, so they stay unnoticed.
async function isActiveMember(cid, userId) {
  const [type, id] = cid.split(":");
  let members;
  try {
    ({ members } = await serverClient
      .channel(type, id)
      .queryMembers({ id: userId }));
  } catch (error) {
    if (error.status === 404) return false; // the channel doesn't exist
    throw error; // for example, Stream is unreachable: answer with an error, not a 403
  }
  const member = members[0];
  if (!member || member.banned) return false;
  if (member.user?.banned) return false;
  return !member.invited || Boolean(member.invite_accepted_at);
}

const app = express();

// GET /channel-keys?cid=messaging:general          -> every key version of the current master secret
// GET /channel-keys?cid=messaging:general&kid=1.3  -> one key, for example from an older master secret
app.get("/channel-keys", requireAppUser, async (req, res) => {
  const cid = String(req.query.cid);
  if (!(await isActiveMember(cid, req.user.id))) return res.status(403).end(); // no keys

  const latest = await keyVersions.get(cid);
  const current = `${currentMaster}.${latest}`;

  if (req.query.kid) {
    const kid = String(req.query.kid);
    const [master, version] = kid.split(".").map(Number);
    if (
      !masterSecrets[master] ||
      !Number.isInteger(version) ||
      version < 1 ||
      version > latest
    ) {
      return res.status(400).end();
    }
    return res.json({
      current,
      keys: { [kid]: deriveChannelKey(cid, master, version) },
    });
  }

  const keys = {};
  for (let version = 1; version <= latest; version++) {
    keys[`${currentMaster}.${version}`] = deriveChannelKey(
      cid,
      currentMaster,
      version,
    );
  }
  res.json({ current, keys });
});

app.listen(3000);

The response always has the same shape:

{
  "current": "2.5",
  "keys": {
    "2.1": "base64...",
    "2.5": "base64..."
  }
}

Check it works: call the endpoint as a channel member and you get back current and keys. Call it as someone who isn't a member, or who is banned from the channel, and you get a 403.

Note:

A channel list shows many channels at once, and the app requests each channel's keys separately. If your app shows long channel lists, consider an endpoint that accepts several cid values.

Change the key when someone leaves

When someone is removed from or banned in a channel, increase the channel's key version. New messages then use a key that person can't get, even if their app still has the keys it fetched before. You can do this where your server removes or bans members, or when Stream sends you a member.removed or user.banned webhook event, as shown below. A shadow ban doesn't change the key, so it stays unnoticed. You can also change keys at any other time, for example every month.

// Stream retries a webhook with the same X-Webhook-Id. Keep the ids you've handled, so one
// removal raises the key version once. Store them in your database in production.
const handledWebhooks = new Set();

app.post(
  "/stream-webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    // Checks the signature, and unzips the body if your app compresses webhooks.
    let event;
    try {
      event = serverClient.verifyAndParseWebhook(
        req.body,
        req.get("x-signature"),
      );
    } catch {
      return res.status(401).end();
    }

    const webhookId = req.get("x-webhook-id");
    if (webhookId && handledWebhooks.has(webhookId))
      return res.status(200).end();

    // A ban from one channel has its cid. A ban from the whole app has none: see below.
    // A shadow ban doesn't change the key, so the shadow-banned user doesn't notice it.
    const removed =
      event.type === "member.removed" ||
      (event.type === "user.banned" && !event.shadow);
    if (removed && event.cid) {
      try {
        const next = await keyVersions.increment(event.cid);
        // Tells apps watching the channel to fetch the new key.
        const [type, id] = event.cid.split(":");
        await serverClient
          .channel(type, id)
          .updatePartial({ set: { key_version: next } });
      } catch (error) {
        // Stream retries for 15 seconds at most. Keep failed changes, for example in a queue,
        // and retry them yourself, or the removed member keeps the current key.
        console.error(`Couldn't change the key of ${event.cid}`, error);
        return res.status(500).end();
      }
    }

    if (webhookId) handledWebhooks.add(webhookId);
    res.status(200).end();
  },
);

Updating the channel sends a channel.updated event, and the Flutter SDK updates the channel's custom data, so apps fetch the new key. The event only reaches apps that are watching the channel, which an app always is while it shows the channel.

Increase the version with one atomic update in your database, such as UPDATE ... SET version = version + 1 RETURNING version. Two removals at the same moment then give two new versions, instead of both writing the same one. If the second step fails after the first succeeded, the retry increases the version again. That only skips a version, which is harmless.

A ban from the whole app isn't tied to a channel, so the webhook above skips it. When you ban someone from the app, also change the key of each encrypted channel they're a member of.

You decide whether new members can read messages sent before they joined. In this example they can, because the endpoint hands out every key version up to the current one. To prevent it, store when each member joined, and only return key versions created after that.

Check it works: remove a member from a test channel. The channel's version in your database and its key_version in Stream have both gone up by one. Ban another member from the channel, and they go up again.

Create a channel type for encrypted channels

This guide encrypts message text only. Files, polls and shared locations would be stored unencrypted, and features that read message text only ever see the placeholder. Turn them off for encrypted channels.

A separate channel type is the easiest way: the rest of your app keeps these features, and your app can tell encrypted channels apart by their type. Run this once on your server. Don't rely on the defaults of a new channel type: the example sets every feature it depends on, including max_message_length, automod and automod_behavior. Compare it with await serverClient.getChannelType('messaging') and add any other features your app uses:

// Sets every feature explicitly, so the type doesn't depend on defaults.
await serverClient.createChannelType({
  name: "encrypted",
  max_message_length: 5000,
  automod: "disabled", // automod would only ever see the placeholder text
  automod_behavior: "flag",
  typing_events: true,
  read_events: true,
  delivery_events: true,
  connect_events: true,
  custom_events: true,
  push_notifications: true,
  reactions: true,
  replies: true,
  quotes: true,
  mutes: true,
  uploads: false, // files would be uploaded unencrypted
  polls: false, // poll questions and options would be stored as readable text
  shared_locations: false, // locations would be stored as readable text
  search: false, // search would only ever find the placeholder text
  url_enrichment: false, // link previews would only ever see the placeholder text
  commands: [], // commands such as /giphy would run on the placeholder text
});

Then create encrypted channels with this type, for example client.channel('encrypted', 'team-chat') in your app. If every channel in your app is encrypted, apply the same settings to your existing channel type with updateChannelType instead.

With search: false, Stream stops indexing new messages in the channel type, and a search that only covers encrypted channels returns an error saying there are no searchable channels.

uploads: false stops Stream from storing files, but your app can still try to send them first. Encrypt messages turns off attachments and voice recordings in the composer, so files never leave the phone.

Check it works: run await serverClient.getChannelType('encrypted') and confirm these features are off, and that replies, quotes and reactions are still on.

Change your push notification template

A push notification only contains the placeholder text that Stream stores. Change your push notification template to generic text, such as New message from {{ sender.name }}. To show the real message, your app has to decrypt it on the device when the notification arrives.

Don't turn on automatic translation for encrypted channels. It can only translate the placeholder.

Next step

Encrypt messages in your Flutter app with the keys from this endpoint.