OMEMO Encryption

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

TLS protects an XMPP connection between a client and its server, and between servers, but the server sees every message. For conversations where the operator of the server must not be able to read the content, XMPP has OMEMO, an end-to-end encryption protocol built on the Signal protocol’s Double Ratchet. This page explains what OMEMO protects and what it does not, how its keys travel over the publish-subscribe nodes of the user’s account, how the encrypted message looks on the wire, why history, search and the browser make it harder than it looks, and how to inspect what an account publishes. The general end-to-end design for the Chatterbox application, including the custom WebSocket protocol, is on End-to-End Encryption; this page is the XMPP specific part. The client that implements OMEMO in a page is Converse.js (Converse.js).

Versions and statuses were checked on 2026-10-24: XEP-0384 OMEMO Encryption is at version 0.9.1 (2026-04-06) and its status is Experimental, even though OMEMO is deployed widely; Converse.js 14.0.1 is the client used in the examples; the server is ejabberd 26.09 from XMPP Servers.

Two generations, two namespaces

The protocol went through incompatible revisions, and both generations are in use:

Legacy OMEMO Current OMEMO (XEP-0384 0.8.0 and later)

Namespace

eu.siacs.conversations.axolotl

urn:xmpp:omemo:2

Device list node

eu.siacs.conversations.axolotl.devicelist

urn:xmpp:omemo:2:devices

Bundles

One node per device: eu.siacs.conversations.axolotl.bundles:<deviceId>

One node, urn:xmpp:omemo:2:bundles, one item per device, the item id is the device id

Payload

AES-128-GCM with the key in the header

An encrypted XEP-0420 Stanza Content Encryption envelope with AES-256-CBC and HMAC-SHA-256

Specification

XEP-0384 versions 0.2 and 0.3 (2017 and 2018) adopted the namespace already used in the wild

XEP-0384 0.8.0 (2021-09-28) and later; the intermediate 0.4 to 0.7 versions used urn:xmpp:omemo:1

A client that wants to reach everyone supports both, publishes both device lists, and sends with the generation that every recipient device supports. The legacy namespace stays in use because many deployed clients only speak it, and new deployments should treat the urn:xmpp:omemo:2 generation as the target. After logging in with Converse.js 14.0.1 and omemo_default: true, an account held both eu.siacs.conversations.axolotl.devicelist and urn:xmpp:omemo:2:devices in its PEP nodes, and the current bundles existed under urn:xmpp:omemo:2:bundles.

How it works

The protocol keeps a separate long-lived Double Ratchet session between every pair of devices (not accounts): the sender’s browser tab, the sender’s phone, each of the recipient’s devices. In each message:

  1. The sender encrypts the message once, with a fresh random key.

  2. The key is then encrypted separately for every recipient device (and for the sender’s own other devices) through the session with that device, and these small encrypted keys go into the message header.

  3. The payload is sent once. Each recipient device finds its header entry, decrypts the key and then the payload.

The cryptography is Signal’s: a X3DH key agreement establishes the first shared secret from the recipient’s published bundle (an identity key, a signed PreKey with its signature, and a pool of one-time PreKeys), using Curve25519 and SHA-256, then the Double Ratchet derives a new key for every message, which gives forward secrecy and break-in recovery. The specification states what OMEMO promises (confidentiality, forward secrecy that has been shown to be weak until both parties complete the key exchange, authentication when keys are verified, and asynchronicity: the recipient can be offline) and what it does not: an attacker with permanent access to a device, a lost device whose notifications can be read, denial of service, and metadata and traffic analysis. The server still sees who talks to whom, when, and how much.

Device lists

Every device announces itself by adding its random device id to the account’s device list, a PEP node whose access model must be open, because a contact who has no presence subscription yet (the first messages of a conversation, or another participant of a group chat) still needs to read it:

<iq type="get" to="alice@chat.example.test" id="dl1">
  <pubsub xmlns="http://jabber.org/protocol/pubsub">
    <items node="urn:xmpp:omemo:2:devices"/>
  </pubsub>
</iq>

<iq type="result" from="alice@chat.example.test" id="dl1">
  <pubsub xmlns="http://jabber.org/protocol/pubsub">
    <items node="urn:xmpp:omemo:2:devices">
      <item id="current">
        <devices xmlns="urn:xmpp:omemo:2">
          <device id="16048"/>
          <device id="3912"/>
        </devices>
      </item>
    </items>
  </pubsub>
</iq>

A device may add a label attribute for its name, which must be accompanied by a signature (labelsig) so that nobody else can plant or replace labels. Clients should drop devices that have not been seen for a long time, because every listed device receives a copy of the key for every message sent, and a stale device both wastes space and is a security liability.

Bundles

