Back to home
|
Launching to app stores

My Finances

A personal-finance app with end-to-end encryption and a zero-knowledge backend: your financial data never leaves your device unencrypted.

React NativeNext.jsTypeScriptNode.jsMongoDBVercelE2EEZero-KnowledgeOffline-firstMonorepo

This project was born from a personal need: I wanted to build the habit of managing my finances and couldn't find an app I fully trusted with my data. So I built it. My own product, designed and built end to end: a cross-platform mobile app to manage income and expenses, backed by a custom API and a web companion. The founding premise was radical — that not even I, as the server's owner, could read a user's financial data.

The problem and the privacy thesis

Most finance apps store your records in plaintext on their servers. Here the design choice was the opposite: privacy by design. All encryption happens on the device, and the server only stores opaque blobs it cannot decrypt. The result is a zero-knowledge backend: even if the database were breached, the data would be unreadable.

Architecture

A single monorepo (pnpm workspaces + Turborepo) with four packages — mobile app, backend API, web companion, and a shared package — unified by one cryptographic model. Anything mobile and web both need, above all the E2EE crypto, lives in the shared package from day one, so both clients encrypt and decrypt with the exact same code.

Mobile app (React Native · Expo)

React Native 0.83 / Expo 55 with expo-router, TypeScript and React 19. Per-domain state with Zustand, offline storage in local SQLite (expo-sqlite), tokens in the secure Keychain/Keystore, and biometric-gated keys via react-native-keychain. All encryption runs client-side through the shared envelope (crypto.subtle via react-native-quick-crypto).

Backend API (Express · TypeScript)

Node.js + Express 4 + TypeScript over MongoDB (Mongoose 8), deployed serverless on Vercel with a daily notifications cron. Strict layered architecture (repository → service → controller → router) with manual dependency injection, Zod validation and Swagger docs. It stores encrypted material but never decrypts it, and binds every request to the device with an ECDSA P-256 signature.

Web companion (Next.js)

Next.js 16 (App Router) + React 19 + Tailwind CSS v4. It never sees your password: it gains access through Web Link and decrypts your data in the browser with the same shared envelope. State lives entirely in memory, with a strict CSP and no persistence of tokens or keys.

Shared package

Platform-agnostic TypeScript: domain types, pure business logic (actuals, goal math, recurrence, dates), the EN/ES locales, and — most importantly — the E2EE envelope, built only on standard crypto.subtle. Writing it once guarantees exact cryptographic parity between mobile and web.

Offline-first approach

The app is built to work without a connection: the device is the source of truth and the cloud acts as an encrypted backup that syncs when the network is available.

How it was achieved

Invoices are written first to local SQLite (finanzas.db via expo-sqlite) with an operation queue; a syncManager replays the queued operations once connectivity returns, so the UI responds instantly even offline. Sync is idempotent: each operation carries a unique operationId and the server keeps an OperationLog (30-day TTL) to discard duplicates on resends, preventing double invoices. Budgets and goals, being low-write-frequency, save directly against the API.

Security in offline mode

The local cache never stores plaintext — it holds the same end-to-end encrypted blobs, and keys stay in the system's secure storage (Keychain/Keystore). Every sensitive operation (create, edit, delete) requires biometric authorization before it applies, even offline; and when syncing, each request is signed by the device (ECDSA P-256) within an anti-replay window.

Challenges migrating to offline-first

The biggest challenge was keeping encryption and state consistent between local and remote. It required migrating the data model so the client fully owned the content —moving sensitive fields inside a single encrypted payload—, defining how to reconcile changes and resolve conflicts when the same record is edited at different times, and preserving the security guarantees (biometric authorization and signed requests) for actions created offline and applied later.

Security in depth

The heart of the project. Defense in depth across every layer: identity, data, and transport.

End-to-end encryption

Every sensitive entity (invoice, budget, goal) is stored as an opaque blob using envelope encryption, implemented once in the shared package over crypto.subtle: a per-payload AES-256-GCM key encrypts the content, and that key is wrapped with the user's 4096-bit RSA-OAEP public key. The server receives only the ciphertext, the wrapped key and the IV, validates its size, and never decrypts it.

Key management & recovery

The 4096-bit RSA keypair is generated on-device at sign-up. The private key never travels in the clear: it's stored encrypted two independent ways — with your password (PBKDF2 + AES-256-GCM) and, separately, with a 5-word BIP-39 recovery passphrase. A recovery PDF (expo-print) with that passphrase is generated automatically, and account recovery supports several paths (recovery code, email OTP, BIP-39) without the server ever learning the key.

