Part of a series on building production real-time communication apps with Flutter and Matrix

A Complete Guide to Building E2EE Chat in Flutter with the Matrix SDK

July 2026 12 min read
Series

Previously in this series: What Is the Matrix SDK? A Flutter Developer's Guide

What This Article Covers

What this article does NOT cover (reserved for later in the series):

By the end, you will have a production-grade encrypted chat screen in Flutter, and you will understand exactly what the encryption is doing at every layer.


1. The Stack

LayerTechnology
Client frameworkFlutter / Dart
SDKmatrix-dart-sdk 6.x (package:matrix)
Encryption primitiveslibolm (Olm/Megolm ratified implementations)
HomeserverSynapse (reference), Conduit (Rust), or Dendrite (Go)
Device pushFCM / APNs (transport only — your Sygnal gateway controls dispatch)
Key storageSQLite via ClientDatabase (persisted to device)

Matrix is not a service. Matrix is an open protocol. You run a homeserver, or you connect to one. The SDK is your client into that protocol — it handles the HTTP/WebSocket sync loop, room state management, and crucially, all Olm/Megolm cryptographic operations at the client layer.


2. Architecture Overview

In an E2EE room, the homeserver is a dumb relay. It receives encrypted payloads and forwards them. It cannot read the message content. It can still see metadata — room membership, event timestamps, device identifiers. This is a protocol-level constraint, not a bug.

Diagram 1: Architecture & sync flow

Insert diagram showing the client-side SDK layers (Client → Room → OlmEngine → Database) connected via HTTPS/WS to a Synapse homeserver, with encrypted payloads flowing through.

The SDK maintains a persistent sync connection to the homeserver via a long-polling WebSocket. Every time the server has new events, it pushes them to the client. The SDK processes each event: if it is encrypted, it routes to the Olm/Megolm engine for decryption before any event stream emits.

Sync loop (simplified):

  Client → GET /_matrix/client/v3/sync (long poll)
  Server → { rooms: { join: { !room:server: { timeline: { events: [...] } } } } }
  SDK    → Filter events by type
         → If m.room.encrypted → decrypt via Olm/Megolm
         → Parse decrypted content
         → Emit through onTimelineEvent stream
         → UI re-renders

3. End-to-End Encryption in Matrix — The Full Mechanics

This section is the core of the article. If you read nothing else, read this.

3.1 Two Algorithms, Two Jobs

Matrix E2EE uses two separate cryptographic ratchets, each with a distinct purpose.

Olm — One-to-One Double Ratchet

Olm is a port of the Signal double-ratchet algorithm to the Matrix ecosystem. It provides:

Olm is used exclusively for:

Every Olm session is between exactly two devices (not two users). If Alice has a phone and a laptop, and Bob has a phone, the SDK establishes three Olm sessions: Alice-phone ↔ Bob-phone, Alice-laptop ↔ Bob-phone, and Alice-phone ↔ Alice-laptop (for self-verification).

Olm session establishment:

  1. Alice's device generates a Curve25519 key pair + Ed25519 signing key
  2. Alice publishes these as device keys via /keys/upload
  3. Bob's device downloads Alice's device keys via /keys/query
  4. Bob creates an Olm session: his private key + Alice's public key
  5. Bob sends a pre-key Olm message (wrapped in m.room.encrypted)
  6. Alice receives it, establishes the session on her side
  7. Both devices now share a symmetric ratchet for one-to-one messaging

Megolm — Group Ratchet

Megolm is a single-direction ratchet designed for group communication. It makes a deliberate tradeoff:

PropertyOlmMegolm
Participants2 devicesN devices in a room
Forward secrecyYesNo — one leaked key decrypts all past messages
Future secrecyYesPartial — new session on membership changes
Performance overheadPer-recipient encryptionSingle encryption, key shared to all
Key rotationEvery messageConfigurable (default: every 100 messages or every week)

The tradeoff is explicit and documented by the Matrix team. Megolm sacrifices forward secrecy because encrypting every message N times (once per recipient) does not scale to rooms with hundreds of members. Instead, the SDK encrypts each message once with the Megolm session key, then encrypts the session key itself using each recipient's Olm session.

Diagram 2: Full encryption pipeline

