Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Implement End-to-End Encrypted Messaging in FlutterFlow

End-to-end encrypted messaging in FlutterFlow uses RSA key pairs generated with the pointycastle package. Each user's public key is stored in Firestore; their private key stays on-device in flutter_secure_storage. To send a message, encrypt it with the recipient's public key. To read it, decrypt with your own private key. For group chats, use hybrid encryption: encrypt the message body with AES, then encrypt the AES key separately for each recipient.

What you'll learn

  • How to generate RSA key pairs with the pointycastle package in a Custom Action
  • How to store public keys in Firestore and private keys securely on-device
  • How to encrypt and decrypt messages using hybrid RSA + AES encryption
  • How to structure Firestore conversations so only participants can read messages
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read60-90 minFlutterFlow Pro+ (code export required for custom packages)March 2026RapidDev Engineering Team
TL;DR

End-to-end encrypted messaging in FlutterFlow uses RSA key pairs generated with the pointycastle package. Each user's public key is stored in Firestore; their private key stays on-device in flutter_secure_storage. To send a message, encrypt it with the recipient's public key. To read it, decrypt with your own private key. For group chats, use hybrid encryption: encrypt the message body with AES, then encrypt the AES key separately for each recipient.

End-to-End Encryption with RSA Key Pairs

True end-to-end encryption means the server never sees plaintext messages — only the sender and recipient can decrypt them. In FlutterFlow, you achieve this using Custom Actions that call the pointycastle Dart package for RSA operations and the encrypt package for AES. The architecture is: each user generates a key pair on first login, uploads their public key to Firestore, and stores their private key encrypted in flutter_secure_storage on their device. When you send a message, it's encrypted with the recipient's RSA public key (fetched from Firestore) before it ever leaves the device.

Prerequisites

  • A FlutterFlow Pro account with code export enabled
  • A Firebase project with Firestore and Authentication configured
  • Basic understanding of FlutterFlow Custom Actions
  • The flutter project exported locally so you can add pub dependencies: pointycastle, encrypt, flutter_secure_storage

Step-by-step guide

1

Add encryption packages to pubspec.yaml after code export

Export your FlutterFlow project and open the pubspec.yaml file. Under dependencies, add three packages: pointycastle (version ^3.7.4) for RSA key generation and encryption, encrypt (version ^5.0.3) for AES-256 symmetric encryption used in hybrid mode, and flutter_secure_storage (version ^9.0.0) for safe on-device private key storage. Run 'flutter pub get' in the terminal. These packages provide all the cryptographic primitives you need without any native dependencies, meaning they work on iOS, Android, and Web.

pubspec.yaml
1# pubspec.yaml add under dependencies:
2dependencies:
3 flutter:
4 sdk: flutter
5 # Existing FlutterFlow dependencies...
6 pointycastle: ^3.7.4
7 encrypt: ^5.0.3
8 flutter_secure_storage: ^9.0.0

Expected result: flutter pub get completes with no errors and all three packages appear in pubspec.lock.

2

Create a Custom Action to generate and store the RSA key pair

In FlutterFlow, open the Custom Actions panel and create a new action named 'generateKeyPair'. This action should be called once during new user registration. It generates a 2048-bit RSA key pair using pointycastle's RSAKeyGenerator, converts both keys to PEM format strings, stores the private key in flutter_secure_storage under the key 'rsa_private_key', and uploads the public key to the user's Firestore document at users/{uid}/public_key. The action returns a Boolean indicating success. Call this action from your registration flow, after Firebase Auth creates the account.