The bundle is what lets another device start a session without the owner being online. It is one item in a node that must store multiple items (not the default of PEP, so the publisher sets pubsub#max_items to max through publish options):

<iq type="set" id="bundle1">
  <pubsub xmlns="http://jabber.org/protocol/pubsub">
    <publish node="urn:xmpp:omemo:2:bundles">
      <item id="16048">
        <bundle xmlns="urn:xmpp:omemo:2">
          <ik>base64 public identity key</ik>
          <spk id="1">base64 public signed PreKey</spk>
          <spks>base64 signature of the signed PreKey</spks>
          <prekeys>
            <pk id="1">base64 public PreKey</pk>
            <pk id="2">base64 public PreKey</pk>
          </prekeys>
        </bundle>
      </item>
    </publish>
  </pubsub>
</iq>

The specification asks for around 100 PreKeys in a bundle and never fewer than 25, and for the signed PreKey to be rotated about once a week to once a month, keeping the old private key for another period for late messages. Each PreKey is meant for one session start, so a client must refill the pool as they are consumed. The server must support persistent items, publish options, max for the persisted items and the open access model; ejabberd’s mod_pubsub with the pep plugin does.

This script reads an account’s device list and bundles, and reports what it finds. It is useful to diagnose why a contact cannot be encrypted to (no devices, no bundle, a bundle with no PreKeys):

import { client, xml } from "@xmpp/client";

const PUBSUB = "http://jabber.org/protocol/pubsub";
const OMEMO = "urn:xmpp:omemo:2";

async function items(xmpp, jid, node, id) {
  const itemsEl = xml("items", { node }, ...(id ? [xml("item", { id })] : []));
  const res = await xmpp.iqCaller.request(xml("iq", { type: "get", to: jid }, xml("pubsub", { xmlns: PUBSUB }, itemsEl)), 10000);
  return res.getChild("pubsub", PUBSUB).getChild("items").getChildren("item");
}

export async function inspectOmemo(xmpp, bareJid) {
  const list = await items(xmpp, bareJid, `${OMEMO}:devices`);
  const devices = list[0].getChild("devices", OMEMO).getChildren("device").map((d) => d.attrs.id);
  const report = [];
  for (const id of devices) {
    const found = await items(xmpp, bareJid, `${OMEMO}:bundles`, id);
    const bundle = found[0]?.getChild("bundle", OMEMO);
    if (!bundle) { report.push({ device: id, bundle: "missing" }); continue; }   // stale device: skip it when encrypting
    report.push({
      device: id,
      signedPreKeyId: bundle.getChild("spk").attrs.id,
      hasSignature: Boolean(bundle.getChildText("spks")),
      hasIdentityKey: Boolean(bundle.getChildText("ik")),
      preKeys: bundle.getChild("prekeys").getChildren("pk").length,
    });
  }
  return report;
}

Source: XEP-0384: OMEMO Encryption (sections "Device list" and "Bundles") and XEP-0060: Publish-Subscribe. Run against an account that had logged in with Converse.js 14.0.1 and OMEMO enabled, it listed devices whose bundles each held a signed PreKey, its signature, an identity key and 100 PreKeys. It also listed older device ids that had no bundle under the new node, which is the typical state after a client is reinstalled or its storage is cleared: the device list keeps a device that cannot be encrypted to. Converse 14.0.1 published signed PreKey id 0, whereas the specification gives the range 1 to 2^31-1; a tolerant reader accepts both.

The encrypted message

An OMEMO message carries an encrypted element and almost nothing readable. The header names the sender’s device (sid), and for each recipient account lists, per device (rid), the encrypted key; kex="true" marks a key that is part of a key exchange because no session existed yet:

<message to="staff@conference.chat.example.test" type="groupchat" id="e1">
  <encrypted xmlns="urn:xmpp:omemo:2">
    <header sid="27183">
      <keys jid="alice@chat.example.test"><key rid="16048">base64</key></keys>
      <keys jid="bob@chat.example.test"><key rid="123" kex="true">base64</key></keys>
    </header>
    <payload>base64 of the encrypted envelope</payload>
  </encrypted>
  <store xmlns="urn:xmpp:hints"/>
</message>

The payload is an XEP-0420 envelope that contains the real content (the body and any extension that must not be visible to the server, such as a reply or a correction reference), padded with a random rpad element so that the length of the ciphertext does not reveal the length of the text. Anything outside the envelope, such as the store hint or the addressing, is visible to the server. Clients also add a fallback body and an encryption indicator (XEP-0380) so that a client without OMEMO shows a notice instead of an empty message; that fallback must say nothing about the content.

Key consequences:

  • No searching on the server. The archive stores ciphertext. A user can search only the messages their own device has decrypted and stored locally.

  • One message, many keys. The header grows with the number of devices of all recipients. Large rooms with many devices produce large stanzas, which interacts with the server’s max_stanza_size (262144 in the ejabberd configuration of this group).

  • Everything outside the envelope is plaintext to the server. Do not put sensitive extension data in the message outside the envelope.

Trust management

An encryption key you did not verify protects only against a passive eavesdropper. The specification therefore says that a client must not send to a newly seen device until the user has made a trust decision, because an attacker who can publish a fake device to the victim’s device list would otherwise get a copy of every message. Common policies:

  • Manual verification. The user compares fingerprints (a hex string or QR code of the identity key), ideally out of band. The specification recommends splitting the fingerprint into groups of eight lowercase hex characters and colouring each group with XEP-0392 so that it is easy to compare.

  • Trust on first use (TOFU): the first key seen for a device is trusted, later changes raise a warning.

  • Blind trust before verification (BTBV): trust new devices automatically until the user verifies one contact, then require verification for that contact. It reduces friction while keeping a path to strong security.

  • Automatic Trust Management (XEP-0450, Experimental) shares trust decisions between a user’s own devices through Trust Messages (XEP-0434).

Decryption errors must not trigger an automatic new session without user interaction, and a message received twice must be ignored silently (the second decryption always fails). The user interface of Chatterbox should show whether a room or chat is encrypted, show each unverified device, and make it impossible to send plaintext accidentally after an opt-out from a contact.

Group chats

OMEMO works in multi-user chat rooms with these conditions: the room must be non-anonymous (so the real JIDs of the members are known), and should be members-only. A participant fetches the member, admin and owner lists, takes the real JIDs, fetches the device list and the bundle of each member (including those offline), and encrypts to every device, including its own other devices. The client must watch affiliation changes and re-key when members are added or removed: a removed member must not receive later messages. Fan-out cost grows with members times devices, which is why encrypted rooms are practical for tens of people, not thousands. A semi-anonymous public room cannot use OMEMO.

The browser

Running OMEMO in a page has constraints that a native application does not have:

  • Where the keys live. The identity key, the signed PreKeys, the PreKeys and every ratchet state must be stored on the device, and in a page the realistic stores are IndexedDB or localStorage (Converse.js defaults to IndexedDB). They are bound to the origin, readable by any script running on that origin, and not backed by hardware. A cross-site scripting flaw therefore exposes every key, which makes a strict Content Security Policy and output escaping a precondition.

  • Storage can disappear. Users clear site data, browsers evict storage under pressure, private windows discard it on close, and sessionStorage is gone after the tab closes. The device then loses its identity: its old id remains in the device list, new messages are not readable on it, and old encrypted history cannot be decrypted again. Ask for persistent storage with navigator.storage.persist() and tell the user that a cleared browser is a lost device.

  • Every browser profile is a device. A user with a desktop browser, a phone browser and a native client has three devices to verify, and a shared computer with a persistent store is a risk.

  • No secure enclave. The Web Crypto API can hold non-extractable keys, but the Signal-style protocols need access to raw key material for the ratchet in JavaScript, so the keys are extractable in memory.

  • Cost. Creating a bundle and publishing 100 PreKeys is a burst of work and stanzas at first login, and every message adds one encryption per recipient device.

History and message archive

OMEMO and the archive (XEP-0313) interact in three ways. Messages in the archive are ciphertext, so the server and any admin cannot read or search them, which is the goal. A new device cannot read the messages that were sent before it existed, because nobody encrypted a key for it, and the Double Ratchet’s forward secrecy prevents re-deriving old keys. Finally, catching up through the archive after a long absence replays the key exchanges, and the specification asks the client to postpone deleting the used PreKey until catch-up finishes, then to send an empty OMEMO message to move the ratchet on. A product that promises "full history on every device" and OMEMO has to explain this limit to its users.

Libraries and licences

  • Converse.js contains an OMEMO implementation on top of libsignal. The libsignal library is licensed under the GPLv3, which matters to anyone who ships a modified Converse in a closed-source product (see Converse.js).

  • Other implementations exist for Python (the omemo and twomemo packages), Java (Smack’s OMEMO modules) and native clients such as Conversations and Dino. Check which generation each supports before assuming interoperability.

  • Do not implement the Double Ratchet yourself. Use a maintained library, and keep its version current, because key handling bugs are security bugs.

OMEMO or another approach

OMEMO is the XMPP answer when the server operator must not read chats, several devices per user must stay in sync, and asynchronous delivery matters. Alternatives within XMPP are OpenPGP for XMPP (XEP-0373), which has no forward secrecy, and Off-the-Record, which suits two devices that are online together. For rooms of thousands, for compliance regimes that require the operator to retain readable content, and for features that need server-side processing (search, bots, link previews), transport encryption plus access control is the realistic design. The Chatterbox custom protocol can adopt the same ideas, an application-layer envelope encrypted per device, described on the end-to-end encryption page.

Common errors and pitfalls

  • Sending to a device that has no bundle. Skip devices without a bundle, tell the user, and let them remove stale devices.

  • Encrypting only to the recipient. The sender’s other devices must also receive a key, or they cannot read their own sent messages.

  • Trusting new devices silently. A fake device published to a device list receives copies of all messages.

  • Opening the room to anonymous participants. An OMEMO room must be non-anonymous.

  • Leaving a stale device in the list. It enlarges every message and is a risk if the old browser profile is recovered.

  • Assuming the server cannot see anything. It sees metadata, the unencrypted stanza wrapper and the sizes.

  • Treating sessionStorage as a key store. The identity disappears with the tab.

  • Mixing generations. A message encrypted for urn:xmpp:omemo:2 is not readable by a legacy-only client.