Skip to main content
LibXMTP uses SQLite for local storage and supports optional encryption via SQLCipher. This guide covers enabling encryption, managing keys, and best practices for securing user data.

Overview

Database encryption protects:
  • Message history and content
  • Group metadata and membership
  • User identity and installation keys
  • Consent preferences
  • All local XMTP data
Important: Database encryption is strongly recommended for production applications. However, key derivation, storage, and secure management are the responsibility of the application developer.

Enabling Encryption

Basic Encryption Setup

Provide a 32-byte encryption key when creating the database:
use xmtp_db::EncryptedMessageStore;
use xmtp_db::StorageOption;

// Generate or derive a 32-byte encryption key
let encryption_key: [u8; 32] = generate_encryption_key();

// Create encrypted database
let db = EncryptedMessageStore::new(
    StorageOption::Persistent("/path/to/db.sqlite3"),
    Some(encryption_key.to_vec()),
)?;

// Use with client builder
let client = Client::builder(identity_strategy)
    .store(db)
    .default_mls_store()?
    // ... other configuration
    .build()
    .await?;

Without Encryption

For testing or non-sensitive environments:
// Unencrypted database (not recommended for production)
let db = EncryptedMessageStore::new(
    StorageOption::Persistent("/path/to/db.sqlite3"),
    None,  // No encryption key
)?;
If you create a database without encryption, you cannot add encryption later without recreating the database.

Key Generation

Secure Random Key

Generate a cryptographically secure key:
use rand::RngCore;

fn generate_encryption_key() -> [u8; 32] {
    let mut key = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut key);
    key
}

Derive from Password

Derive a key from a user password using a KDF:
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::{SaltString, rand_core::OsRng};

fn derive_key_from_password(password: &str, salt: &[u8]) -> Result<[u8; 32], Error> {
    let argon2 = Argon2::default();
    let salt_string = SaltString::encode_b64(salt).map_err(|_| Error::InvalidSalt)?;
    
    let hash = argon2.hash_password(password.as_bytes(), &salt_string)
        .map_err(|_| Error::KeyDerivation)?;
    
    let key_bytes = hash.hash.ok_or(Error::KeyDerivation)?;
    let mut key = [0u8; 32];
    key.copy_from_slice(&key_bytes.as_bytes()[..32]);
    
    Ok(key)
}
Always use a proper Key Derivation Function (KDF) like Argon2, PBKDF2, or scrypt. Never use plain password hashing algorithms like SHA-256 for key derivation.

Platform-Specific Storage

Store encryption keys securely using platform keychains:
import Security

func saveEncryptionKey(_ key: Data, identifier: String) -> Bool {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: identifier,
        kSecValueData as String: key,
        kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    ]
    
    SecItemDelete(query as CFDictionary)  // Delete any existing
    return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}

func loadEncryptionKey(identifier: String) -> Data? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: identifier,
        kSecReturnData as String: true
    ]
    
    var result: AnyObject?
    guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
        return nil
    }
    return result as? Data
}

SQLCipher Configuration

Verify SQLCipher Support

Check if SQLCipher is available:
// SQLCipher is automatically enabled on native platforms
// WASM does not support encryption

#[cfg(not(target_arch = "wasm32"))]
fn is_sqlcipher_available() -> bool {
    // SQLCipher is built-in for native
    true
}

#[cfg(target_arch = "wasm32")]
fn is_sqlcipher_available() -> bool {
    // Encryption not supported in WASM
    false
}

Custom Cipher Settings

SQLCipher uses secure defaults, but you can customize if needed:
use xmtp_db::encrypted_store::database::native::sqlcipher_connection::SqlCipherConnection;

// Default settings (recommended):
// - PRAGMA cipher_page_size = 4096
// - PRAGMA kdf_iter = 256000
// - PRAGMA cipher_hmac_algorithm = HMAC_SHA512
// - PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512

// These are applied automatically when you provide an encryption key
The default SQLCipher settings provide strong security. Only modify cipher settings if you have specific security requirements and understand the implications.

TypeScript (Node.js) Examples

import { createClient, generateKey } from '@xmtp/node-bindings'

// Generate encryption key
const encryptionKey = generateKey()

// Create client with encryption
const client = await createClient(
  user,
  encryptionKey  // Pass 32-byte key
)

// Store key securely for next session
saveKeySecurely(encryptionKey)

Key Rotation

SQLCipher does not support online key rotation. To change encryption keys, you must create a new database and migrate data.

Migrate to New Key

use xmtp_db::EncryptedMessageStore;