Device binding (ECDSA P-256)

Beyond the JWT, every authenticated request is signed by the device. On a new device the client generates an ECDSA P-256 keypair, and each request carries the token, a timestamp (rejected if off by more than 5 min), and an ECDSA-P256-SHA256(sessionId:timestamp) signature the server verifies against the registered public key. This neutralizes token theft — a leaked access token is useless without the device key, which never leaves the secure keychain. Access JWTs last 15 min with a rotating 30-day refresh; the sessionId rotates on each login.

Web Link — delegated web access

The web companion gets access without ever seeing your password, banking-style: the browser generates an ephemeral, non-extractable RSA keypair and shows a QR that encodes only a sessionId (plus a manual code for cameraless emulators). The phone approves and uploads the account key re-wrapped for that browser, which unwraps it as a non-extractable CryptoKey. A 256-bit browserToken that never travels in the QR guards the exchange, the web JWT is never persisted (a refresh ends the session), and sessions are revocable in real time and capped (3 concurrent, 12h TTL), with an out-of-band push and email on approval.

Native security modules (iOS & Android)

I wrote a custom native module (Kotlin and Swift) that hardens the app at runtime: it detects compromised devices —root on Android, jailbreak on iOS—, an attached debugger, and whether it's running on an emulator or simulator, and blocks startup in production when something is off. It also implements iOS App Attest (Secure Enclave) which, together with Play Integrity on Android, lets the API require that every request comes from the genuine, unmodified app.

Defense in depth

Password, PIN and biometrics; email verification to register or reset a device; cryptographic device binding; per-endpoint-class rate limiting (8 distinct limiters); PIN verification on invoice mutations; account lockout against brute force; mandatory audit logging of every entity mutation with IP and geolocation; strict Zod validation, a strict CSP and bcrypt hashing; plus app attestation (App Attest / Play Integrity). A documented security audit hardened the system, resolving nearly 18 findings.

Design patterns

A few deliberate patterns keep the codebase clean and the zero-knowledge model intact.

Mobile — Feature-Sliced Design

Screens are thin wrappers; all logic lives in per-feature hooks and UI-less services. Multi-step flows (sign-up, recovery, device reset) render props-driven steps from a single hook, and every API call returns a typed Result whose errorCodes map to localized messages.

Backend — layers + manual DI

Every entity follows a strict chain — repository → service → controller → router — never skipping layers, wired by a lazy singleton container (manual dependency injection). Each layer defines an interface, so tests mock the interface, not the implementation, isolating services from the database.

Recurring invoices — Template + Occurrences

Recurring invoices aren't materialized forever: a template spawns occurrences on demand (recurringRole, templateId, occurrenceIndex), and editing supports this, this-and-future and all scopes — just like a calendar event.

The server knows no relationships

Links between entities (for example a goal contribution creating a linked paid invoice) live entirely inside the client's encrypted payloads. The server treats every document independently — no cross-entity validation, sync or cascade delete. Referential integrity is the client's job, a direct consequence of zero-knowledge.

100% local suggestion engine

Savings suggestions (required monthly contribution, behind-pace nudges, a 3–6-month emergency-fund benchmark, tips like 50/30/20) are computed from a static rule table on-device, over data already decrypted in memory. Wiring this to an external AI would break E2EE, so it stays local by design — no LLM involved.

Quality, testing & CI/CD

Quality is enforced mechanically at three stages — commit, push, and CI — so nothing merges without passing type-safety, lint, tests and a security scan.

Test pyramid

Jest across all four packages. The base is fast unit tests over the pure business logic in the shared package (actuals, goal math, recurrence, the E2EE envelope) and mobile utilities. Above that, the API's services and repositories are tested in isolation from the database via interface and Mongoose mocking, and a route test uses supertest to exercise routing and middleware wiring. On top, a post-deploy Playwright smoke test runs against the real deployment. Current test files: 34 (API), 9 (mobile), 7 (shared), 2 (web).

Static analysis & architecture boundaries

TypeScript strict mode in all four packages, plus a shared ESLint (flat config) and Prettier setup. Architecture is enforced by lint, not convention: eslint-plugin-boundaries forbids reverse dependencies across the API's layers (models → repositories → services → controllers → routes), and no-restricted-imports keeps the shared package free of any imports from the apps. A layer violation fails the same lint gate as any other error.

Git hooks (Husky)

Three hooks catch problems early: pre-commit runs lint-staged per package (~2s), commit-msg enforces Conventional Commits via commitlint, and pre-push runs turbo typecheck + test across the whole workspace (8 tasks, ~21s) before anything reaches the remote.

CI/CD — GitHub Actions + native Vercel

On every push and PR, GitHub Actions runs two parallel jobs. Quality runs turbo lint, typecheck, test and build across the monorepo; Security runs pnpm audit (high severity) plus a gitleaks secret scan over the full history. Deployment is handled natively by Vercel's git integration — no deploy step in the runner, which removed a whole class of build-env bugs — and a Playwright smoke test fires on the deployment-status event to verify the live build (hydration + CSP nonce).

Merge gates

A merge is blocked by any of: a lint error, a layer-boundary violation, a type error, a failing test, a broken build, a high or critical dependency advisory (pnpm audit), or a leaked secret (gitleaks). It all runs automatically in CI, so the barrier to merge is mechanical, not manual review alone.

Stack & practices

Mobile app

React Native 0.83Expo SDK 55expo-routerTypeScript 5.9React 19Zustandexpo-sqliteexpo-secure-storereact-native-keychainreact-native-quick-cryptoReanimatedApp AttestPlay Integrityreact-i18nextSentry

Backend

Node.jsExpress 4TypeScriptMongoDB · Mongoose 8Zod 4JWTECDSA P-256bcryptpinonodemailerSwaggerJestVercelDependency Injection

Security

RSA-OAEP 4096AES-256-GCMEnvelope EncryptionPBKDF2BIP-39ECDSA P-256Zero-KnowledgeWeb LinkApp AttestPlay IntegrityJailbreak/Root DetectionStrict CSP

Quality & DevOps

pnpm workspacesTurborepoJestsupertestPlaywrightHuskylint-stagedcommitlintESLinteslint-plugin-boundariesPrettierGitHub ActionsgitleaksDocker ComposeEAS Build

Live demo

Try the real app in an isolated environment: mobile runs in an in-browser emulator and the web is the deployed companion. Data comes from a seeded test account — no real data is involved.

Mobile app (emulator)

📱The emulator opens in a new tab on Appetize — the real app running on an Android device in your browser.Launch the emulator ↗

Web companion

The demo uses an isolated backend with QA accounts. Link the web to mobile via the Web Link manual code.

Open the web companion ↗

How to try it

  1. 1In the emulator, sign in with the test account.
  2. 2When asked for a verification code, enter the demo OTP below (it is fixed for this sandbox) and then the PIN.
  3. 3Open the web companion and copy the Web Link code.
  4. 4In the emulator, paste the code under "manual entry" and approve.
  5. 5The web is now linked and decrypts the data in the browser.

Test account

QA · sandbox

For the isolated demo environment only.

Gallery

How it looks and how it works. (Screenshots and GIFs of the main flows.)

Screenshots

Balance: every invoice at a glance, with status filters and search.
Balance: every invoice at a glance, with status filters and search.
Invoice detail with payment reminder and one-tap “mark as paid”.
Invoice detail with payment reminder and one-tap “mark as paid”.
Guided invoice form: payment method, recurrence and reminders.
Guided invoice form: payment method, recurrence and reminders.
Privacy by design: deletion is permanent — the server keeps no backups.
Privacy by design: deletion is permanent — the server keeps no backups.
Dashboard: paid vs. pending, decrypted and aggregated on-device.
Dashboard: paid vs. pending, decrypted and aggregated on-device.
Top concepts and payment methods.
Top concepts and payment methods.
Monthly budget: actual vs. planned, by category.
Monthly budget: actual vs. planned, by category.
Savings goals: target amount or recurring habit.
Savings goals: target amount or recurring habit.
Goal progress with contribution history.
Goal progress with contribution history.
Security center: PIN, biometrics, recovery codes and activity log.
Security center: PIN, biometrics, recovery codes and activity log.
Audit trail: every sensitive action, per device.
Audit trail: every sensitive action, per device.
Web Link: pair the web companion via QR or manual code.
Web Link: pair the web companion via QR or manual code.

Flows in action

Full walkthrough: the mobile app and the web companion linked live — the E2EE delegation chain end to end.

Current status

The project is in its launch and store-deployment phase. There's no public link to share yet, but you can see screenshots of how it looks and GIFs of how it works in the gallery above.

Want to talk about the project?

I'm happy to dive into the technical decisions or walk you through a demo.

Contact me