
How Pulsyn Encrypts Your Health Data: SQLCipher, 600,000 PBKDF2 Iterations, and Why We Do Not Know Your PIN
TL;DR
Most health apps encrypt data in transit and call it privacy. Pulsyn encrypts data at rest on your phone using SQLCipher with a key derived from your PIN through 600,000 PBKDF2 iterations. We do not know your PIN. We do not store your encryption key in our cloud. If you forget your PIN and lose your phone, your health data is gone. That is the point.
The threat model most apps ignore
When health app companies talk about security, they usually mean TLS. Your data travels from the phone to their server inside an encrypted tunnel. That is fine for transit. It does nothing for the moment the data sits on your phone, unencrypted, in a SQLite file any other app with storage permissions can read.
Oura, Whoop, Fitbit, and most fitness trackers store raw sensor data in the app sandbox. On Android, that sandbox is protected by the OS permission model, but any app with root access, a backup tool, or a malicious SDK bundled into another app can scrape it. On iOS, the situation is better but not absolute. iTunes backups of health data are often unencrypted unless the user explicitly enables backup encryption. Most users do not.
The threat I care about is not a nation-state actor intercepting packets. It is your phone getting stolen, your backup being restored to a laptop you sell on eBay, or a third-party keyboard app reading your SQLite journal files. Pulsyn's encryption is designed for those scenarios because they are the ones that actually happen.

SQLCipher and the local database
Pulsyn stores every heart rate reading, sleep stage, HRV sample, SpO2 measurement, temperature reading, and activity segment in a single local file called pulsyn_encrypted.db. The database lives in your phone's application documents directory. It is a standard SQLite database, but it is opened through SQLCipher, which transparently encrypts every page of the database file using AES-256 in CBC mode with a 256-bit key.
Here is how the database connection is opened in production, from lib/core/database/drift/pulsyn_database.dart:
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'pulsyn_encrypted.db'));
if (Platform.isAndroid) {
await sqlite3_libs.applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
}
final cachebase = (await getTemporaryDirectory()).path;
sqlite3.tempDirectory = cachebase;
final encryptionKey = await _getOrCreateEncryptionKey();
return NativeDatabase.createInBackground(
file,
setup: (db) {
db.execute("PRAGMA key = '$encryptionKey'");
db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL');
},
);
});
}A few things to note about this setup. NativeDatabase.createInBackground runs the database on a background isolate so encryption and decryption do not block the UI thread. WAL mode means reads do not block writes, which matters when the ring is streaming heart rate samples while you are browsing historical sleep data. PRAGMA synchronous=NORMAL is the right tradeoff for a mobile app: it fsyncs less aggressively than FULL, so you do not drain battery on every write, but it still guarantees durability for committed transactions.
The encryption key itself is a 64-character hex string representing 32 bytes of random data. It is generated once per installation using Random.secure(), which pulls from the operating system's cryptographically secure random number generator. The key is stored in FlutterSecureStorage, which maps to Android's encrypted SharedPreferences and iOS's Keychain with first_unlock accessibility. That means the key is available after the first device unlock following a reboot, but it is not readable by other apps and it does not leak into unencrypted backups.
Key derivation: why 600,000 iterations
SQLCipher encrypts the database at the page level using a single master key. But we also encrypt individual data fields using AES-256-GCM with a key derived from your PIN. This is not paranoia. It is defense in depth. If someone extracts the SQLCipher key from the Keychain, they still need your PIN to read the sensitive health records inside.
The derivation happens in lib/core/security/key_derivation_service.dart:
class KeyDerivationService {
static const int iterations = 600000;
static const int keyLength = 32;
static const int saltLength = 32;
Uint8List deriveKey(String pin, Uint8List salt) {
final pbkdf2 = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64));
pbkdf2.init(Pbkdf2Parameters(salt, iterations, keyLength));
final pinBytes = Uint8List.fromList(utf8.encode(pin));
final derivedKey = Uint8List(keyLength);
pbkdf2.deriveKey(pinBytes, 0, derivedKey, 0);
_encryption.secureZero(pinBytes);
return derivedKey;
}
}The iteration count is 600,000 because that is the OWASP 2023 recommendation for PBKDF2-HMAC-SHA256. We are not inflating the number to look serious. The recommendation exists because hardware gets faster, and password hashes from 2010 that used 10,000 iterations are now trivial to crack on a modern GPU. At 600,000 iterations, deriving a single key takes roughly 100-200 milliseconds on a mid-range phone. That is long enough to make brute-force attacks impractical, short enough that unlocking the app does not feel broken.
The derivation runs in a background isolate via Flutter's compute() function. If we ran PBKDF2 on the main thread, the UI would freeze for 200 milliseconds every time you entered your PIN. That is unacceptable. The isolate keeps the animation smooth and the derivation slow, which is exactly what you want.
We also check for weak PINs before accepting them. The isWeakPin method rejects sequential digits, repeated pairs, and a hardcoded list of obvious choices like 000000 and 123456. We generate a 6-digit PIN for you by default, but if you change it, we validate the strength locally. No server is involved.
After derivation, we create a verification hash from the KEK using HMAC-SHA256 over a fixed tag string. This hash is stored on disk. When you enter your PIN, we derive the KEK, recompute the hash, and compare it with constantTimeEquals. This lets us verify your PIN without ever storing the PIN itself or the KEK in persistent storage. The constant-time comparison is small but important: a standard equality check on byte arrays would short-circuit at the first mismatch, leaking information about how many digits an attacker guessed correctly.

