Encrypted channels
A channel whose name starts with private-encrypted- is end-to-end encrypted. Your server seals each payload before publishing it, and approved browser clients decrypt it with a key from your authorization endpoint. The Realtime edge and network intermediaries see only ciphertext.
Generate and store a 32-byte master key. The master key never appears in a Realtime API request, and the channel-name prefix enables the feature. Bird cannot recover a lost key, and payloads sealed with that key remain unreadable after you replace it.
Encrypted channels use the same endpoint and signature as private channels. The authorization response also includes the channel's derived decryption key as shared_secret. Rejecting a subscription prevents that client from receiving the key.
Generate a master key
Generate 32 random bytes, encode them as base64, and store the value like the app secret:
Codebeispiel
openssl rand -base64 32Give it to your server SDK as part of the realtime configuration, next to the app key and secret.
Publish an encrypted event
The server SDK detects the channel prefix, derives its key from the master key, and seals the JSON payload locally. The publish request contains the sealed envelope.
import { BirdClient } from "@messagebird/sdk";
const bird = new BirdClient({
apiKey: process.env.BIRD_API_KEY,
realtime: {
key: process.env.BIRD_REALTIME_KEY,
secret: process.env.BIRD_REALTIME_SECRET,
encryptionMasterKey: process.env.BIRD_REALTIME_MASTER_KEY,
},
});
await bird.realtime.publish("rap_01krdgeqcxet5s7t44vh8rt9mg", {
event: "order.updated",
channels: ["private-encrypted-orders"],
data: { order_id: "ord_123", status: "shipped" },
});from bird import Bird
client = Bird(
realtime_key=os.environ["BIRD_REALTIME_KEY"],
realtime_secret=os.environ["BIRD_REALTIME_SECRET"],
realtime_encryption_master_key=os.environ["BIRD_REALTIME_MASTER_KEY"],
)
client.realtime.publish(
"rap_01krdgeqcxet5s7t44vh8rt9mg",
event="order.updated",
channels=["private-encrypted-orders"],
data={"order_id": "ord_123", "status": "shipped"},
)client, err := bird.NewClient(
option.WithAPIKey(os.Getenv("BIRD_API_KEY")),
option.WithRealtimeCredentials(os.Getenv("BIRD_REALTIME_KEY"), os.Getenv("BIRD_REALTIME_SECRET")),
option.WithRealtimeEncryptionMasterKey(os.Getenv("BIRD_REALTIME_MASTER_KEY")),
)
if err != nil {
log.Fatal(err)
}
_, err = client.Realtime.Publish(context.Background(), "rap_01krdgeqcxet5s7t44vh8rt9mg", bird.RealtimePublishParams{
Event: "order.updated",
Channels: []string{"private-encrypted-orders"},
Data: map[string]any{"order_id": "ord_123", "status": "shipped"},
})$bird = new Bird(getenv('BIRD_API_KEY'), realtime: new RealtimeOptions(
key: getenv('BIRD_REALTIME_KEY'),
secret: getenv('BIRD_REALTIME_SECRET'),
encryptionMasterKey: getenv('BIRD_REALTIME_MASTER_KEY'),
));
$bird->realtime->publish('rap_01krdgeqcxet5s7t44vh8rt9mg', (new RealtimePublish())
->setEvent('order.updated')
->setChannels(['private-encrypted-orders'])
->setData(['order_id' => 'ord_123', 'status' => 'shipped']));An encrypted channel must be the only channel in a single publish. Each encrypted channel derives a different key, so other channels could not decrypt the same sealed payload. The SDKs reject this fan-out locally, and the API returns E23000 if it receives one. To publish to several encrypted channels, use a batch with one channel per event.
Return the shared secret from your auth endpoint
Your auth endpoint approves encrypted subscriptions the way it approves private ones. Use the SDK's authorizeChannel helper and the response gains the shared_secret automatically whenever the channel name carries the encrypted prefix:
app.post("/bird/auth", async (req, res) => {
const { connection_id, channel_name } = req.body;
const user = getUserFromSession(req);
if (!user || !mayJoin(user, channel_name)) return res.sendStatus(403);
res.json(
await bird.realtime.authorizeChannel({
connectionId: connection_id,
channelName: channel_name,
}),
);
});@app.post("/bird/auth")
def bird_auth():
body = request.get_json()
user = get_user_from_session()
if user is None or not may_join(user, body["channel_name"]):
abort(403)
return client.realtime.authorize_channel(
connection_id=body["connection_id"],
channel_name=body["channel_name"],
)func birdAuth(w http.ResponseWriter, r *http.Request) {
var body struct {
ConnectionID string `json:"connection_id"`
ChannelName string `json:"channel_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
user, ok := userFromSession(r)
if !ok || !mayJoin(user, body.ChannelName) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
auth, err := client.Realtime.AuthorizeChannel(bird.RealtimeChannelAuthorizationParams{
ConnectionID: body.ConnectionID,
ChannelName: body.ChannelName,
})
if err != nil {
http.Error(w, "authorization failed", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(auth)
}function birdAuth(string $connectionId, string $channelName, User $user): array
{
if (!mayJoin($user, $channelName)) {
http_response_code(403);
exit;
}
return $bird->realtime->authorizeChannel($connectionId, $channelName);
}The SDK derives a separate shared_secret for each channel. Authorization for private-encrypted-orders therefore does not decrypt private-encrypted-invoices. The secret travels in your authorization response and is not included in the subscription frame sent to the edge.
Subscribe and decrypt in the browser
The cipher uses the separate @messagebird/realtime/encrypted entry point. Import it and pass it as the client's encryption option:
Codebeispiel
import { BirdRealtime } from "@messagebird/realtime";
import { encryption } from "@messagebird/realtime/encrypted";
const bird = new BirdRealtime({
appKey: "your-app-key",
region: "us1",
authEndpoint: "/bird/auth",
encryption,
});
const orders = bird.subscribe("private-encrypted-orders");
orders.bind("order.updated", (data) => {
console.log(data); // decrypted: { order_id: "ord_123", status: "shipped" }
});Bindings receive plaintext. Subscribing without the encryption option throws immediately, and an authorization response without shared_secret fails the subscription.
Only the browser client currently receives encrypted channels. The Swift and Kotlin clients reject private-encrypted- subscriptions because they do not implement decryption.
Rotate the master key
Deploy the new key to every publisher and authorization endpoint together. During rotation:
- New publishes seal under the new key.
- A subscribed browser client that cannot decrypt an event re-authorizes once and retrieves the new shared_secret.
- Instances using different master keys can briefly publish events that some clients cannot decrypt, so coordinate the rollout across instances.
Rotate a leaked or lost key. Rotation protects future payloads but cannot re-seal earlier events or revoke copies of the old key.
What encrypted channels do not do
- Official clients do not support client events. Browser trigger() throws on encrypted channels because the client does not seal client-to-client payloads. Do not send plaintext client events from a custom client.
- Presence and encryption cannot be combined. The presence-encrypted- prefix is unsupported. Cache and encryption work together: private-encrypted-cache- channels store the cached event sealed, though after a key rotation the cached copy stays sealed under the old key until the next publish replaces it.
- Channel names and event names are not encrypted. Only the payload is. Pick channel names that do not leak what you are protecting.
- The Realtime edge cannot inspect payloads. Channel and event names remain visible, while the payload stays encrypted.
Next steps
- Authorizing channels is the signature mechanism this guide builds on.
- Publishing events covers the publish and batch APIs themselves.
- Cache channels explains the last-event replay that private-encrypted-cache- combines with.