Insert diagram showing: plaintext → Megolm ciphertext → Olm-wrapped per-recipient → m.room.encrypted event → homeserver → recipients → unwrap → decrypt → plaintext.

Message encryption pipeline (group room):

  Plaintext: "Hello, world!"

  Step 1: Megolm encrypt
    Encrypt plaintext with current Megolm session key
    (AES-256 + HMAC-SHA256)
    Result: m.megolm.v1.aes-sha2 ciphertext

  Step 2: Wrap Megolm key per recipient
    For each device in room:
      Encrypt Megolm session key with that device's Olm session
      Result: olm.v1.curve25519-aes-sha2 ciphertext

  Step 3: Assemble m.room.encrypted event
    {
      "algorithm": "m.megolm.v1.aes-sha2",
      "ciphertext": "<megolm encrypted blob>",
      "session_id": "<current megolm session id>",
      "sender_key": "<sender's curve25519 key>",
      "device_id": "<sender's device id>"
    }

  Step 4: Send to homeserver as POST /rooms/{roomId}/send/m.room.encrypted
    Server stores and relays. Server cannot decrypt.

  Step 5: Each recipient receives event
    SDK checks algorithm → m.megolm.v1.aes-sha2
    Looks up Megolm session by session_id
    If session key known → decrypt directly
    If session key unknown → request via /keys/claim (Olm)

  Plaintext: "Hello, world!" ✓

3.2 Key Generation and Storage

When the SDK initializes for the first time on a device:

  1. Account key generation: a persistent Ed25519 signing key pair (the identity key) and a Curve25519 key pair are generated and stored in ClientDatabase.
  2. Device key upload: the public halves are uploaded via /keys/upload so other devices can establish Olm sessions.
  3. One-time key generation: a batch of one-time Curve25519 keys (OTKs) is generated and uploaded. Other devices use these to establish Olm sessions without a round-trip. The SDK automatically replenishes OTKs when they run low.
  4. Fallback key: a single fallback Curve25519 key is uploaded as a safety net for when OTKs are exhausted.
Diagram 3: Key lifecycle

Insert diagram showing: first init → generate account keys → upload device + one-time keys → establish Olm sessions → negotiate Megolm sessions → encrypt/decrypt messages → periodic key export.

Where keys live on disk:

ClientDatabase (SQLite)
├── account_data         # Ed25519 identity key, Curve25519 key
├── olm_sessions         # Established Olm sessions (recipient device → session)
├── megolm_inbound       # Megolm sessions for rooms the device is in
├── megolm_outbound      # Current Megolm session for each room
├── device_keys          # Known device keys for all users in the room
├── cross_signing        # Cross-signing keys (master, self-signed, user-signing)
└── trust_state          # Which devices are verified or blocked

Key persistence is not optional for production. If the database is lost, the device cannot decrypt past messages (because the Olm sessions that wrapped the Megolm keys are gone). The SDK provides exportKeys() and importKeys() for backup — call them periodically and let the user store the file.

// Export encryption keys for backup
final exported = await client.exportKeys(
  password: 'user-provided-passphrase',
);

// Import on another device or after restore
await client.importKeys(
  exported,
  password: 'user-provided-passphrase',
);

// Configure persistent database on init
final client = Client(
  'MyChatApp',
  databaseBuilder: (client) => ClientDatabase(
    'chat_app.db',
    exportDir: await getApplicationDocumentsDirectory(),
  ),
);

3.3 Device Trust and Cross-Signing

Matrix uses cross-signing to establish trust between devices without requiring users to verify every single device manually.