AES-256-GCM in practice
The actual encryption of individual records uses AES-256-GCM via the Pointycastle library. GCM is authenticated encryption: it provides confidentiality and integrity in one pass. If an attacker flips a single bit in the ciphertext, authentication fails and decryption throws rather than returning corrupted health data.
From lib/core/security/encryption_service.dart:
static const int keyLength = 32;
static const int nonceLength = 12;
static const int tagLength = 16;
Uint8List encrypt(Uint8List plaintext, Uint8List key) {
final nonce = generateNonce();
final cipher = GCMBlockCipher(AESEngine());
cipher.init(
true,
AEADParameters(
KeyParameter(key),
tagLength * 8,
nonce,
Uint8List(0),
),
);
final ciphertext = Uint8List(plaintext.length + tagLength);
final outputLen = cipher.processBytes(plaintext, 0, plaintext.length, ciphertext, 0);
cipher.doFinal(ciphertext, outputLen);
final result = Uint8List(nonceLength + ciphertext.length);
result.setAll(0, nonce);
result.setAll(nonceLength, ciphertext);
return result;
}The output format is simple: 12 bytes of nonce, followed by the ciphertext, followed by the 16-byte authentication tag. The nonce is generated fresh for every encryption call using Random.secure(). We never reuse a nonce with the same key. That is critical in GCM. A reused nonce leaks the XOR of plaintexts, which in a health context could expose relationships between your HRV readings and your sleep scores.
Why GCM instead of CBC? CBC provides confidentiality but not integrity. An attacker can modify CBC ciphertext and cause specific bit flips in the decrypted output without knowing the key. In a health app, that could mean altering a sleep score or a stress reading. GCM prevents this because the authentication tag is a cryptographic checksum. Any modification invalidates the tag and decryption aborts.
After encryption, the service calls secureZero on the plaintext bytes. Dart is a garbage-collected language, so we cannot guarantee the data is erased from physical memory, but zeroing the Uint8List reduces the window during which a heap dump would contain raw health data. It is a best-effort defense, not a guarantee.
The sync queue: encrypted uploads
Pulsyn has an optional cloud tier. If you enable it, your data syncs to Supabase. But the sync pipeline does not decrypt anything. It uploads ciphertext.
The SyncQueue table in the local database stores records that are waiting for upload. Each row contains the encrypted payload, a hash of the content for deduplication, and a timestamp. The sync queue DAO runs a background job that batches these rows and uploads them. The server receives base64-encoded encrypted blobs. It cannot read them. It only stores them.
We also maintain a SyncQueueV2 table with additional fields for archival and retry tracking. If an upload fails repeatedly, the record moves to a dead-letter state rather than blocking the queue forever. This keeps the sync pipeline healthy without exposing plaintext to the error-handling path.
This architecture means a Supabase compromise would expose encrypted blobs, timestamps, and user IDs. It would not expose heart rate readings, sleep stage classifications, or stress scores. The decryption key never leaves your phone. Even if you use the cloud backup, restoring your data to a new device requires your PIN to regenerate the KEK and decrypt the SQLCipher key from the Keychain.
There is a subtle point here about key escrow. Some apps offer "account recovery" by storing your encryption key in their cloud. We do not. There is no backup of your encryption key on our servers. There is no recovery email that resets your PIN. If you forget your PIN and lose your phone, your data is gone. That is an intentional design choice, not a missing feature.

What we do not know
I want to be precise about what Pulsyn the company cannot access, because vague claims are worse than useless.
We do not know your PIN. It is never transmitted. It is hashed with PBKDF2 and compared locally using constantTimeEquals to prevent timing attacks. The comparison is constant-time because a standard == operator on byte arrays short-circuits at the first mismatch, which leaks information about how many digits an attacker got right.
We do not know your SQLCipher encryption key. It is generated on your device and stored in OS-level secure storage. We have no API endpoint that receives it.
We do not know your health data in plaintext. The app processes sleep scores, readiness indices, and AI insights locally. The cloud tier only sees encrypted blobs. The optional premium cloud AI sees nothing because we are building the on-device AI to handle the sensitive stuff.
We do not have a backdoor. The code is open source. The encryption paths I quoted above are in the public repository. If we added a backdoor, you could find it by reading encryption_service.dart.
What we still worry about
I would be lying if I said this was perfect. There are real limitations.
Dart's garbage collector means sensitive data can linger in memory longer than we would like. A sophisticated attacker with physical access and a cold-boot attack could potentially extract keys from RAM. We mitigate this by keeping key material in Uint8List instances that we zero explicitly, but we cannot pin memory or disable swap.
The SQLCipher key is stored in FlutterSecureStorage, which delegates to the OS. If the OS keychain is compromised, the SQLCipher key is available. That is why we layer the PIN-derived KEK underneath. But if both the keychain and the PIN are compromised, the data is readable. Defense in depth is not defense forever.
Biometric authentication is available as an alternative to the PIN, but on most Android devices, biometric data is handled by the OS, not by us. We trust the OS to enforce biometric policies correctly. That trust is reasonable for consumer threats but not for targeted attacks.
We have not had a third-party security audit yet. We will, before the Kickstarter ships in Q2 2026. Until then, the code is open for inspection, and I welcome scrutiny.
About the author
James Hoffmann is the founder of Pulsyn. He has been building the local-first encryption stack for the Rune 1 since 2024 and still checks encryption_service.dart for nonce-reuse bugs before every release.
References
- OWASP Cheat Sheet Series: Password Storage. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- SQLCipher Documentation: Encryption Key Management. https://www.zetetic.net/sqlcipher/design/
- Pointycastle API Reference: GCMBlockCipher. https://github.com/bcgit/pc-dart
- Drift Documentation: Encryption with SQLCipher. https://drift.simonbinder.eu/docs/other-engines/encryption/



