Security
PennyPlan handles financial behaviour data for people on low incomes. Security is a launch requirement, not polish. This page states what is enforced in code today and what is still to do, so nobody assumes a protection exists that does not.
Enforced today (API)
| Protection | How | Proven by |
|---|---|---|
| Every route needs a valid token | A global guard. The only exceptions are routes explicitly marked @Public() (health checks) | Tests for missing, garbage and expired tokens |
| Tokens are checked properly | RS256 only, verified against the Auth0 tenant’s published keys, with issuer and audience pinned | Tests for forged keys, wrong issuer, wrong audience, alg: none and HS256 |
| Fail closed | If Auth0 is not configured, every protected route returns 401. Production refuses to start without it | Test and startup validation |
| Identity comes only from the token | The acting user is looked up from the token’s subject. User ids in bodies, query strings or headers are ignored | Test sending spoofed ids |
| Roles come from our database | A token claiming ADMIN gets nothing. Only the database role counts | Test with a forged role claim |
| No account takeover on sign-in | An account is created, or an existing v1 account linked, only if Auth0 reports the email as verified. An unverified email gets a 403 and no account | Tests with an unverified email, and with the same person after verifying |
| Only safe fields are editable | PATCH /me rejects any field not on the allow-list (role, email, auth0Sub, id) | Test for each |
| Passwords and tokens never leave the API | The profile response is a fixed shape that omits them | Test |
| Data access is scoped per user | Services use PrismaService.forUser(userId), which adds the user to every query and refuses any model not explicitly listed | Unit tests |
| Security headers | nosniff, frame denial, HSTS, a strict content policy, and no X-Powered-By | Test |
| Tight CORS | Only the configured web origin; no credentials, since we use bearer tokens, not cookies | Tests |
| Errors reveal nothing | One error shape; server faults return a generic message and the details stay in the logs. Oversized and malformed bodies are 4xx, never 500 | Tests |
| Dependencies checked | pnpm audit --prod is clean on the API | Run on every upgrade |
| One person can never reach another’s money data | Every ledger, budget, capture, notification and consent query is confined to the signed-in user. Categories (shared built-ins plus personal ones) spell out ownership by hand | Real-database tests with several users, mutation-tested |
| Ids cannot be probed | An entry’s id is only unique per person. Referring to another person’s category, account or entry gives the same answer as one that does not exist | Real-database tests |
| Entries cannot be duplicated or lost on retry | Client-generated ids; sync is idempotent; a late old edit cannot resurrect a deleted entry | Real-database tests, including simultaneous requests |
| Push endpoints cannot be used to attack our servers (SSRF) | Only https addresses on real push services (Google, Mozilla, Apple, Microsoft) are accepted. Private addresses, localhost, the cloud metadata address, lookalike hosts and odd ports are refused | Unit and real-database tests |
| WhatsApp is opt-in, enforced by the API | It cannot be chosen without a recorded consent, and withdrawing consent removes it everywhere | Real-database tests |
| A reminder is sent at most once | A unique key per person, type, channel and local day, so a retried job or two racing workers cannot double-send | Test with ten simultaneous attempts |
| Consent is a complete record | Append-only history, with the version of the notice seen. Nothing is agreed to by default | Real-database tests |
| Your data can be exported safely | The spreadsheet export neutralises formulas (=, +, -, @) hidden in notes, the download is never cached, and the export never contains passwords, tokens or the login provider’s id | Unit and real-database tests |
| Your data can be deleted completely | One all-or-nothing deletion of everything, including the older v1 tables. Only a minimal record survives, so the login itself can be removed | Real-database test that fills every table, deletes, and checks each one |
| Free text is treated as data | Typed phrases are parameterised and stored inertly; hostile input is tested | Real-database test |
Not done yet
These are planned (see the implementation plan) and must not be assumed:
| Gap | Planned |
|---|---|
| Rate limiting per user and per IP | Milestone 12. On a serverless host it needs a shared store, so it will use the same Redis or database as the queue |
| Audit log for admin and org-admin actions | Milestone 12 |
| Field-level encryption of notes, counterparties and the text people type into capture | Milestone 12. Until then these are stored as plain text in the database |
| Removing the login at Auth0 on account deletion | Needs a machine-to-machine Auth0 credential. Until then the data is removed but the sign-in survives, and signing in again creates a new empty account |
| Database row-level security as a second layer under the user scoping | To be decided |
| Webhook signature checks (WhatsApp, payments) | Milestones 7 and 14 |
| PII stripping before any AI call | Milestone 8 |
| Secrets in a managed secrets store | Before launch. For now, secrets live in the host’s environment settings and never in the repository |
| Web app sign-in with sealed cookies, CSRF protection and a strict content policy | Decided (ADR 0005); not built yet |
| The web app’s dependencies | Next.js was upgraded to a patched version. Two findings remain in Tailwind’s build tooling, one with no patched release |
Dependency hygiene
Run pnpm audit --prod in every repository when you upgrade anything. Where a fix is only available through
a transitive package, use a pnpm override with a comment saying why, and remove it once the parent
package ships the fix.
The API’s overrides cover Prisma’s command-line tooling (used at build time, never loaded by the server) and one package under Swagger.
Reporting a problem
Treat anything that exposes one user’s data to another, or lets someone act as someone else, as the highest severity. Stop, tell the owner, and do not discuss the details in public channels.