Skip to main content

Encryption

Whether your data is end-to-end encrypted depends on the backend. Short version first, then how to add it yourself where it's missing.

What you get without doing anything

BackendEncrypted in transitEncrypted at restEnd-to-end
CloudKit private database, plain fields✅ (Apple's keys)
CloudKit via cloudKitEncryptednatively
iCloud key-value store✅ (Apple's keys)only under ADP
iCloud Drive (icloudDocuments)✅ (Apple's keys)only under ADP
Google Drive appDataFolder✅ (Google's keys)

"End-to-end" means the provider stores ciphertext it cannot decrypt. Everything else is encrypted in transit and at rest, but the provider holds a key - readable by them, by a legal request, or by anyone who compromises the account.

Two things worth internalising:

  • CloudKit has real, native end-to-end encryption, and this package exposes it - no key management, no passphrase, nothing to lose
  • Google Drive has none: appDataFolder is hidden from the user's Drive UI, not private - anything holding the OAuth token can read it, including your own backend. Want E2E on Drive? You build it

CloudKit's native encryption

Apple encrypts CKRecord fields written through encryptedValues on device, with a key from the user's iCloud Keychain - Apple's servers store ciphertext and never see the key.

import { cloudKitEncrypted } from 'react-native-cloud-sync'

await cloudKitEncrypted.setItem('auth.refreshToken', token)
const token = await cloudKitEncrypted.getItem('auth.refreshToken')

Or through the facade:

const secrets = createCloudStore({ providers: ['cloudKitEncrypted'] })

Best option when it fits: the hardest part of end-to-end encryption - getting the key onto other devices without ever putting it on a server - is already solved by the iCloud Keychain.

What it costs

Apple platforms only, permanently - the key is in the iCloud Keychain, which CloudKit Web Services cannot reach. A value written here isn't "hard to read" from Android and web, it's unreadable. cloudKitEncrypted.isAvailable() returns false there, and every operation rejects with ERR_UNSUPPORTED_PLATFORM.

Don't mix it into a store with a plaintext provider:

// Wrong. The Drive copy is plaintext, so the encryption bought you nothing.
createCloudStore({ providers: ['cloudKitEncrypted', 'googleDrive'], writeMode: 'mirror' })

If the same data has to be readable off Apple platforms, use the codec instead - a key you manage works everywhere, at the cost of you managing it.

Values aren't queryable either - CloudKit can't index an encrypted field, so there's no server-side filtering or sorting (getAllKeys() still works, since it queries record names). Keys aren't encrypted - record names are visible to Apple, only values are protected, so settings.theme is fine as a key but patient.7f3a-diagnosis-hiv is not. Put nothing sensitive in a key; the codec approach needs the same discipline, since getAllKeys(), tiering and read repair all need cleartext keys.

It's also a separate record type (EncryptedKVBlob, not cloudKit's KVBlob): CloudKit records encryption in the schema, so a field cannot be encrypted for one write and plain for another - the two providers would collide on a field-type conflict if they shared one. cloudKit and cloudKitEncrypted are separate stores; moving data between them is a migrate().

Advanced Data Protection

Separate from the above, and outside your app's control. ADP is a user-facing iCloud setting extending end-to-end encryption to most iCloud categories, including iCloud Drive and iCloud backups - on, icloudDocuments files and device backups become E2E encrypted; off (the default), Apple holds those keys.

There is no public API to detect or request it, so treat it as a bonus rather than a guarantee. cloudKitEncrypted is E2E either way, which is the point of using it.

Google Drive has none

appDataFolder gives you obscurity, not confidentiality:

  • hidden from the user's Drive UI, so they will not stumble on it or delete it by accident;
  • scoped to your app's OAuth client, so another app cannot request it;
  • but stored under Google's keys, readable by Google, and readable by anything holding a valid access token.

Google Workspace has a client-side encryption feature, but it's admin-configured, enterprise-only, and unreachable from the drive.appdata scope - not an option for a mobile app.

So on Drive, end-to-end encryption is something you add.

Doing it yourself

createCloudStore takes a codec - a two-way value transform applied at the boundary between the store and its providers.

const store = createCloudStore({
providers: ['googleDrive'],
codec: {
encode: (value, key) => encrypt(value, key),
decode: (value, key) => decrypt(value, key),
},
})

The provider only ever sees ciphertext; your app only ever sees plaintext, and both halves may be async. This package deliberately ships no cipher - bring a library you chose: react-native-quick-crypto, expo-crypto plus a Keychain-backed key, libsodium.

What it applies to, and what it does not

ValuesEncrypted
KeysNot encrypted - getAllKeys(), tiering and read repair all need them
The outboxHolds ciphertext, so a persisted queue never contains plaintext
migrate()Moves ciphertext verbatim; migration does not need the key
ResolversRun on plaintext - decoding happens before your resolve function
TieringRoutes on the encrypted size, because that is what actually gets stored
cloudKitAssets / googleDriveFiles / icloudDocumentsNot covered - they move files by path, not values through the store. Encrypt the file yourself before handing it over

The last three rows are what catch people: a codec that inflates a value by 40% can push it over a tiering threshold, so your effective kvMaxBytes is smaller than it looks - see Encrypting at rest for the full mechanism.

Where the key lives

The whole problem: encryption is easy, getting the same key onto a second device without the provider reading it is not.

A passphrase the user remembers

Works on every platform, and the provider never has anything usable.

import { pbkdf2 } from 'your-crypto-lib'

const key = await pbkdf2(passphrase, salt, { iterations: 600_000, length: 32 })

The cost is real: forget the passphrase, lose the data - no reset, by construction. Say so plainly in the UI at the moment they set it, not in a settings screen they will never open.

The iCloud Keychain, for Apple-only apps

A Keychain item marked synchronizable propagates to the user's other Apple devices, end-to-end encrypted, with no passphrase - exactly what cloudKitEncrypted does internally, so use that instead of rebuilding it.

A random data key, wrapped by a passphrase-derived key

The one to reach for if you expect to support a passphrase change, or more than one unlock method later.

// Once, at setup:
const dataKey = randomBytes(32) // never leaves the device unwrapped
const wrapped = await wrap(dataKey, await derive(passphrase))
await store.setItem('crypto.wrappedKey', toBase64(wrapped)) // safe to sync

Changing the passphrase then re-wraps 32 bytes instead of re-encrypting every value. Treat the wrapped key as the one value you never encrypt with the codec - write it through a second store, or a provider directly, so you are not trying to decrypt the key with itself.

Tell a wrong key from corrupt data

Store a canary alongside the wrapped key - a known plaintext, encrypted; if it doesn't decrypt, the passphrase is wrong. Without one, a wrong passphrase and genuinely damaged data look identical, and you show the wrong error.

const canary = await store.getItem('crypto.canary')
if (canary !== 'rncs-ok') return promptPassphraseAgain()

Use an authenticated cipher (AES-GCM, XChaCha20-Poly1305) so a wrong key fails rather than producing garbage. The store surfaces a throwing decode as an error, not a value - pinned by the test suite - so an authentication-tag mismatch reaches your catch instead of becoming corrupt state.

A worked example

import { createCloudStore } from 'react-native-cloud-sync'
import { aesGcmDecrypt, aesGcmEncrypt, randomBytes } from 'your-crypto-lib'

function encryptedStore (dataKey: Uint8Array) {
return createCloudStore({
providers: ['googleDrive'],
outboxStorage: mmkvAdapter,
codec: {
encode: async (value) => {
// A fresh nonce per write. Reusing one across writes is the classic
// way to destroy GCM's guarantees.
const nonce = randomBytes(12)
const ciphertext = await aesGcmEncrypt(value, dataKey, nonce)
return `${toBase64(nonce)}.${toBase64(ciphertext)}`
},
decode: async (stored) => {
const [nonce, ciphertext] = stored.split('.')
// Throws on an authentication-tag mismatch, which is what we want -
// a wrong key must not read as a successful decode.
return await aesGcmDecrypt(fromBase64(ciphertext), dataKey, fromBase64(nonce))
},
},
})
}

One consequence of the fresh nonce: identical plaintext produces different ciphertext each time, so read repair rewrites bytes even when nothing changed. Harmless, but it costs a request - turn off repairOnRead if that matters and you are not running a mixed fleet.

Choosing

Apple-only, sensitive datacloudKitEncrypted. Nothing to manage, nothing to lose.
Cross-platform, sensitive datacodec with a passphrase-derived key
Cross-platform, mildly sensitiveDrive's appDataFolder on its own may be enough - decide deliberately rather than by default
Files rather than valuesEncrypt before handing the path to icloudDocuments / googleDriveFiles

And the question worth asking before any of it: does this data need to leave the device at all? The cheapest way to protect a secret in a sync library is not to sync it.