custom_actions/generate_key_pair.dart
1// custom_actions/generate_key_pair.dart
2import 'dart:math';
3import 'package:pointycastle/export.dart';
4import 'package:flutter_secure_storage/flutter_secure_storage.dart';
5import 'package:cloud_firestore/cloud_firestore.dart';
6import 'package:firebase_auth/firebase_auth.dart';
7import 'dart:typed_data';
8
9Future<bool> generateKeyPair() async {
10 // Generate 2048-bit RSA key pair
11 final secureRandom = FortunaRandom();
12 final seedSource = Random.secure();
13 final seeds = List<int>.generate(32, (_) => seedSource.nextInt(256));
14 secureRandom.seed(KeyParameter(Uint8List.fromList(seeds)));
15
16 final keyGen = RSAKeyGenerator()
17 ..init(ParametersWithRandom(
18 RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64),
19 secureRandom));
20
21 final pair = keyGen.generateKeyPair();
22 final publicKey = pair.publicKey as RSAPublicKey;
23 final privateKey = pair.privateKey as RSAPrivateKey;
24
25 // Convert to PEM strings
26 final publicPem = encodePublicKeyToPem(publicKey);
27 final privatePem = encodePrivateKeyToPem(privateKey);
28
29 // Store private key securely on device
30 const storage = FlutterSecureStorage();
31 await storage.write(key: 'rsa_private_key', value: privatePem);
32
33 // Upload public key to Firestore
34 final uid = FirebaseAuth.instance.currentUser?.uid;
35 if (uid == null) return false;
36 await FirebaseFirestore.instance
37 .collection('users')
38 .doc(uid)
39 .update({'public_key': publicPem});
40
41 return true;
42}

Expected result: After registration, the user's Firestore document has a public_key field and the private key is stored only on their device — never in Firestore.

3

Create a Custom Action to encrypt a message before sending

Create another Custom Action called 'encryptMessage' with two String parameters: plaintext (the message text) and recipientPublicKeyPem (fetched from the recipient's Firestore document). The action generates a random 32-byte AES-256 key, encrypts the plaintext with AES-GCM, then encrypts the AES key with the recipient's RSA public key using OAEP padding. It returns a Map with two fields: encrypted_body (the AES-encrypted message as Base64) and encrypted_key (the RSA-encrypted AES key as Base64). Both values are stored in the Firestore message document, making the message unreadable to anyone without the matching RSA private key.

custom_actions/encrypt_message.dart
1// custom_actions/encrypt_message.dart
2import 'dart:math';
3import 'dart:convert';
4import 'package:pointycastle/export.dart';
5import 'package:encrypt/encrypt.dart' as enc;
6import 'dart:typed_data';
7
8Future<Map<String, String>> encryptMessage(
9 String plaintext, String recipientPublicKeyPem) async {
10 // Generate random AES-256 key
11 final random = Random.secure();
12 final aesKeyBytes = Uint8List.fromList(
13 List<int>.generate(32, (_) => random.nextInt(256)));
14 final aesKey = enc.Key(aesKeyBytes);
15 final iv = enc.IV.fromSecureRandom(16);
16
17 // Encrypt message with AES-GCM
18 final encrypter = enc.Encrypter(enc.AES(aesKey, mode: enc.AESMode.gcm));
19 final encryptedBody = encrypter.encrypt(plaintext, iv: iv);
20 final encryptedBodyWithIv =
21 base64.encode(iv.bytes + encryptedBody.bytes);
22
23 // Encrypt AES key with recipient's RSA public key
24 final publicKey = parsePublicKeyFromPem(recipientPublicKeyPem);
25 final rsaCipher = OAEPEncoding(RSAEngine())
26 ..init(true, PublicKeyParameter<RSAPublicKey>(publicKey));
27 final encryptedKey = rsaCipher.process(aesKeyBytes);
28
29 return {
30 'encrypted_body': encryptedBodyWithIv,
31 'encrypted_key': base64.encode(encryptedKey),
32 };
33}

Expected result: The action returns an encrypted_body and encrypted_key. Inspect the Firestore message document — the message body should be unreadable Base64 strings.

4

Create a Custom Action to decrypt received messages

Create a Custom Action called 'decryptMessage' that accepts encrypted_body (String) and encrypted_key (String) as parameters. It reads the user's private key from flutter_secure_storage, decrypts the AES key using RSA-OAEP, then decrypts the message body using AES-GCM. The IV is prepended to the encrypted bytes (first 16 bytes). Return the decrypted plaintext string. Call this action inside a ListView item's initState or onVisible action, then store the result in a local Page State variable to display in the Text widget. Cache decrypted messages in a local Map (message_id → plaintext) to avoid re-decrypting on scroll.

custom_actions/decrypt_message.dart
1// custom_actions/decrypt_message.dart
2import 'dart:convert';
3import 'package:pointycastle/export.dart';
4import 'package:encrypt/encrypt.dart' as enc;
5import 'package:flutter_secure_storage/flutter_secure_storage.dart';
6import 'dart:typed_data';
7
8Future<String> decryptMessage(
9 String encryptedBody, String encryptedKey) async {
10 const storage = FlutterSecureStorage();
11 final privatePem = await storage.read(key: 'rsa_private_key');
12 if (privatePem == null) return '[Key not found]';
13
14 // Decrypt AES key using RSA private key
15 final privateKey = parsePrivateKeyFromPem(privatePem);
16 final rsaCipher = OAEPEncoding(RSAEngine())
17 ..init(false, PrivateKeyParameter<RSAPrivateKey>(privateKey));
18 final encryptedKeyBytes = base64.decode(encryptedKey);
19 final aesKeyBytes = rsaCipher.process(Uint8List.fromList(encryptedKeyBytes));
20
21 // Decrypt message body with AES-GCM
22 final combined = base64.decode(encryptedBody);
23 final iv = enc.IV(Uint8List.fromList(combined.sublist(0, 16)));
24 final cipherBytes = Uint8List.fromList(combined.sublist(16));
25 final aesKey = enc.Key(Uint8List.fromList(aesKeyBytes));
26 final encrypter = enc.Encrypter(enc.AES(aesKey, mode: enc.AESMode.gcm));
27 return encrypter.decrypt(enc.Encrypted(cipherBytes), iv: iv);
28}

Expected result: Messages in the chat screen display as readable plaintext. Open Firebase console and confirm the stored message body is still encrypted — only the app can decrypt it.

5

Set Firestore security rules to enforce E2E isolation

Open your Firebase console, go to Firestore → Rules, and write rules that prevent anyone from reading a message's encrypted_key or encrypted_body unless they are a participant in that conversation. Create a 'conversations' collection with a 'participants' array field. In the rule, check that request.auth.uid is in the resource.data.participants array. Also add a rule that prevents users from writing to other users' public_key fields — only the authenticated user can update their own public key document. This means even if an attacker gains Firestore access, they cannot read encrypted messages destined for other users.

firestore.rules
1// firestore.rules
2rules_version = '2';
3service cloud.firestore {
4 match /databases/{database}/documents {
5 // Users can read any public key (needed for encryption)
6 // but only update their own
7 match /users/{userId} {
8 allow read: if request.auth != null;
9 allow write: if request.auth.uid == userId;
10 }
11
12 // Only conversation participants can read/write messages
13 match /conversations/{convId} {
14 allow read, write: if request.auth.uid in resource.data.participants
15 || request.auth.uid in request.resource.data.participants;
16
17 match /messages/{msgId} {
18 allow read, write: if request.auth.uid in
19 get(/databases/$(database)/documents/conversations/$(convId)).data.participants;
20 }
21 }
22 }
23}

Expected result: Non-participants receive permission-denied when attempting to read any message or conversation document. Participants can read and write normally.

Complete working example

custom_actions/crypto_utils.dart
1// crypto_utils.dart — PEM encode/decode helpers for RSA keys
2// Used by generateKeyPair, encryptMessage, and decryptMessage actions
3
4import 'dart:convert';
5import 'dart:typed_data';
6import 'package:pointycastle/export.dart';
7import 'package:asn1lib/asn1lib.dart';
8
9/// Encodes an RSA public key to PEM format string
10String encodePublicKeyToPem(RSAPublicKey publicKey) {
11 final algorithmSeq = ASN1Sequence();
12 algorithmSeq.add(ASN1ObjectIdentifier.fromName('rsaEncryption'));
13 algorithmSeq.add(ASN1Null());
14
15 final publicKeySeq = ASN1Sequence();
16 publicKeySeq.add(ASN1Integer(publicKey.modulus!));
17 publicKeySeq.add(ASN1Integer(publicKey.exponent!));
18 final publicKeyBitString =
19 ASN1BitString(Uint8List.fromList(publicKeySeq.encodedBytes));
20
21 final topLevelSeq = ASN1Sequence();
22 topLevelSeq.add(algorithmSeq);
23 topLevelSeq.add(publicKeyBitString);
24
25 final dataBase64 = base64.encode(topLevelSeq.encodedBytes);
26 return '-----BEGIN PUBLIC KEY-----\n$dataBase64\n-----END PUBLIC KEY-----';
27}
28
29/// Encodes an RSA private key to PEM format string (PKCS#1)
30String encodePrivateKeyToPem(RSAPrivateKey privateKey) {
31 final seq = ASN1Sequence();
32 seq.add(ASN1Integer(BigInt.zero)); // version
33 seq.add(ASN1Integer(privateKey.n!));
34 seq.add(ASN1Integer(privateKey.exponent!));
35 seq.add(ASN1Integer(privateKey.privateExponent!));
36 seq.add(ASN1Integer(privateKey.p!));
37 seq.add(ASN1Integer(privateKey.q!));
38 seq.add(ASN1Integer(
39 privateKey.privateExponent! % (privateKey.p! - BigInt.one)));
40 seq.add(ASN1Integer(
41 privateKey.privateExponent! % (privateKey.q! - BigInt.one)));
42 seq.add(ASN1Integer(privateKey.q!.modInverse(privateKey.p!)));
43
44 final dataBase64 = base64.encode(seq.encodedBytes);
45 return '-----BEGIN RSA PRIVATE KEY-----\n$dataBase64\n-----END RSA PRIVATE KEY-----';
46}
47
48/// Parses a PEM public key string back to RSAPublicKey
49RSAPublicKey parsePublicKeyFromPem(String pemString) {
50 final bytes = base64.decode(pemString
51 .replaceAll('-----BEGIN PUBLIC KEY-----', '')
52 .replaceAll('-----END PUBLIC KEY-----', '')
53 .replaceAll('\n', '')
54 .trim());
55 final asn1Parser = ASN1Parser(bytes);
56 final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
57 final bitString = topLevelSeq.elements[1] as ASN1BitString;
58 final publicKeyParser = ASN1Parser(bitString.contentBytes()!);
59 final publicKeySeq = publicKeyParser.nextObject() as ASN1Sequence;
60 final modulus = (publicKeySeq.elements[0] as ASN1Integer).valueAsBigInteger;
61 final exponent = (publicKeySeq.elements[1] as ASN1Integer).valueAsBigInteger;
62 return RSAPublicKey(modulus!, exponent!);
63}
64
65/// Parses a PEM private key string back to RSAPrivateKey
66RSAPrivateKey parsePrivateKeyFromPem(String pemString) {
67 final bytes = base64.decode(pemString
68 .replaceAll('-----BEGIN RSA PRIVATE KEY-----', '')
69 .replaceAll('-----END RSA PRIVATE KEY-----', '')
70 .replaceAll('\n', '')
71 .trim());
72 final asn1Parser = ASN1Parser(bytes);
73 final seq = asn1Parser.nextObject() as ASN1Sequence;
74 final modulus = (seq.elements[1] as ASN1Integer).valueAsBigInteger;
75 final privateExponent = (seq.elements[3] as ASN1Integer).valueAsBigInteger;
76 final p = (seq.elements[4] as ASN1Integer).valueAsBigInteger;
77 final q = (seq.elements[5] as ASN1Integer).valueAsBigInteger;
78 return RSAPrivateKey(modulus!, privateExponent!, p, q);
79}

Common mistakes when implementing End-to-End Encrypted Messaging in FlutterFlow

Why it's a problem: Storing the private key in Firestore instead of flutter_secure_storage

How to avoid: Always use flutter_secure_storage for the private key. On iOS it uses the Keychain; on Android it uses the Keystore. These are hardware-backed stores designed exactly for this use case.

Why it's a problem: Encrypting the message directly with RSA (no AES hybrid)

How to avoid: Use hybrid encryption: generate a random AES-256 key, encrypt the message body with AES, and only use RSA to encrypt the short AES key. This is the industry-standard approach (used by TLS, PGP, and Signal).

Why it's a problem: Not handling the case where a user installs the app on a new device

How to avoid: Implement a key backup prompt that encrypts the private key with a user-chosen passphrase and stores it in cloud backup. Alternatively, warn users that messages are device-specific and cannot be recovered without a backup.

Best practices

  • Never store private keys anywhere except flutter_secure_storage — not in SharedPreferences, Firestore, or app documents.
  • Generate new key pairs per device, not per user account. A user with two phones should have two key pairs; senders encrypt for all of a user's registered public keys.
  • Use AES-256-GCM rather than AES-CBC. GCM provides authenticated encryption, detecting if the ciphertext was tampered with during transit.
  • Store a key version number alongside each user's public key so you can rotate keys in the future without breaking existing message threads.
  • Add a visual indicator (lock icon) in the conversation header that confirms E2E encryption is active and shows the key fingerprint for manual verification.
  • Encrypt message metadata (recipient, timestamp) where possible — leaked metadata can reveal social graphs even when content is encrypted.
  • Test decryption on a second device before shipping to ensure your key exchange and encryption pipeline works end-to-end.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm adding end-to-end encrypted messaging to a FlutterFlow app (exported Flutter project). I need a Dart utility file that: 1) generates a 2048-bit RSA key pair using pointycastle, 2) encodes public and private keys as PEM strings, 3) encrypts a message using hybrid RSA+AES-256-GCM where the AES key is encrypted with the recipient's RSA public key, and 4) decrypts a received message using the private key from flutter_secure_storage. Include error handling and comments.

FlutterFlow Prompt

In my FlutterFlow project I have a Custom Action called 'encryptMessage' that takes two String parameters: plaintext and recipientPublicKeyPem. I want to call this action from my Send Message button's action flow. The action returns a Map with keys 'encrypted_body' and 'encrypted_key'. After the action completes, I need to create a Firestore document in conversations/{convId}/messages with fields: encrypted_body, encrypted_key, sender_uid, timestamp. How do I wire this up in FlutterFlow's Action Flow editor?

Frequently asked questions

Does FlutterFlow support pointycastle out of the box?

No. You must export the FlutterFlow project to a local Flutter codebase and add the pointycastle dependency to pubspec.yaml manually. After adding the package, you can re-import the exported project or maintain the custom action files alongside your FlutterFlow project.

Can I use this approach for group chat encryption?

Yes. For each group message, generate one AES key and encrypt it separately with each participant's RSA public key. Store one 'encrypted_key_{uid}' field per participant in the message document. Each recipient decrypts their own copy of the AES key to read the message.

What happens if a user loses their device?

Without a key backup, all messages encrypted to that device's key pair become permanently unreadable. Implement an optional encrypted key backup (passphrase-protected) that users can export to cloud storage. This is a known trade-off in E2E encryption design.

Is RSA-2048 still secure in 2026?

RSA-2048 is currently considered secure against classical computers. For higher assurance, use RSA-4096 at the cost of slower key generation. NIST recommends transitioning to elliptic-curve alternatives (like X25519 + AES-GCM) for new systems, which the Dart cryptography package supports.

Will this work in FlutterFlow's Run Mode browser preview?

No. flutter_secure_storage and the RSA operations depend on native platform APIs not available in the web sandbox. You must test on a physical iOS or Android device, or an emulator, using flutter run from the exported codebase.

How do I verify I'm talking to the right person and not an impersonator?

Show users a 'safety number' — a short fingerprint derived from both parties' public key hashes (e.g., SHA-256 of both PEM strings, truncated to 60 characters). If both users see the same safety number out of band (phone call, in person), they can confirm their connection is authentic.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.