The Lab

Building and Hardening My Spend Tracker

I built a personal spend tracker to answer one question quickly: where is my money actually going each month? The app ingests ASB statements, categorises transactions, tracks budgets, and runs a local AI assistant for summaries and cutback advice.

Over time it stopped being just a finance tool and became a proper systems project. Once I decided to put it on the public internet, the focus shifted from “does it work?” to “can I trust it when I am not watching it?”

Why I hardened it

There were three practical reasons:

  1. It stores sensitive financial data
  2. It is reachable from the internet
  3. It has an AI endpoint, which is basically an input surface by design

If any one of those goes wrong, best case is bad data and a broken dashboard. Worst case is account takeover or leaked transaction history. I wanted the app to fail closed, not fail open.

How the app is structured

The architecture is intentionally small:

  • Python app on localhost
  • Local encrypted JSON storage per user
  • Browser frontend with fetch-based API calls
  • Local Ollama model for categorisation and Q&A
  • Reverse tunnel + edge protection in front

No SQL database, no third-party AI API, no public internal ports. Keeping the stack boring made the security work much easier because there are fewer moving parts to reason about.

The hardening work, phase by phase

Phase 1: Authentication and sessions

The first job was to stop treating login as a one-time gate and treat it as a full lifecycle:

  • Session token creation and expiry
  • Explicit logout invalidation
  • Session checks on every protected API call
  • Lockout/throttle logic on repeated failed logins

I also cleaned up edge cases that cause “stuck on checking password” behavior, which usually came from frontend retries + slow backend responses + stale session state.

Phase 2: CSRF and request trust

Any authenticated POST route was updated to require a CSRF token, not just a session marker. That matters because session presence alone does not prove request intent.

In practice this meant:

  • Generating CSRF values server-side
  • Storing them in session context
  • Requiring them on sensitive API operations
  • Returning clear errors when checks fail

This removed an entire class of cross-site request attacks for state-changing actions.

Phase 3: Data encryption at rest

This was the biggest change.

Each user’s financial files are encrypted at rest, so raw JSON on disk is not readable without the encryption key material. The app can decrypt in-process, but a direct file read on the server is not useful by itself.

The important part here was key hygiene:

  • Stable key loading from environment
  • Preventing accidental key rotation without migration
  • Defensive error handling for invalid tokens/signatures
  • Clear operator feedback when decryption fails

Most of the “InvalidToken” pain came from mismatched keys between old and new runs, so the process now assumes key continuity unless a deliberate migration is performed.

Phase 4: Input validation and traversal safety

This app ingests statements and handles user-driven operations, so input controls matter.

I tightened:

  • Upload file type and parsing validation
  • Path handling and normalization for any file read/write path
  • Strict allow-lists for mutable categories/accounts where possible
  • Numeric parsing guards to prevent NaN chains in overview calculations

The NaN issues in the dashboard were not cosmetic. They indicated weak null/number boundaries between backend payloads and frontend math. Fixing that improved both correctness and security posture.

Phase 5: Edge and transport protections

The app process only listens locally. Public traffic is handled at the edge, where controls are easier to manage and update.

The baseline policy:

  • Managed WAF rules enabled
  • Login/API rate limits enabled
  • Bot mitigation enabled
  • TLS set to strict end-to-end mode

This is not a replacement for application security. It is a pressure reducer that catches noisy traffic before it reaches the app.

Phase 6: Frontend security posture

I reduced inline script/style patterns and moved toward CSP-friendly structure. Strict CSP is easier to enforce when the frontend avoids inline event handlers and keeps JS behavior in one place.

That work is still iterative, but each pass tightens what the browser is allowed to execute.

What changed in day-to-day behavior

Hardening was not just security theater. It changed how the app feels to use and operate:

  • Authentication issues fail with clear messages
  • Bad or missing encryption keys fail fast instead of corrupting data
  • Overview metrics are stable and no longer spike from transfer logic mistakes
  • AI responses are less random because deterministic paths handle common cases first
  • Runtime process is managed as a service rather than a manual terminal command

What I used as references

I did not invent most of this. I pulled from standard guidance and then adapted it to this stack.

Lessons that mattered

  • Transfer handling is a security and correctness issue, not just a finance logic issue.
  • Small apps still need real session and CSRF discipline.
  • Encryption at rest is only as good as key lifecycle management.
  • The edge can reduce noise, but app-layer checks still do the real protection.
  • You can build a secure personal tool without turning it into enterprise complexity.

That is really the whole point of this build. Keep it small, but take the security work seriously enough that it is safe to run in public.