I track every transaction I make in a CSV: date, amount, item, place, which card paid, who it was for, a category, and two flags for mistake and outlier spending. A spreadsheet holds that data well but answers questions badly. FinDash turns it into a calm, fast dashboard I can open from anywhere, without publishing my finances to the internet.
It's fully static: a few files on Cloudflare's edge, no server, no database, no accounts. The data file that gets deployed is encrypted, and it only decrypts in the browser after I type my password. A wrong password doesn't get rejected by a check. It simply fails to decrypt.
The constraints
- Cheap and boring to run. No server to patch, no database, almost no cost. Adding data is "edit the CSV, merge".
- Private. The data is personal, so a public website was not acceptable.
The Idea
A password screen on a static site protects nothing. The data file can be downloaded and the JavaScript can be read. So in FinDash the password is never checked. It derives the key, and without the key the file is noise.
01Plaintext data
data/transactions.csv
↓ Encrypt (at build time)
pnpm encrypt
(PBKDF2-SHA256 600k → AES-256-GCM)
02Encrypted file
public/data/transactions.enc
↓ Bundle
Vite build
03Static assets
dist/
- index.html
- assets/
- data/transactions.enc
- …
↓ Deploy
Cloudflare Worker
(static assets)
04Live site
findash.mrp2003.com
↑ In the browser
Password → Web Crypto decrypt → parse → render
One crypto implementation
The same file uses the Web Crypto API, which exists in both Node 24 and browsers. The build script encrypts with it and the app decrypts with it, so there's one piece of code to trust.
No server code at all
The Worker only serves files. Security headers come from a _headers file.
Hash routing in about 20 lines
Instead of a router library. Refresh and the back button work, and the static host needs no rewrite rules.
Money in integer fils
Every total adds whole fils, 1/100 of a dirham, and converts at the end, so 0.1 + 0.2 really is 0.30.
What it shows
Lock screen
Just the wordmark and a password field. A wrong password shakes the field. The right one plays a short animation where the logo flies into the header, then the dashboard fades in.
Overview
Four cards in a grid: spend (gross, net, normal, credits, mistakes, outliers), then categories, cards and recipients. Every row has a thin bar, its vermilion fading by rank.
Trends
A monthly line chart of gross, net and normal spending, and a daily bar chart for the selected month. Both size themselves to fit the screen.
Transactions
A sortable table, 10 rows a page, with flagged rows tagged and credits marked with a plus.
One toolbar for everything
A period pill, filter pills for card, person, category, mistakes and outliers, and a search that expands from an icon. Filters carry across pages.
Light and dark themes
Remembered per browser and applied before the first paint, so there's no flash.
Design
The look went through three stages:
A functional first build
A conventional light dashboard with eight KPI cards, two full-width charts and an 11-column table. The page was over 5,000 px tall.
A first redesign
Near-black with a lime accent and a large hero number. More modern, but loud.
Sumi-e
Ink on warm paper with one vermilion accent, like a seal stamped on a scroll. Playfair Display names things, JetBrains Mono explains them. It shares its design system with this portfolio, with a dark "night sky" mode on top.
Smaller moves that added up: splitting one long page into three, merging the period, filters and search into one row, swapping the hero number for a uniform card grid so every edge lines up, and dropping percentages, since the bars already show proportion.
Security, honestly
- Reading the data by downloading files
- Blocked: only ciphertext is deployed; the readable CSV returns 404
- Bypassing the login
- Not possible: there is no check, only decryption
- Tampering with the encrypted file
- Detected: AES-GCM authentication fails
- Injected scripts stealing decrypted data
- Blocked by a strict Content Security Policy, tested by injecting a script and an outbound request
- Being embedded in another site
- Blocked:
frame-ancestors 'none'andX-Frame-Options: DENY - Showing up in search engines
- Prevented:
noindexin a meta tag and a header - Data lingering after use
- Decrypted data lives in memory only; a reload locks the app
- Offline password guessing
- Open by design, limited by password strength and 600,000 PBKDF2 iterations
The last row is the one trade-off I accepted knowingly. The encrypted file is public, so anyone can download it and guess offline, with no lockout. The password is the entire defence. Cloudflare Access could go in front later with no code changes.
A side effect I like: the strict CSP even blocks the analytics script Cloudflare injects automatically. For a page holding decrypted financial data, that's the behaviour I want.
Shipping it
Few dependencies
Four at runtime: React, React DOM, Recharts and PapaParse. I removed Tailwind once it was clear the app only used its CSS reset, replaced it with 8 lines of CSS, and proved nothing changed by diffing screenshots of every page pixel by pixel.
Deploys with no stored token
My first plan deployed from GitHub Actions, which needs a Cloudflare API token stored in GitHub. I switched to Cloudflare's own Git integration, Workers Builds, so no deploy token exists anywhere. The dashboard password lives as an encrypted build secret, so every merge re-encrypts fresh data.
Process
93 commits over 7 pull requests in two days, every change on a typed branch and checked in a real browser at desktop and mobile widths. 10 tests cover money arithmetic, CSV validation, filters, daily totals and encryption, on Node's built-in test runner.
Problems worth telling
The password prompt that ate its own input
- Problem
- The encrypt script read the password in raw terminal mode. When both entries arrived in one chunk, as they do when pasted, the first prompt swallowed the second and the script hung.
- Fix
- Buffering keystrokes past the line break for the next prompt.
A 688 px page on a 390 px phone
- Problem
- Screen-reader-only labels are positioned absolutely. Inside horizontally scrolling rows they escaped and stretched the page.
- Fix
- Anchoring them fixed it.
The CSP versus the theme script
- Problem
- The no-flash theme script was inline, which the CSP forbids.
- Fix
- Moving it to a tiny external file kept both.
Day-one outage
- Problem
- The first automatic deploy never started. Cloudflare's status page showed an open incident with Workers Builds.
- Fix
- The site stayed up on the previous deploy throughout.
What's next
- Put Cloudflare Access in front as a second lock against offline guessing.
- Split the 634 KB JavaScript bundle, mostly Recharts, so the lock screen loads lighter.
- Let more months of data make the monthly trend chart earn its space.