Para cifrar una base de datos SQLite en Capacitor, usa un plugin basado en SQLCipher, abre la base de datos con una clave de cifrado y guarda esa clave en el iOS Keychain o el Android Keystore, y no en tu code. Esta guía lo hace con @capgo/capacitor-fast-sql en Capacitor 8: configuración del build para ambas plataformas, generación y almacenamiento de claves, cifrado de una base de datos que ya contiene datos de usuario, rotación de claves y los casos de fallo que debes gestionar.
What SQLCipher protects, and what it does not
SQLCipher es una extensión de código abierto de SQLite que cifra cada página del archivo de la base de datos con AES-256. Sin la clave, el archivo son solo bytes aleatorios. También cubre los archivos WAL y journal.
Protege contra:
- alguien que copie los archivos de la app desde un dispositivo con jailbreak o root, un backup sin cifrar o un teléfono perdido con un passcode débil,
- herramientas forenses que leen contenedores de apps,
- otras apps que lean el archivo mediante un directorio compartido mal configurado.
It does not protect against:
- code ejecutándose en tu app (una dependencia maliciosa, un bug de XSS en el WebView) que pueda llamar a tu capa de datos,
- un atacante que logre extraer la clave, razón por la cual el almacenamiento de la clave importa más que el cifrado,
- datos que también guardes en texto plano en otros sitios: logs,
localStorage, caches o eventos de analytics.
iOS y Android ya cifran el almacenamiento de la app cuando el dispositivo está bloqueado. SQLCipher añade una capa que no depende del passcode del dispositivo y que sobrevive a los backups. Vale la pena para apps de salud, finanzas o sectores regulados. Para una visión completa de las amenazas de almacenamiento, considera los backups, logs y caches en texto plano fuera de SQLite.
Requisitos
SQL rápido instalado y configurado como en Cómo usar SQLite en una app de Capacitor, incluyendo la red local de iOS y la configuración de cleartext en Android. El cifrado es opcional, por lo que la instalación base no incluye SQLCipher.
Paso 1: añade SQLCipher a los Native Build
Android
Añade la dependencia al cambio de android/app/build.gradle:
dependencies {
implementation 'net.zetetic:sqlcipher-android:4.13.0'
}
El README del plugin fija la versión 4.13.0. Otras versiones 4.x funcionan siempre que se mantenga la versión mayor 4. Sin esta dependencia, encrypted: true falla con un error claro (“SQLCipher native library not found”) en lugar de cerrarse inesperadamente.
SQLCipher incluye archivos .so nativos para cada ABI. El APK crecerá unos pocos megabytes. Los App Bundles solo entregan la ABI que cada dispositivo necesita.
iOS
Cambia el plugin a su subspec de SQLCipher en ios/App/Podfile, after bunx cap sync ha escrito la entrada por defecto:
pod 'CapgoCapacitorFastSql/SQLCipher', :path => '../../node_modules/@capgo/capacitor-fast-sql'
Luego:
cd ios/App && pod install
bunx cap sync regenera la sección Capacitor del Podfile, así que revisa esta línea tras cada sync. Sin el subspec, el plugin compila con el SQLite del sistema, y al abrir con encrypted: true devuelve Encryption is not available in this build.
Web
No hay SQLCipher en la web. La opción de Fast SQL encrypted es solo para iOS y Android. Si tu app también es una PWA, usa una rama en Capacitor.isNativePlatform() y no guardes tablas sensibles en el build del navegador.
Paso 2: generar una clave
Usa la API Web Crypto, disponible en las WebViews de iOS y Android:
export function generateKey(): string {
const bytes = new Uint8Array(32); // 256 bits
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
Esto genera un string hex de 64 caracteres. SQLCipher usa el string como passphrase y deriva la clave real con PBKDF2-HMAC-SHA512 (256.000 iteraciones por defecto en SQLCipher 4). Esa derivación se ejecuta una vez al abrir y cuesta una fracción de segundo perceptible en teléfonos antiguos, lo cual es normal.
No uses la contraseña del usuario directamente como clave, a menos que quieras que la base de datos quede bloqueada con ella. Los usuarios olvidan contraseñas, las cambian en otros dispositivos o eligen contraseñas débiles. El diseño habitual es una clave de dispositivo aleatoria con secure storage. Si necesitas acceso controlado por usuario, protege el acceso a la clave (con biometría) en su lugar.
Paso 3: guarda la clave en el Keychain o el Keystore
@capgo/capacitor-native-biometric almacena strings arbitrarios cifrados en reposo con el Keychain de iOS y el Keystore de Android. setData y getData (plugin 8.6.0 y superiores) no muestran ningún prompt al usuario. Añade accessControl para requerir Face ID, Touch ID o huella dactilar antes de poder leer la clave.
bun add @capgo/capacitor-native-biometric
bunx cap sync
// src/db/key.ts
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { generateKey } from './generate-key';
const KEY_NAME = 'app.db.key';
export async function getOrCreateDbKey(): Promise<{ key: string; created: boolean }> {
const { isSaved } = await NativeBiometric.isDataSaved({ key: KEY_NAME });
if (isSaved) {
const { value } = await NativeBiometric.getData({ key: KEY_NAME });
return { key: value, created: false };
}
const key = generateKey();
await NativeBiometric.setData({ key: KEY_NAME, value: key });
return { key, created: true };
}
export async function deleteDbKey(): Promise<void> {
await NativeBiometric.deleteData({ key: KEY_NAME });
}
Opcional: requiere biometría para desbloquear la base de datos
import { AccessControl, NativeBiometric } from '@capgo/capacitor-native-biometric';
// When creating the key
await NativeBiometric.setData({
key: 'app.db.key',
value: generateKey(),
accessControl: AccessControl.BIOMETRY_ANY,
});
// When opening the database
const { value: key } = await NativeBiometric.getSecureData({
key: 'app.db.key',
reason: 'Unlock your records',
});
BIOMETRY_ANY se mantiene tras registrar una nueva huella o rostro. BIOMETRY_CURRENT_SET invalida la clave cuando cambia el registro biométrico, lo cual es más estricto pero significa que la base de datos local será ilegible si el usuario añade una huella. Elígelo solo si los datos se pueden resincronizar desde tu servidor. Más sobre prompts y fallbacks en autenticación biométrica en apps de Capacitor.
Dónde no guardar la clave
- En tus archivos de
.envJavaScript, ocapacitor.config.ts. Todos se incluyen en el bundle de la app y cualquiera puede descomprimir un IPA o APK. - En
@capacitor/preferencesolocalStorage. Esos están en texto plano. - Se obtienen de tu API en cada inicio sin copia local. La app no abriría offline, lo que anula el propósito de una base de datos local. Lo ideal es obtenerla tras el login y guardarla en el Keychain o Keystore.
Paso 4: abrir la base de datos cifrada
// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { getOrCreateDbKey } from './key';
import { migrate } from './migrate';
let dbPromise: Promise<SQLConnection> | null = null;
export function getDb(): Promise<SQLConnection> {
if (!dbPromise) {
dbPromise = (async () => {
const { key } = await getOrCreateDbKey();
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: key,
walMode: true,
});
await verifyKey(db);
await migrate(db);
return db;
})();
}
return dbPromise;
}
// SQLCipher only notices a wrong key when it reads the first page
async function verifyKey(db: SQLConnection): Promise<void> {
await db.query('SELECT count(*) AS n FROM sqlite_master');
}
verifyKey importa. SQLCipher no valida la clave al abrirla. La primera lectura real fallará con file is not a database si la clave es incorrecta; así que haz una lectura rápida de inmediato y gestiona el error en un solo lugar en vez de en medio de una pantalla.
Para verificar que el cifrado está activo, ejecuta PRAGMA cipher_version en la conexión. Devuelve la versión de SQLCipher y un resultado vacío en SQLite sin cifrar.
Paso 5: gestionar una clave perdida o incorrecta
Una clave puede perderse en el uso normal:
- El usuario restaura una copia de seguridad en un teléfono nuevo. El archivo de la base de datos vuelve, pero los elementos del Keychain creados solo para el dispositivo y las claves de Android Keystore no se transfieren al nuevo hardware.
- Android Auto Backup restaura los archivos de la app, pero la clave de Keystore usada para cifrar el secreto almacenado no se restauró.
- El usuario cambia el registro biométrico y tú usaste
BIOMETRY_CURRENT_SET.
La recuperación siempre es la misma: los datos locales se perdieron, así que borras el archivo, empiezas de cero y vuelves a sincronizar desde tu backend.
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL } from '@capgo/capacitor-fast-sql';
import { deleteDbKey } from './key';
export async function resetEncryptedDatabase(name = 'vault'): Promise<void> {
try {
await FastSQL.disconnect(name);
} catch {
// not connected
}
// Fast SQL stores <name>.db in Documents on iOS and in files/ on Android.
// Directory.Data maps to those two locations.
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${name}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
await deleteDbKey();
}
Llámala cuando verifyKey falla con file is not a database, reinicia el dbPromise, luego abre de nuevo. Se genera una nueva clave y se crea una base de datos vacía.
Dos ajustes relacionados para revisar:
- En Android, considera excluir la base de datos del Auto Backup en
data_extraction_rules.xmlsi de todos modos no se puede desencriptar tras la restauración. Evitas un reset confuso a tus usuarios. - En iOS, los archivos en
Documentsvan a las copias de iCloud. Están encriptados y solo son legibles con la clave, pero excluirlos mantiene las copias pequeñas si los datos se pueden resincronizar.
Encriptar una base de datos existente en texto plano
Si tu app ya se lanzó con una base de datos sin cifrar, no puedes cambiarlo encrypted: true en el mismo archivo. SQLCipher intentaría descifrar páginas de texto plano y fallaría. Copia los datos en una nueva base de datos cifrada una sola vez:
// src/db/encrypt-existing.ts
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
export async function encryptExistingDatabase(
plainName: string,
encryptedName: string,
key: string,
): Promise<SQLConnection> {
const plain = await FastSQL.connect({ database: plainName });
const enc = await FastSQL.connect({ database: encryptedName, encrypted: true, encryptionKey: key });
const objects = (await plain.query(
`SELECT type, name, sql FROM sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END`,
)) as { type: string; name: string; sql: string }[];
const [{ user_version }] = await plain.query('PRAGMA user_version');
const tables = objects.filter((o) => o.type === 'table');
const others = objects.filter((o) => o.type !== 'table'); // indexes, triggers, views
await enc.transaction(async (tx) => {
for (const t of tables) {
await tx.execute(t.sql);
}
for (const t of tables) {
const rows = await plain.query(`SELECT * FROM "${t.name}"`);
if (rows.length === 0) continue;
const columns = Object.keys(rows[0]);
const statement = `INSERT INTO "${t.name}" (${columns.map((c) => `"${c}"`).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`;
await tx.executeBatch(rows.map((r) => ({ statement, params: columns.map((c) => r[c]) })));
}
// Indexes and triggers after the data: faster, and triggers do not fire during the copy
for (const o of others) {
await tx.execute(o.sql);
}
await tx.execute(`PRAGMA user_version = ${Number(user_version)}`);
});
await FastSQL.disconnect(plainName);
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${plainName}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
return enc;
}
Ejecútalo una vez, protegido por un flag tras el éxito, y muestra un estado de progreso ya que lee cada fila. Notas:
- Primero se crean y llenan las tablas, luego los índices, triggers y views. Crear índices al final es más rápido, y los triggers no se disparan mientras se copian las filas.
sqlite_sequencese omite. SQLite lo actualiza automáticamente al insertar filas con ids explícitos, así queAUTOINCREMENTcontinúa desde el id más alto.- Las tablas virtuales (FTS) crean tablas shadow internas. Recrea las tablas FTS desde sus
CREATE VIRTUAL TABLEstatement y reconstruye el índice en lugar de copiar las shadow tables. - Para tablas muy grandes, usa paginación con
WHERE rowid > ? ORDER BY rowid LIMIT 5000en lugar de una únicaSELECT *. - El objetivo del ejercicio es borrar el archivo en texto plano. El almacenamiento flash borrado aún puede contener bloques antiguos, pero el archivo ya no es accesible mediante el sistema de archivos.
Rotar la clave
SQLCipher vuelve a cifrar toda la base de datos con PRAGMA rekey. Con Fast SQL, lo ejecutas como un statement en una conexión cifrada abierta:
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { getDb } from './index';
import { generateKey } from './generate-key';
export async function rotateKey(): Promise<void> {
const db = await getDb();
const newKey = generateKey(); // hex only, safe to inline
await db.execute(`PRAGMA rekey = '${newKey}'`);
await NativeBiometric.setData({ key: 'app.db.key', value: newKey });
}
El orden importa. Si la app se cierra entre ambas líneas, la base de datos usará la nueva clave mientras que el Keychain aún tendrá la antigua. Para evitarlo, escribe primero la nueva clave con un segundo nombre, haz el rekey, luego promociónala y borra la entrada antigua; al arrancar, intenta usar la clave pendiente si la principal falla. PRAGMA no acepta parámetros vinculados, por eso la clave se incluye inline. Solo incluye valores generados por ti, nunca inputs de usuario. Prueba la rotación en ambas plataformas con una copia de datos de tamaño producción, ya que rekey reescribe cada página.
Cifrado con otros plugins
| Plugin | Cómo funciona el cifrado | Gestión de claves |
|---|---|---|
@capgo/capacitor-fast-sql |
SQLCipher opcional en iOS y Android | Pasas la clave connect() |
@capacitor-community/sqlite |
SQLCipher siempre incluido, activado en capacitor.config |
El plugin guarda una passphrase por app con setEncryptionSecret(), modos secret, encryption (cifrar un archivo existente), newsecret |
@capgo/capacitor-data-storage-sqlite |
SQLCipher para su almacén clave-valor | Passphrase definida en capacitor.config, así que se incluye en la app |
Del plugin de la comunidad encryption puede cifrar una base de datos existente in situ, lo cual es práctico si ya la estás usando. Con una passphrase en capacitor.config (data-storage-sqlite), trata el cifrado como una ofuscación: el secreto está en el bundle. Para valores realmente sensibles, prefiere Fast SQL con una clave de secure storage. Consulta las opciones de almacenamiento key-value en Capacitor para evaluar esa compensación.
Notas de cumplimiento
- Cumplimiento de exportación de App Store. Incluir SQLCipher significa que tu app contiene cifrado. App Store Connect lo pregunta en cada build (o puedes configurarlo
ITSAppUsesNonExemptEncryptionenInfo.plist). El cifrado de los datos del propio usuario en el dispositivo suele estar exento, pero la respuesta depende de tu app y de los países donde la distribuyas. Revisa la guía de Apple y pide asesoramiento si tienes dudas. Nuestro resumen de cifrado de apps ofrece una visión más amplia. - Licencia de SQLCipher. La edición comunitaria usa una licencia tipo BSD que requiere incluir el aviso de copyright en tu app. Añádelo a tu pantalla de licencias open source.
- Seguridad de datos de Google Play. Puedes declarar que los datos están cifrados en reposo. Asegúrate de que esto sea cierto para cada copia, incluyendo caches y logs.
Solución de problemas
file is not a database en la primera consulta. Clave incorrecta, o el archivo es texto plano y lo abriste con encrypted: true. Verifica que la clave en el secure storage sea la misma usada al crearla; luego usa la migración anterior para archivos de texto plano.
Encryption is not available in this build en iOS. El Podfile sigue usando el subspec por defecto. Vuelve a añadir la CapgoCapacitorFastSql/SQLCipher línea después de cap sync y ejecuta pod install.
SQLCipher native library not found en Android. El sqlcipher-android Falta la dependencia en el módulo de la app, o la añadiste al del plugin o al de la raíz build.gradle en su lugar.
Abrir la base de datos es lento. La derivación de la clave corre en cada apertura. Abre una vez al inicio y reutiliza la conexión en vez de conectar por query.
La base de datos se abre en un dispositivo, pero no tras restaurarla. La clave no se incluyó en el backup. Resuélvelo con el flujo de reset y resincroniza desde el servidor.
Resumen
- Añade SQLCipher:
sqlcipher-androiden el módulo de la app de Android, elSQLCiphersubspec en iOS. - Genera una clave aleatoria de 256 bits en el primer inicio.
- Guárdalo con
@capgo/capacitor-native-biometric(setData, opcionalmente conaccessControl). - Abrir con
encrypted: true, luego lee una vez para verificar la clave. - Plan para pérdida de llaves: reset y resincronización.
- Migra las bases de datos en texto plano una vez, y rota claves con
PRAGMA rekeysi tu política lo requiere.
Los cambios nativos como añadir SQLCipher requieren un nuevo build del store. Build de Capgo puede generar binarios firmados para iOS y Android sin un Mac local, y el Docs de Fast SQL lista cada opción de conexión.