Master signing key (offline, stored in SDK)
    ├── Self-signing key (signs user's own devices)
    │       └── Device A ✓, Device B ✓
    └── User-signing key (signs other users' masters)
            └── Bob's master key ✓

When Alice logs into a new phone: the SDK generates a new device key pair. Alice's existing laptop (already verified) signs the new phone's key using the self-signing key. Other users see the new phone as "verified" because it chains up to Alice's master key.

If cross-signing is not set up, users must verify each device manually using emoji or number comparison (the m.sas.v1 verification flow).

// Check cross-signing state
if (!client.crossSigning.isSetup) {
  await client.crossSigning.setup();
}

// Verify another user's device
final userIdentity = client.userIdentity(UserId('@bob:server.com'));
await userIdentity?.requestVerification();

// Listen for incoming verification requests
client.onVerificationRequest.stream.listen((request) {
  // Show emoji comparison dialog
  // request.accept() or request.reject()
});

What happens when an untrusted device exists in a room: the SDK still encrypts messages for that device (the protocol sends Megolm keys to all devices in the room), but the application can choose to show a warning. The SDK does not block sending — that is an application-layer decision.


4. Project Setup

dependencies:
  matrix: ^6.0.0
  path_provider: ^2.0.0
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';

Client createClient() {
  return Client(
    'MyChatApp',
    databaseBuilder: (client) => ClientDatabase(
      'chat_app.db',
      exportDir: getApplicationDocumentsDirectory(),
    ),
  );
}

The databaseBuilder parameter is required for production use. Without it, the SDK operates in memory-only mode — keys are lost when the app process dies, and the device becomes unable to decrypt past messages after restart.


5. Authentication

Future<void> login(Client client) async {
  try {
    final response = await client.login(
      LoginType.mLoginPassword,
      password: 'user-password',
      user: '@user:matrix.org',
      initialDeviceDisplayName: 'My Phone',
    );
  } on MatrixException catch (e) {
    // e.errorCode: M_FORBIDDEN, M_LIMIT_EXCEEDED, M_UNKNOWN
    throw e;
  }
}

Future<bool> restoreSession(Client client) async {
  try {
    await client.checkHomeserver(Uri.parse('https://matrix.org'));
    await client.restoreLogin(
      DatabaseApi(
        'chat_app.db',
        exportDir: await getApplicationDocumentsDirectory(),
      ),
    );
    return client.isLogged;
  } on MatrixException {
    return false; // Token expired — prompt for re-login
  }
}

Device ID management: on first login, the server assigns a device ID. On subsequent logins, reuse the same device ID to avoid orphaned Olm sessions (other devices have established sessions with the old device ID that will now fail).


6. Creating an Encrypted Room

Future<Room> createEncryptedDirectRoom(Client client, String userId) async {
  final room = await client.createRoom(
    name: 'Direct Chat',
    invite: [userId],
    isDirect: true,
  );
  // For direct rooms, encryption is enabled automatically
  return room;
}

Future<Room> createEncryptedGroupRoom(Client client, List<String> userIds) async {
  final room = await client.createRoom(
    name: 'Group Chat',
    invite: userIds,
    preset: CreateRoomPreset.privateChat,
  );
  // IMPORTANT: enableEncryption() MUST be called before the first
  // state event is sent to the room. After encryption is enabled,
  // it cannot be disabled.
  await room.enableEncryption();
  return room;
}
Warning: calling enableEncryption() after messages have already been sent creates a partially-unencrypted room. Messages before the m.room.encryption state event are stored as plaintext on the homeserver. The protocol does not allow encryption to be toggled off once enabled.

7. Sending Encrypted Messages

When you call room.sendTextEvent(), the SDK performs the full encryption pipeline internally:

Future<void> sendEncryptedMessage(Room room, String text) async {
  try {
    // The SDK internally:
    //   1. Serializes text into m.text content
    //   2. Encrypts with Megolm outbound session key
    //   3. Wraps Megolm key per recipient's Olm session
    //   4. Sends m.room.encrypted event to homeserver
    await room.sendTextEvent(text);
  } on MatrixException catch (e) {
    if (e.errorCode == 'M_TOO_LARGE') {
      // Message exceeds server limit
    } else if (e.isRateLimitError) {
      // Retry with exponential backoff
    }
  }
}

You do not interact with the crypto layer directly for basic messaging. The SDK exposes it through client.olm if you need low-level access. For 99% of use cases, the high-level Room API is sufficient.

To verify encryption is working:

client.onTimelineEvent.stream.listen((event) {
  if (event.type == EventTypes.Message && event.content != null) {
    // event.content is the DECRYPTED plaintext
    // event.originalContent is the raw m.room.encrypted JSON
    print('Decrypted message from ${event.senderId}: ${event.body}');
  }
});

If decryption fails, event.content is null and event.error is set. Handle this case — show a "decryption failed" indicator rather than silently dropping it.


8. The Chat Screen

class EncryptedChatScreen extends StatefulWidget {
  final Room room;
  const EncryptedChatScreen({required this.room});

  @override
  State<EncryptedChatScreen> createState() => _EncryptedChatScreenState();
}

class _EncryptedChatScreenState extends State<EncryptedChatScreen> {
  late Timeline timeline;
  final TextEditingController _textController = TextEditingController();
  StreamSubscription? _timelineSubscription;

  @override
  void initState() {
    super.initState();
    _initTimeline();
  }

  Future<void> _initTimeline() async {
    timeline = await widget.room.getTimeline();
    _timelineSubscription = timeline.onUpdate.stream.listen((_) {
      if (mounted) setState(() {});
    });

    // Listen for decryption failures
    widget.room.client.onTimelineEvent.stream
        .where((e) => e.type == EventTypes.Message && e.error != null)
        .listen((event) {
      debugPrint('Decryption failure: ${event.error}');
    });
  }

  @override
  void dispose() {
    _timelineSubscription?.cancel();
    _textController.dispose();
    super.dispose();
  }

  void _sendMessage() {
    final text = _textController.text.trim();
    if (text.isEmpty) return;
    widget.room.sendTextEvent(text);
    _textController.clear();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            itemCount: timeline.events.length,
            itemBuilder: (context, index) {
              final event = timeline.events[index];
              if (event.type == EventTypes.Message) {
                if (event.error != null) {
                  return _DecryptionFailureTile(event: event);
                }
                return _MessageTile(event: event);
              }
              return const SizedBox.shrink();
            },
          ),
        ),
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              Expanded(
                child: TextField(
                  controller: _textController,
                  decoration: const InputDecoration(
                    hintText: 'Encrypted message...',
                    border: OutlineInputBorder(),
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.send),
                onPressed: _sendMessage,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _MessageTile extends StatelessWidget {
  final Event event;
  const _MessageTile({required this.event});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: CircleAvatar(
        child: Text(event.senderId?.characters.first.toUpperCase() ?? '?'),
      ),
      title: Text(event.senderId ?? 'Unknown'),
      subtitle: Text(event.body ?? ''),
    );
  }
}

class _DecryptionFailureTile extends StatelessWidget {
  final Event event;
  const _DecryptionFailureTile({required this.event});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: const Icon(Icons.lock, color: Colors.red),
      title: const Text('Message could not be decrypted'),
      subtitle: Text(event.error?.toString() ?? 'Unknown error'),
    );
  }
}
// Usage
final room = client.getRoomById('!roomid:server.com');
if (room == null) {
  // Room not found — join or create
}
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => EncryptedChatScreen(room: room),
  ),
);