fn rotate_encryption_key(
    old_db_path: &str,
    old_key: Vec<u8>,
    new_key: Vec<u8>,
) -> Result<(), Error> {
    // Open old database
    let old_db = EncryptedMessageStore::new(
        StorageOption::Persistent(old_db_path),
        Some(old_key),
    )?;
    
    // Create new database with new key
    let new_db_path = format!("{}.new", old_db_path);
    let new_db = EncryptedMessageStore::new(
        StorageOption::Persistent(&new_db_path),
        Some(new_key),
    )?;
    
    // Export and import data
    // Note: This requires implementing data migration
    // LibXMTP doesn't provide built-in migration
    
    // After successful migration:
    std::fs::rename(&new_db_path, old_db_path)?;
    
    Ok(())
}

Best Practices

1
Always Encrypt in Production
2
Never ship production apps without encryption:
3
#[cfg(debug_assertions)]
fn create_db(path: &str) -> EncryptedMessageStore {
    // Development: Allow unencrypted for easier debugging
    EncryptedMessageStore::new(
        StorageOption::Persistent(path),
        None,
    ).unwrap()
}

#[cfg(not(debug_assertions))]
fn create_db(path: &str) -> EncryptedMessageStore {
    // Production: Always require encryption
    let key = load_or_generate_key();
    EncryptedMessageStore::new(
        StorageOption::Persistent(path),
        Some(key),
    ).expect("Failed to create encrypted database")
}
4
Use Platform Keychains
5
Store keys in OS-provided secure storage:
6
// Good: Use platform keychain
let key = platform::get_key_from_keychain("xmtp-db-key")?;

// Avoid: Storing key in app preferences
// let key = preferences.get_string("db_key")?;  // Insecure!
7
Handle Wrong Key Gracefully
8
Provide clear error messages:
9
use xmtp_db::StorageError;

match EncryptedMessageStore::new(path, Some(key)) {
    Ok(db) => db,
    Err(StorageError::DbInit(msg)) if msg.contains("file is not a database") => {
        // Wrong encryption key or corrupted database
        return Err(AppError::WrongEncryptionKey);
    }
    Err(e) => return Err(e.into()),
}
10
Backup Encryption Keys
11
Provide key backup/recovery mechanisms:
12
// Example: Export encrypted key backup
fn export_key_backup(key: &[u8], user_password: &str) -> Vec<u8> {
    // Encrypt key with user password
    encrypt_with_password(key, user_password)
}

// Restore from backup
fn restore_key_backup(backup: &[u8], user_password: &str) -> Result<Vec<u8>, Error> {
    decrypt_with_password(backup, user_password)
}
13
Test Key Persistence
14
Ensure keys survive app restarts:
15
#[test]
fn test_encryption_key_persistence() {
    // Generate and save key
    let key = generate_encryption_key();
    save_key_to_keychain(&key).unwrap();
    
    // Simulate app restart
    drop(key);
    
    // Load key
    let loaded_key = load_key_from_keychain().unwrap();
    
    // Verify database opens
    let db = EncryptedMessageStore::new(
        StorageOption::Persistent("/tmp/test.db"),
        Some(loaded_key),
    ).unwrap();
}

Platform Considerations

Native (iOS/Android)

SQLCipher is fully supported:
// Native platforms: Full encryption support
#[cfg(not(target_arch = "wasm32"))]
let db = EncryptedMessageStore::new(
    StorageOption::Persistent(path),
    Some(encryption_key),
)?;

WebAssembly

Encryption is not supported in WASM:
// WASM: Encryption key is ignored
#[cfg(target_arch = "wasm32")]
let db = EncryptedMessageStore::new(
    StorageOption::Persistent(path),
    encryption_key,  // Will log warning and ignore
)?;
In WASM environments, the database is stored in IndexedDB without encryption. Consider this when handling sensitive data in web applications.

Troubleshooting

Database Won’t Open

// Error: "file is not a database"
// Cause: Wrong encryption key or corrupted database

// Try with correct key
let db = EncryptedMessageStore::new(
    StorageOption::Persistent(path),
    Some(correct_key),
)?;

// If still failing, database may be corrupted
// You'll need to recreate it (data will be lost)

Performance Impact

Encryption adds minimal overhead:
// Encryption overhead is typically < 5%
// No special optimization needed

let db = EncryptedMessageStore::new(
    StorageOption::Persistent(path),
    Some(key),
)?;  // Performance is comparable to unencrypted

Key Size Errors

// SQLCipher requires exactly 32 bytes (256 bits)
let key = generate_encryption_key();  // Must be [u8; 32]

assert_eq!(key.len(), 32, "Encryption key must be 32 bytes");

Next Steps

Creating Clients

Learn how to create clients with encrypted storage

Consent Management

Manage user preferences and privacy

Build docs developers (and LLMs) love