Security
What actually protects the financial data in this application — and, just as importantly, which security claims cannot honestly be made about it.
Version 2026-08-06.2 · Last updated 1 August 2026
Authentication
Access requires two factors at every sign-in. There is no “remember this device” option, and no development bypass in any environment.
| Control | Implementation |
|---|---|
| First factor | Password, hashed with scrypt (N=2¹⁷, r=8, p=1) and a per-account salt. The password itself is never stored, logged, or recoverable. |
| Second factor | A six-digit one-time code emailed to the owner's address. Stored only as an HMAC-SHA-256 digest, valid for 10 minutes, usable once, and dead after five wrong guesses. |
| Re-verification | A fresh code is required again — within the last 10 minutes — before connecting or disconnecting a bank, deleting data, changing the password, or revoking sessions. |
| Brute-force resistance | Lockout after 8 consecutive failures for 15 minutes, plus database-backed rate limits per address and per IP that are shared across all server instances. |
| Enumeration resistance | Failed sign-ins return one message and take the same time whether or not the address exists. |
| Recovery | Ten single-use recovery codes, issued once at setup and replaceable from the Security page. They substitute for the second factor only — the password is still required. |
Sessions
Sessions are server-side. The cookie carries a random 256-bit token and nothing else — no claims, no signed state — and every fact about a session is read from the database on each request. That is what makes revocation immediate: there is no window in which a signed token stays valid because it has not expired yet.
- Only an HMAC of the token is stored, keyed by a secret held outside the database
- Cookies are HTTP-only,
SameSite=Lax, and Secure on every deployment - The token is rotated when the second factor completes, closing session fixation
- Idle timeout 720 minutes; hard ceiling 720 hours that no amount of activity extends
- All sessions are listed in-app and can be revoked individually or in bulk
- Changing the password revokes every other session
Encryption
- In transit. HTTPS with TLS 1.2 or above everywhere, terminated by Vercel. HSTS is sent with a two-year max-age including subdomains. Connections to MongoDB Atlas, Plaid, OpenAI and Resend are all TLS.
- At rest. MongoDB Atlas encrypts storage with AES-256. On top of that, Plaid access tokens are encrypted by the application itself with AES-256-GCM before they are written, under a key held only in the environment — so a database dump alone does not yield a usable bank token.
- One-way where possible. Passwords, session tokens, one-time codes and recovery codes are all stored as one-way values. None of them can be read back out of the database.
- Key separation. The authentication key and the token-encryption key are different variables with different lifecycles, and test and production use different values for both. Rotating the authentication key signs everyone out; it does not make stored bank tokens undecryptable.
Application controls
- Every financial route is defined through a wrapper that authenticates first — a route written without one has no user to work with and does not compile
- CSRF: an Origin check plus a per-session double-submit token on every state-changing request
- A Content-Security-Policy with a per-request nonce and
strict-dynamicfor scripts,frame-ancestors 'none', and no third-party origins other than Plaid's Link SDK X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy: no-referrer, a restrictive Permissions-Policy, and cross-origin isolation headers- All API responses are
no-store: financial data is never cached - Structured logs redact any field whose name resembles a secret; the security log records reason codes, never payloads
- Input is validated with schemas at the boundary; database access is through a typed ODM with no string-concatenated queries
Data separation
Test and production data live in separately named databases, and the name is enforced in code rather than by convention. Seed, reset and demo-removal tooling refuses a production database outright — with no override flag — and refuses any remote database whose name carries no environment marker. Test suites can only reach an ephemeral local database.
Vulnerability management
- Every push and pull request runs formatting, linting, strict type checking, the full test suite, a production build, a dependency audit and a secret scan
- CodeQL static analysis runs on every push and weekly
- Dependabot proposes dependency and workflow updates weekly
- Patch cadence: critical within 48 hours, high within 7 days, moderate within 30 days, low at the next routine update
- A dependency lockfile pins every transitive version
No penetration test has been performed. No independent security assessment of any kind has been performed.
What is deliberately not claimed
Each of these is a real limitation, stated rather than left to be inferred:
- The multi-factor authentication is not phishing-resistant. A code you can read and retype is a code you could be persuaded to retype elsewhere, and anyone who controls the mailbox controls this factor. A passkey or hardware key would be phishing-resistant; this is not one.
- No SOC 2, ISO 27001, or PCI attestation. None has been sought and none is held.
- No penetration test or independent audit.
- The database is not restricted to a fixed IP range. Vercel and Railway both use dynamic outbound addresses on their standard plans, so an allowlist would be either ineffective or so broad as to be meaningless. Access is controlled instead by TLS, a database user scoped to a single database with only the privileges it needs, and a long random password held in platform secret storage. Saying otherwise would be a false claim, so it is not made.
- No 24/7 monitoring or on-call rotation. One operator, best effort.
- No formal disaster-recovery test beyond what is written in the recovery procedure.
Reporting a vulnerability
Security reports are welcome and will be acknowledged. Contact ravino.juwono@gmail.com. Please do not test against the live instance — it holds one person's real financial records.