Security overview

Your agency's data is encrypted in your browser, before it reaches us.

This page is the long version. What we encrypt, who can read it, where it lives, and what we don't store at all.

  • Personal contact details and bank routing identifiers are encrypted in your browser with AES-256-GCM. Our servers receive ciphertext for those fields.
  • 5 roles, enforced twice: once at the API, once again by Postgres row-level security.
  • Every write is logged with timestamp, user, IP, and the before/after values.
  • Application data lives on Supabase in Dublin, Ireland. EU West region.

Encryption

Personal contact details and bank routing identifiers for your models, employees, and company payment accounts get encrypted in your browser before they leave your device. The cipher is AES-256-GCM with a 12-byte IV and a 16-byte auth tag. It hides the value and detects tampering in one step. The team key is derived from your password using Argon2id and stays on your device. We never see it. There is no admin backdoor that decrypts your sensitive fields without your password.
  • Key derivation

    Argon2id, memory-hard so GPU attacks scale poorly. Runs inside a Web Worker so the main thread doesn't block.

  • Memory cost

    64 MB on desktop. Reduced on mobile to fit per-tab WebAssembly limits.

  • Fallback

    iOS WebKit and locked-down Android WebViews don't ship Argon2id. On those, PBKDF2-HMAC-SHA-256 at 600,000 iterations.

  • Cipher

    AES-256-GCM, 12-byte IV, 16-byte auth tag. A flipped bit shows up as a decryption failure, not silent corruption.

Money columns like earnings, payouts, and amounts stay in the clear so the dashboard, P&L, and reports can aggregate them. Encryption protects the personal-data columns that don't need to be summed.

Access control

Most agencies don't want their HR person reading the ledger, or their bookkeeper editing payouts. Five roles cover the common splits. The rules are checked twice. The API rejects an unauthorised request first. Then Postgres row-level security checks again at the data layer. If the API check is wrong, RLS still refuses to return rows the user shouldn't see.
  • Can access: Every module. Every record. Every setting.

    Cannot access: Nothing is restricted. That is the point of the role and why you should not have many of them.

  • Can access: All financial records across the team. Exports. Reporting tools. Journal entries.

    Cannot access: Cannot invite or remove teammates, change security settings, or reconfigure how audit logs work.

  • Can access: Imports transactions, categorises expenses, reconciles accounts.

    Cannot access: No payout initiation. No HR data. No team settings.

  • Can access: Employee and model records, contract terms, compensation schedules, approval queue for payouts.

    Cannot access: The raw financial ledger and accounting configuration are out of reach. The HR manager sees who gets paid, not the books behind the payment.

  • Can access: Their earnings summaries. Their documents. Nothing else.

    Cannot access: Cannot see another creator's row, even by guessing IDs. RLS rejects the query in Postgres before any UI logic runs.

Practical implication: a direct SQL query against the database returns the same restricted set of rows. RLS lives in Postgres, not in our application code.

Authentication

Two factors: a password plus a TOTP code from your authenticator app. Magic-link sign-in is offered on first invite. Single-use, expires after 72 hours.

Audit logs

Every change to your data writes one audit row, visible only to admins inside your own team. Retention is 365 days; admins can flag entries as reviewed but cannot quietly delete a single row.

Visible to your team only, not to us

Audit rows sit behind row-level security in Postgres, scoped to your team's tenant. Nobody at OFM Finance Hub can read the entries in your audit trail. Not support, not engineers, not the founders. A direct database query from our side returns zero rows; RLS rejects it the same way it rejects an unauthorised role.

Individual rows cannot be deleted. The only way to drop log rows is to delete the whole team account, which is itself logged.

Where your data lives

Customer application data sits on Supabase in Dublin, Ireland. EU West region, inside the European Economic Area. Operational telemetry (error stacks, CDN logs) is handled by US sub-processors and never carries customer records.
  • Hosted inside the EEA

    Customer records like accounts, ledger entries, and encrypted personal data never leave the Supabase cluster in Dublin.

  • GDPR Art. 28 processor

    We act as your data processor. The DPA is built into §14 of the Terms. No separate signature required.

  • Telemetry only · US sub-processors

    Error tracking and CDN logs use US-based services. They never carry customer records. Full list and regions on /privacy.

Sub-processors

5 external services. 2 hold application data; 3 handle telemetry only.
Sub-processors with criticality, purpose, and region.
Sub-processorRegion
SupabasePostgres database, auth, edge functionsDublin, Ireland (EU West, EEA)
StripeSubscription billing and card processingUnited States, global
VercelStatic hosting, CDN, edge runtimeUnited States
SentryError tracking, IPs anonymised where the SDK supports itUnited States
ResendTransactional email (invites, password resets, receipts)United States

Primary sub-processors hold customer application data. Operational sub-processors do not.

Browser & infrastructure

Browser-side defenses if a build dependency or third-party script is compromised.

Content Security Policy

Our CSP allows scripts from our own origins and Stripe. Inline scripts are blocked. If an npm package is compromised tomorrow and tries to inject a fetch-your-tokens snippet into the page, the browser refuses to execute it. The compromise still happens at the build layer, but it stops before it reaches your customers.

Stripe-hosted payment fields

We don't store card data. Stripe does. Card numbers, CVCs, and expiry dates are typed into an iframe served from stripe.com. The values go straight to Stripe. They never hit our backend, never land in our logs, and are not in scope if our infrastructure is breached.

HTTP security headers

Every response sends Strict-Transport-Security with a 2-year max-age and preload, so even a typed http:// URL upgrades to HTTPS. Referrer-Policy: strict-origin-when-cross-origin keeps internal paths out of third-party referer logs. The Permissions-Policy denies camera, microphone, and geolocation by default. X-Content-Type-Options: nosniff stops MIME-sniffing. frame-ancestors 'none' blocks the page from being framed, which removes a class of clickjacking attacks.

Found a security issue?

Email security@ofmfinances.com. Include a clear description and steps to reproduce; the more specific, the faster we can confirm and act. There is no formal bug-bounty programme today. Reports are read by a real engineer, and researchers who want public credit get it.

Need it in writing?

The DPA is part of our Terms of Service; security.txt is on a stable URL; coordinated disclosure is below. No sales call required to get any of these.