9. Limitations of Matrix E2EE

These are not bugs. They are protocol-level tradeoffs that every production Matrix application must account for.

9.1 Metadata Visibility

The homeserver can see which users are in which rooms, when messages are sent, device identifiers, and event types. It cannot see message content or file content.

Mitigation: for metadata-sensitive use cases, use the Matrix protocol over Tor or a VPN. Protocol-level metadata hiding is a known gap.

9.2 Megolm Forward Secrecy

If a Megolm session key is leaked, an attacker can decrypt every message encrypted under that session. Sessions rotate every 100 messages or every 7 days (configurable).

Mitigation: use Olm for sensitive one-to-one conversations, or configure shorter key rotation intervals.

9.3 Device Verification Is User-Driven

The protocol provides the mechanism (emoji SAS, QR code, cross-signing), but the application must prompt users. Most users will not verify devices unless the app blocks unverified ones — which causes support tickets.

Mitigation: show a persistent but non-blocking indicator rather than blocking message sending.

9.4 Key Loss Is Permanent

If ClientDatabase is deleted and no key export exists, the device cannot decrypt past messages.

Mitigation: enable server-side key backup via client.keyBackup.ensureBackup() and offer periodic key exports to users.
// Enable server-side key backup
if (!client.keyBackup.isActive) {
  await client.keyBackup.ensureBackup();
}

10. Production Checklist


11. What's Next

This article covered the complete E2EE chat implementation — from the Olm/Megolm encryption pipeline to persistent key management to production considerations.

Next in the series: