Security & architecture

Security you can actually read.

Most apps give you a one-line privacy promise. This page gives you the whole blueprint — every layer of how Businessily is built across iPhone, iPad, Mac, the web app and Android, where your data physically lives, and the reasoning behind each decision. Including the parts we don't claim yet.

Privacy by architecture Honest about limits

1 · The core idea: privacy by architecture, not policy

A privacy promise can be broken with one bad deploy. A privacy architecture can't hand over data it was never built to hold. Businessily is designed so the sensitive things are physically absent from places they could leak — not merely "policy says we won't look."

Businessily runs as two tiers, and which one you use depends on your devices:

On-device tier — Apple apps

On iPhone, iPad and Mac, your books live in a local database on the device. By default nothing is sent to any server — not ours, not anyone's. Sync is opt-in and runs through your iCloud.

Private-cloud tier — Web & Android

Where there's no on-device option, your data lives in a Postgres database where row-level security locks every row to your account. It's private to you and encrypted in transit and at rest — but, honestly, it is not end-to-end encrypted (more in §15).

Businessily's two-tier architecture On the left, an on-device tier for iPhone, iPad and Mac: your books live on the device behind a lock, and nothing leaves automatically. On the right, a private-cloud tier for the web and Android: a cloud of rows, each one locked to your account by row-level security. Down the center seam, a barrier marks that financials have no automatic path off the device. On-device tier iPhone · iPad · Mac Your books live here Nothing auto-leaves the device no automatic path for financials Private-cloud tier Web · Android Row-level security every row locked to your account
Two tiers, picked by your devices. Financials have no automatic path off the on-device tier — and on the cloud tier, the database hands back only the rows that are yours.
Why two tiers?

The most private design — everything on-device — only works where there's a device to store it. The web runs in a browser with no durable local store, and Android isn't built yet. Rather than weaken the Apple apps to match the web, we keep each tier as private as its platform allows and tell you exactly which one you're using.

2 · Where your data lives, per device

The same app, four platforms, different storage models. Here's the honest matrix.

 iPhone & iPadMacWeb appAndroid
Status Coming soon Coming soon Live now Coming soon
Primary storageOn deviceOn devicePrivate cloudPrivate cloud
Works fully offline Yes Yes Needs connection Planned
Default syncNone — local onlyNone — local onlyYour accountYour account
Full SSN / contractor TINEncrypted, on device onlyEncrypted, on device only Never stored Never stored
PaymentsYour own StripeYour own StripeYour own StripeYour own Stripe
Analytics / ads None None None None

"Never stored" = there is no database column for the full value (see §9). Tables scroll into cards on small screens.

This is where your data lives. For what each platform can do — and why a few features are iPhone- or app-only — see the Platforms breakdown →

3 · On-device storage (iPhone, iPad, Mac)

The Apple apps are built in SwiftUI with SwiftData (Apple's local persistence framework) as the single source of truth. Every record — clients, invoices, income, expenses, mileage, contractors, payroll, bookkeeping — is written to a local store on the device.

How it's set up

The store is created with cloudKitDatabase: .none. That one flag is deliberate: it tells SwiftData not to auto-mirror your database to iCloud. Storage stays purely local, and any sync is something we do explicitly and selectively (see §6–7). The app also self-heals — if a store ever fails to open or migrate, it's moved aside and rebuilt so the app never bricks on launch.

Why local-first, and why opt out of automatic iCloud mirroring?

Automatic mirroring would copy your entire database — including financials — into iCloud and force every model to fit CloudKit's structural rules. By keeping storage local and syncing only a tiny allowlist by hand, your financial records have no automatic path off the device, and we keep full control of what can ever leave.

Honest scope: the local database benefits from iOS/macOS Data Protection (files are protected while the device is locked), but the store itself is not separately whole-disk-encrypted by the app. Specific sensitive fields get their own encryption — that's §4.

4 · Encryption at rest

On the Apple apps, the most sensitive values get a second lock on top of on-device storage, using Apple's CryptoKit.

  • Algorithm: AES-GCM — authenticated encryption, so data is both unreadable and tamper-evident.
  • Key: a 256-bit key generated on the device the first time it's needed.
  • Key storage: the iOS/macOS Keychain, marked WhenUnlockedThisDeviceOnly — it can't be read while the device is locked, and it never leaves the device or rides along in an iCloud backup.
  • What's encrypted: full Social Security Numbers and contractor TINs (see §9).
How a full SSN is encrypted at rest A full Social Security Number flows into an AES-GCM padlock node and comes out as scrambled, unreadable ciphertext. A key feeds the padlock from above, labelled: 256-bit key, stored in the iOS or macOS Keychain, device-only, and never included in an iCloud backup. 256-bit key iOS / macOS Keychain · device-only never in an iCloud backup Full SSN 123-45-6789 AES-GCM authenticated encryption Stored as 9f·A2·7e·c4·0b…
The punchline is the key: it's generated on the device and never leaves it — so an encrypted SSN can't be decrypted anywhere else, not even from a stolen backup.
Why this scheme?

A device-only key means an encrypted SSN can't be decrypted anywhere but the device that created it — even a stolen backup is useless. AES-GCM detects tampering, not just hides data.

What we deliberately don't overstate: the key is a standard software Keychain key bound to the device — it is not a Secure Enclave key, and it isn't separately gated behind Face ID at the moment it's used. Field-level encryption covers full SSNs/TINs specifically; other on-device records rely on local-only storage plus OS Data Protection.

5 · Sign-in & app lock

The two tiers authenticate differently, on purpose.

Apple apps — biometric app lock, no account required

There's no login to use the Apple apps. Because your data is on-device, the meaningful question isn't "who are you" but "is this the device owner." An optional toggle enables a Face ID / Touch ID lock (with device-passcode fallback) that gates the whole app when you switch away from it.

Why no account?

An account only exists to authenticate you to a server. There is no server in the on-device tier, so requiring one would add a credential to steal and a database to breach for zero benefit. The lock is convenience security layered on top of the device's own protection — and it's optional, off until you turn it on.

Web app — a real account, scoped by token

The web app uses Supabase Auth. You can sign in by magic link (passwordless, recommended), set a password, or start as an anonymous guest. On first sign-in the app provisions your private workspace and your session token is stamped with the claims that scope every database query to you (see §8).

Honest note on guest mode: an anonymous web session lives only in that browser — there's no on-device copy like the native apps have. Add an email or password to make it durable across devices; until then, treat guest mode as a quick try-out.

6 · Sync: CloudKit vs. Supabase

Sync is off by default. When you turn it on, Businessily uses a pluggable design — a single internal "sync provider" interface with interchangeable backends — so the app picks the most private option for your situation without the rest of the app caring how it works.

Local only

The default. Nothing syncs; everything stays on the device.

iCloud (CloudKit)

For all-Apple teams. Syncs through your own iCloud — no Businessily account.

Private cloud (Supabase)

For mixed fleets — Android, web, Windows alongside Apple.

You choose once during setup (a quick "what devices does your team use?" question), and it's stored on your business profile. SwiftData stays the source of truth, so you can switch backends later without losing local data. Conflicts use last-write-wins by timestamp, and deletions propagate via tombstone records so a delete on one device removes the row everywhere.

Why a pluggable provider, and why two clouds?

CloudKit is free, private, and account-free — perfect for solo and all-Apple users, because data rides your iCloud, not our server. But CloudKit is Apple-only, so cross-platform teams need a neutral backend; that's Supabase (Postgres + row-level security). Abstracting both behind one interface means switching is a configuration change, not a rewrite — and the app never hard-codes a single vendor.

Last-write-wins is simple and predictable, but it means a simultaneous edit of the same record on two devices keeps the later one and drops the other. It's not a field-by-field merge.

7 · Apple sync, in detail (CloudKit)

When an all-Apple team turns on sync, Businessily uses CloudKit inside your own iCloud container. The design has two safety properties worth spelling out.

A strict allowlist — financials can't sync, by construction

Only a short, fixed list of record types is ever allowed to leave the device: employees, clock entries, appointments, and the bookkeeping plumbing that supports a shared team (pending changes, activity log, roles). Everything financial is on a hard blocklist — invoices, income, expenses, clients, contractors, mileage, job costs and more. The block is enforced at the point of writing to the cloud (an attempt throws an error), again when pushing, and again on a worker's device when receiving — three layers of "no."

Why a blocklist and no data-mapper?

Beyond the explicit block, financial records simply have no code that knows how to turn them into a cloud record — so there's no path to sync them even by accident. The privacy guarantee is structural: the capability doesn't exist.

Per-worker zones — a worker sees only their own data

The business owner's full dataset sits in a private iCloud zone that is never shared. Each worker you add gets their own separate zone, shared to just that one person. A worker's device can physically only receive their own time punches and their assigned schedule — not the roster, not pay rates, not anyone else's hours.

Per-worker iCloud zones The owner's private zone holds the full dataset and is never shared. To the right, each worker has a separate zone shared to only that one person, holding just that worker's own time punches and assigned schedule. Nothing connects one worker's zone to another's. Owner's private zone full dataset · never shared Each worker — a separate shared zone Worker A only A's punches + A's schedule Worker B only B's punches + B's schedule Worker C only C's punches + C's schedule No worker has a zone that contains anyone else's data — the boundary is the storage itself.
An iCloud share grants a whole zone, so giving each worker a dedicated zone makes the boundary the storage itself — no query to get wrong, no field to forget.
Why partition by zone instead of filtering fields?

An iCloud share grants access to an entire zone. By giving each worker a dedicated zone, the boundary is the storage itself — there's no query to get wrong and no field to forget to strip. A worker literally has no zone that contains other people's data.

8 · The private cloud (Supabase + row-level security)

The web app — and, soon, Android — stores data in Supabase (managed PostgreSQL). The whole security model rests on one Postgres feature done rigorously: Row-Level Security (RLS).

How isolation works

  • Default-deny on every table. RLS is enabled on all data tables, so the database returns nothing unless a policy explicitly allows a row.
  • Your token carries your scope. When you sign in, your session token is stamped (by a server-side hook) with your workspace and role. Every query Postgres runs evaluates that token, row by row.
  • Owner vs. worker. Owner policies allow a row only when it belongs to your workspace and you're the owner. A worker can read and write only their own clock entries and their own assigned shifts — and the rules pin their identity so they can't even clock in as someone else. Workers get zero rows from the roster, roles, pay rates and audit log.
  • File attachments live in a private storage bucket, namespaced per workspace, reachable only through short-lived signed links (one-hour expiry).
How row-level security isolates each account A session token carrying your workspace and role passes into a Postgres row-by-row gate. The gate returns results: the owner gets a checkmark for their own rows, a worker gets only their own clock rows, and another tenant gets zero rows and is blocked. The database is default-deny: it returns nothing that isn't yours. Session token workspace + role stamped on sign-in Postgres row-by-row gate RLS · default-deny Owner ✓ every row in your workspace Worker only their own clock rows Other tenant 0 rows · blocked Default-deny: the database returns nothing that isn't yours.
The token is the scope, checked in the database on every single row — the cloud equivalent of CloudKit's per-worker zones.
Why is RLS the whole boundary?

The web app ships its database key in the browser — that's normal and safe, because that key grants no data access on its own. Postgres refuses every row that isn't yours, regardless of what the client asks for. The token is the scope, checked in the database on every single row — the cloud equivalent of CloudKit's per-worker zones. We back this with an automated adversarial test that asserts a worker sees their own one record, zero of everyone else's, and is rejected when trying to act as another person.

Honest: this tier is not end-to-end encrypted and not zero-knowledge. Your data is private to your account and encrypted in transit (TLS) and at rest, but it is stored in a form the service can process — so the infrastructure operator, technically, can read it. If you want strictly on-device storage, use the iPhone/Mac apps. See §15.

Android's local database — SQLCipher-encrypted

The Android app keeps a working copy of your data in a local Room database that is SQLCipher-encrypted with AES-256, using a key held in the Android Keystore. OS-level app backup is disabled, so the database isn't copied off the device by an automatic system backup. Cross-device sync still runs through the same Supabase private-cloud tier described above.

9 · Tax IDs: SSN, TIN & EIN

This is where "architecture beats policy" is most concrete.

Other people's SSNs and TINs — masked everywhere, full value never in the cloud

When you record a contractor's or employee's tax ID, Businessily keeps only the last four digits for display and for 1099/W-2 logic. On the Apple apps, the full nine-digit value is stored AES-GCM-encrypted on the device and is only ever decrypted to auto-fill a tax form — never shown on screen. In the cloud, there is no column for the full value at all.

Why "no column" beats "we promise not to store it"

A promise fails open: one careless insert, one mis-scoped query, one bug, and the secret is there to leak. An absent column fails closed: there is physically nothing to read, write, breach or subpoena. The database can't hand over a full SSN it was never built to hold.

The honest exception: your own EIN

Your own business's Employer Identification Number is stored in full. An EIN is a public-facing payer ID — it prints on the invoices and 1099s you issue and on the W-9s you hand to clients — so it isn't a secret in the way someone else's SSN is. We'd rather call this out plainly than let "we never store tax IDs" quietly overstate the truth: we never store a full SSN or a contractor's full TIN — only the masked last-4. Your own EIN is stored so your forms can print correctly.

10 · Bank data & statement import (no Plaid)

Businessily does not connect to your bank and uses no login aggregator like Plaid. Instead, you export a statement from your bank and import the file — and the work happens on your side.

  • The file (CSV, OFX or QFX) is read and parsed in your browser / on your device — it isn't uploaded anywhere to be processed.
  • Transactions are categorized on-device with a keyword model, income vs. expense detected from the sign of each amount.
  • Likely duplicates are flagged by matching amount and date against what you've already recorded.
  • Everything lands in a review queue you can edit. Only the rows you approve are ever saved — the raw statement never leaves your device until you say so.
Statement import without Plaid Top lane, struck through: the thing we do not do — your bank to Plaid to an app that keeps your login. Bottom lane, the way it actually works: you export a file, it is parsed on your device, lands in a review queue, and only the rows you approve are saved. No bank login and no live connection to your money. What we DON'T do Your bank username + password Plaid aggregator holds access App keeps your login standing read access What actually happens You export CSV · OFX · QFX Parsed on your device never uploaded Review queue you edit every row Only approved rows are saved No bank login. No live connection to your money.
The raw statement never leaves your device until you approve specific rows — there's no aggregator and no standing access to your accounts.
Why no bank login?

Aggregators ask you to hand over your banking credentials to a third party and grant standing read access to your accounts. Importing a file you exported yourself means Businessily never sees your bank login and never holds a live connection to your money. It's a little more manual, and far more private.

11 · Payments (Stripe Connect)

Accepting card payments is optional and off by default. When you enable it, Businessily uses Stripe Connect to link your own Stripe account.

  • Customers pay into your Stripe balance directly. Money never flows through a Businessily-owned account — we hold no funds.
  • Card details are entered on Stripe-hosted checkout pages — card data never touches Businessily.
  • Your Stripe secret keys are never in the app or the browser. The app only ever holds your connected-account ID and a "connected" flag; all Stripe API calls happen server-side (see §12).
How card payments flow with Stripe Connect A customer's card goes to Stripe-hosted checkout, and the money lands directly in your own Stripe balance. Businessily sits off to the side, connected only by a thin link, holding no funds, never seeing card data, and holding no secret keys. Customer enters card Stripe-hosted checkout · card data here YOUR Stripe balance — paid directly Businessily — off to the side holds no funds · never sees card data · no secret keys
Money flows straight to your Stripe balance — Businessily just generates the link, so it never custodies funds or touches card data.
Why Connect?

Routing payments straight to your own Stripe means Businessily isn't a money transmitter, never custodies your funds, and never stores your card data or Stripe credentials. You keep the relationship with Stripe; we just generate the link.

12 · Secrets & the server surface

The web app is a static site that talks directly to your database. There's almost no "backend" — and what little server code exists is exactly the code that must hold a secret.

  • In the browser: only a public publishable database key. It identifies the project and grants no data access on its own — RLS does the gating. We verified the shipped bundle contains no secret keys.
  • On the server (edge functions): the handful of operations that need a secret — starting a Stripe Connect or Google Calendar OAuth flow, creating a hosted checkout, syncing calendars, accepting public booking/W-9 submissions. The Stripe secret key, the Google client secret and webhook secrets live in the server-side secret store, never shipped to clients.
  • Everything else — all your CRUD, statement parsing, categorization, PDF generation, reports and payroll math — runs in your browser.
The server surface is tiny The browser holds only a publishable key that grants no data access. A small edge-functions box, locked, exists only where a third-party secret is unavoidable. The browser talks directly to your database, which is gated by row-level security. Almost no backend means a tiny attack surface. Browser publishable key only grants no data access all CRUD, parsing, PDFs, reports → run in your browser Edge functions (tiny) only where a third-party secret is unavoidable Your database RLS gates every row
Almost no backend = a tiny attack surface. The publishable key in the browser opens no doors; RLS does the gating, and the only server code is what must hold a secret.
Why keep the server tiny?

Anything reachable in client code is public by definition, so secrets can only live server-side — and the smaller the server, the smaller the attack surface and the less there is to patch, scale or breach. Server code exists only where a long-lived third-party credential makes it unavoidable.

13 · No tracking, no ads, no data sale

There is no analytics SDK, no ad network, no crash-tracking service, no third-party trackers anywhere in Businessily — on any platform. We checked the codebase for the usual suspects; the only matches are the lines in our own UI promising their absence.

  • The Apple apps link only Apple's own frameworks plus the official Supabase client (used solely against your own workspace).
  • On-device intelligence — receipt OCR, voice logging, categorization — uses Apple's Vision, Speech and on-device models, so even those never call out to a server.
  • On-device Apple Intelligence (iOS / macOS 26). The optional AI features — "Ask your books", drafting text, smart categorization — run on Apple's on-device Foundation Models, entirely on the device. Nothing is sent to a cloud model to perform the AI, which is a real privacy difference from assistants that ship your data to a server. The web and Android apps don't include on-device AI rather than route your books to an outside model.
  • The in-app "Your data" panel is wired to the same blocklist the sync engine enforces, with a build-time check that warns us if the marketing claim ever drifts from the code.
Why none of it?

The product is free because it's cheap to run, not because you're the product. There's no ad or data business to feed, so there's no reason to collect anything — and a lot of reasons not to.

14 · App-level hardening

Beyond where your data lives, the apps add a layer of practical, device-side defenses. These are live today unless noted otherwise.

  • Biometric app lock. An optional lock gates the whole app behind Face ID, Touch ID, an Android fingerprint, or the device passcode when you switch away and come back.
  • Jailbreak / root detection. On iOS and Android, the app checks for common signs that the device has been jailbroken or rooted and warns you if it finds them. It's a heads-up, not a wall — it informs rather than blocks.
  • Encrypted local backups. The "back up everything" export is encrypted with a key derived from your passphrase using PBKDF2 (600,000 iterations) and AES-GCM, so a backup file is useless without your passphrase.
  • hCaptcha bot protection. Sign-up and sign-in are protected by hCaptcha to keep automated abuse off the auth surface.
  • Security headers & CSP. The web app ships a Content-Security-Policy and a standard set of hardening response headers to reduce injection and clickjacking risk.
In progress, not live: device attestation — Apple's App Attest and Android's Play Integrity — is being built to let the server gain extra confidence that requests come from a genuine, unmodified app. It is a scaffold and not yet active, so we don't claim the app validates its integrity through attestation today. We'll update this page when it ships.
The honest part

15 · What we don't claim yet

A security page that only lists strengths is marketing. Here's the candid version — the things we want you to know before you trust us.

The web tier isn't end-to-end encrypted.

It's private to your account and encrypted in transit and at rest, but the service can process your records to sync them. For zero-knowledge, on-device storage, use the Apple apps.

No SOC 2 or ISO 27001 yet.

We're early and bootstrapped. We don't have formal third-party security audits or certifications. If your business requires one today, an incumbent may suit you better — and that's okay.

Encryption is scoped, not blanket.

On the Apple apps we field-encrypt full SSNs/TINs with a device-only Keychain key (not the Secure Enclave). Other records rely on on-device-only storage plus OS protection — strong, but not the same as encrypting every field.

Your own EIN is stored in full.

It's a public payer ID that has to print on your forms — but if "no tax IDs are stored" is what you assumed, now you know the precise truth (see §9).

We'd rather you trust us because the architecture holds up to reading — not because we hid the edges. Questions about any of this? Email a real person.

16 · Reporting a vulnerability (responsible disclosure)

Found a security issue? We want to hear from you — and we'll work with you to fix it, not come after you.

How to report

Email honorius@neogy.dev with the affected surface (web, iOS, iPadOS, macOS or Android), a clear description, and reproduction steps. PGP available on request. Please give us a reasonable window to remediate before any public disclosure.

Safe harbor

If you make a good-faith effort to follow this policy during your research, we consider your testing authorized: we won't pursue or support legal action against you, and we'll work with you to understand and resolve the issue quickly. This does not authorize actions that harm users or access data that isn't yours.

Scope & ground rules

  • In scope: businessily.com, businessily.app, and the Businessily native apps.
  • Out of scope: third-party services (Supabase, Cloudflare, Stripe, hCaptcha) — report those to the respective vendor; denial-of-service / volumetric testing; social engineering; physical attacks.
  • Test only against your own account and data — never access, modify or exfiltrate anyone else's. Stop and report as soon as you've confirmed an issue.

What to expect

We aim to acknowledge reports within a few business days and to keep you posted through remediation. We don't run a paid bug-bounty program yet, but we're genuinely grateful for responsible disclosure and happy to credit you.

Machine-readable details for researchers and scanners live at /.well-known/security.txt (per RFC 9116).

Private by design — and free.

Start in your browser today, or read the rest of the story.