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
Add encryption packages to pubspec.yaml after code export
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.
1# pubspec.yaml — add under dependencies:2dependencies:3 flutter:4 sdk: flutter5 # Existing FlutterFlow dependencies...6 pointycastle: ^3.7.47 encrypt: ^5.0.38 flutter_secure_storage: ^9.0.0Expected result: flutter pub get completes with no errors and all three packages appear in pubspec.lock.
Create a Custom Action to generate and store the RSA key pair
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.
1// custom_actions/generate_key_pair.dart2import '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';89Future<bool> generateKeyPair() async {10 // Generate 2048-bit RSA key pair11 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)));1516 final keyGen = RSAKeyGenerator()17 ..init(ParametersWithRandom(18 RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64),19 secureRandom));2021 final pair = keyGen.generateKeyPair();22 final publicKey = pair.publicKey as RSAPublicKey;23 final privateKey = pair.privateKey as RSAPrivateKey;2425 // Convert to PEM strings26 final publicPem = encodePublicKeyToPem(publicKey);27 final privatePem = encodePrivateKeyToPem(privateKey);2829 // Store private key securely on device30 const storage = FlutterSecureStorage();31 await storage.write(key: 'rsa_private_key', value: privatePem);3233 // Upload public key to Firestore34 final uid = FirebaseAuth.instance.currentUser?.uid;35 if (uid == null) return false;36 await FirebaseFirestore.instance37 .collection('users')38 .doc(uid)39 .update({'public_key': publicPem});4041 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.
Create a Custom Action to encrypt a message before sending
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.
1// custom_actions/encrypt_message.dart2import 'dart:math';3import 'dart:convert';4import 'package:pointycastle/export.dart';5import 'package:encrypt/encrypt.dart' as enc;6import 'dart:typed_data';78Future<Map<String, String>> encryptMessage(9 String plaintext, String recipientPublicKeyPem) async {10 // Generate random AES-256 key11 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);1617 // Encrypt message with AES-GCM18 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);2223 // Encrypt AES key with recipient's RSA public key24 final publicKey = parsePublicKeyFromPem(recipientPublicKeyPem);25 final rsaCipher = OAEPEncoding(RSAEngine())26 ..init(true, PublicKeyParameter<RSAPublicKey>(publicKey));27 final encryptedKey = rsaCipher.process(aesKeyBytes);2829 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.
Create a Custom Action to decrypt received messages
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.
1// custom_actions/decrypt_message.dart2import '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';78Future<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]';1314 // Decrypt AES key using RSA private key15 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));2021 // Decrypt message body with AES-GCM22 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.
Set Firestore security rules to enforce E2E isolation
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.
1// firestore.rules2rules_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 own7 match /users/{userId} {8 allow read: if request.auth != null;9 allow write: if request.auth.uid == userId;10 }1112 // Only conversation participants can read/write messages13 match /conversations/{convId} {14 allow read, write: if request.auth.uid in resource.data.participants15 || request.auth.uid in request.resource.data.participants;1617 match /messages/{msgId} {18 allow read, write: if request.auth.uid in19 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
1// crypto_utils.dart — PEM encode/decode helpers for RSA keys2// Used by generateKeyPair, encryptMessage, and decryptMessage actions34import 'dart:convert';5import 'dart:typed_data';6import 'package:pointycastle/export.dart';7import 'package:asn1lib/asn1lib.dart';89/// Encodes an RSA public key to PEM format string10String encodePublicKeyToPem(RSAPublicKey publicKey) {11 final algorithmSeq = ASN1Sequence();12 algorithmSeq.add(ASN1ObjectIdentifier.fromName('rsaEncryption'));13 algorithmSeq.add(ASN1Null());1415 final publicKeySeq = ASN1Sequence();16 publicKeySeq.add(ASN1Integer(publicKey.modulus!));17 publicKeySeq.add(ASN1Integer(publicKey.exponent!));18 final publicKeyBitString =19 ASN1BitString(Uint8List.fromList(publicKeySeq.encodedBytes));2021 final topLevelSeq = ASN1Sequence();22 topLevelSeq.add(algorithmSeq);23 topLevelSeq.add(publicKeyBitString);2425 final dataBase64 = base64.encode(topLevelSeq.encodedBytes);26 return '-----BEGIN PUBLIC KEY-----\n$dataBase64\n-----END PUBLIC KEY-----';27}2829/// 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)); // version33 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!)));4344 final dataBase64 = base64.encode(seq.encodedBytes);45 return '-----BEGIN RSA PRIVATE KEY-----\n$dataBase64\n-----END RSA PRIVATE KEY-----';46}4748/// Parses a PEM public key string back to RSAPublicKey49RSAPublicKey parsePublicKeyFromPem(String pemString) {50 final bytes = base64.decode(pemString51 .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}6465/// Parses a PEM private key string back to RSAPrivateKey66RSAPrivateKey parsePrivateKeyFromPem(String pemString) {67 final bytes = base64.decode(pemString68 .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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation