Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Building FoxyInvoice — Chapter 4: Multi-tenancy & data security — the vault

This series is written in the open, from a real production system. This chapter is the vault: identity, isolation, authorization, and the tests that prove it holds. [All chapters and diagrams live in the public repo.] M

This series is written in the open, from a real production system. This chapter is the vault: identity, isolation, authorization, and the tests that prove it holds. [All chapters and diagrams live in the public repo.]

Multi-tenant means many unrelated companies share one running system and
one database, each seeing only its own data. Get it right and hosting
stays cheap forever. Get it wrong and Business A reads Business B's
invoices — for a billing product, that's the ballgame. This chapter is
how FoxyInvoice's isolation works, and — more important — how it's
proven every time the tests run.

The threat model, honestly

Solo developers imagine hackers. The realistic threat is your own
future self at 2 a.m. writing a query that forgets the tenant filter.

Every mechanism below exists to make that mistake impossible to write,
or impossible to ship. Security here is less about firewalls and more
about making the wrong code hard and the breach loud.

Identity: who are you

Three ways in, one result — a signed ticket:

  • Email + password, hashed with Argon2id (memory-hard; a leaked hash is expensive to crack). Passwords are never stored, logged, or emailed.
  • Google SSO — OAuth handshake, automatic workspace provisioning. Honesty footnote: the OAuth client still lives in an old, legacy-named cloud project that predates the product — it's the live sign-in identity for both sites; a rename is eventual.
  • Either way, login mints a short-lived JWT access token (claims: user id, tenant id, permission list) plus a rotating refresh token. The browser shows the JWT on every API call; the server verifies the signature — no session table lookup on the hot path.

Isolation: the three locks

The security model, rendered live: how a JWT’s tenantId flows into global EF Core query filters, a save interceptor that stamps every insert, and CI tests that prove zero leaks.

  1. Global query filters. The ORM (EF Core) attaches WHERE TenantId = current to every query on tenant-scoped entities — automatically. Application code writes plain db.Invoices.ToList() and cannot forget the filter, because the filter isn't in the application code. (A per-tenant model-cache key keeps the compiled filters correct when tenants interleave.)
  2. The stamp. On save, an interceptor writes the caller's tenant onto every new row. You can't insert into someone else's workspace even by trying — the framework overwrites you.
  3. The proof. Integration tests log in as two tenants, create overlapping data, and assert zero cross-visibility. Isolation that isn't tested is a vibe. These tests run in CI on every push.

The escape hatches are deliberate and rare: background jobs (reminders,
radars) use IgnoreQueryFilters() with explicit tenant handling — each
one a conscious, commented decision.

Authorization: what may you do

RBAC — roles map to permission strings (invoice:create,
tenant:manage_settings, …). The same list drives three layers: route
guards in the SPA, element-level hiding in templates, and — the one
that actually matters — HasPermission checks on the API. The UI is
courtesy; the server is law. Custom role builder for workspaces that
want finer grain.

Scoped access: the share token

For "show this invoice to my client," there's a fourth lock: a
32-byte unguessable token in the URL is the authorization — no
account for the client, scoped to one document, expiring, revocable.
Quote acceptance (Chapter 12) rides the same mechanism.

The rest of the vault checklist

  • Audit trail — an interceptor snapshots before/after JSON for changed entities: who, what, when, from which IP. Subtle bug worth stealing: if a save fails, the interceptor must drain its pending audit buffer, or the next successful save writes audit rows for changes that never committed.
  • Backups — nightly pg_dump, gzip, size-checked (a 200-byte backup is a failed backup), copied off the box (S3 + email). A backup you've never restored is a hope, not a backup.
  • No card data, ever — Stripe holds payment methods; we store ids. PCI compliance by not playing.
  • GDPR posture — "export everything I own" produces a full JSON dump (we built it as a feature and test it with it); deletion disables users immediately and hard-purges after a 30-day grace.
  • Secrets — generated by script, living only in .env files on the host, caught by a gitleaks gate if they ever try to enter the repo.

Security posture: honest about the stage we're at

Isolation, tests, backups, and no-card-data are the controls we treat
as non-negotiable at any size — the 80/20 that protects what actually
matters for a billing product. Beyond that spine, the security program
matures the same way everything else in this series does: gated by
real usage rather than built speculatively ahead of it. The next layer
— deeper account-takeover hardening, broader defense-in-depth — scales
with the stakes, and grows in the open, in this repo.

Recap. Signed identity, filters you can't forget, stamps you can't
spoof, tests that prove it, permissions enforced where it counts, and
scoped tokens for sharing. The vault is mostly removing ways to be
wrong
.

Reading this and want to poke at the vault yourself? Create a free workspace at
foxyinvoice.com, then redeem founding code
U8B4Z8S87X on the Upgrade page — 6 months of Pro, free, no card. If anything
breaks, there's a feedback button in the app. I read every one.

Next: Chapter 5 — The domain: invoices, tax, and money math.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.