Skip to Content
DevelopersSecurity

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)

ProtectionHowProven by
Every route needs a valid tokenA global guard. The only exceptions are routes explicitly marked @Public() (health checks)Tests for missing, garbage and expired tokens
Tokens are checked properlyRS256 only, verified against the Auth0 tenant’s published keys, with issuer and audience pinnedTests for forged keys, wrong issuer, wrong audience, alg: none and HS256
Fail closedIf Auth0 is not configured, every protected route returns 401. Production refuses to start without itTest and startup validation
Identity comes only from the tokenThe acting user is looked up from the token’s subject. User ids in bodies, query strings or headers are ignoredTest sending spoofed ids
Roles come from our databaseA token claiming ADMIN gets nothing. Only the database role countsTest with a forged role claim
No account takeover on sign-inAn 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 accountTests with an unverified email, and with the same person after verifying
Only safe fields are editablePATCH /me rejects any field not on the allow-list (role, email, auth0Sub, id)Test for each
Passwords and tokens never leave the APIThe profile response is a fixed shape that omits themTest
Data access is scoped per userServices use PrismaService.forUser(userId), which adds the user to every query and refuses any model not explicitly listedUnit tests
Security headersnosniff, frame denial, HSTS, a strict content policy, and no X-Powered-ByTest
Tight CORSOnly the configured web origin; no credentials, since we use bearer tokens, not cookiesTests
Errors reveal nothingOne error shape; server faults return a generic message and the details stay in the logs. Oversized and malformed bodies are 4xx, never 500Tests
Dependencies checkedpnpm audit --prod is clean on the APIRun on every upgrade
One person can never reach another’s money dataEvery 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 handReal-database tests with several users, mutation-tested
Ids cannot be probedAn 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 existReal-database tests
Entries cannot be duplicated or lost on retryClient-generated ids; sync is idempotent; a late old edit cannot resurrect a deleted entryReal-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 refusedUnit and real-database tests
WhatsApp is opt-in, enforced by the APIIt cannot be chosen without a recorded consent, and withdrawing consent removes it everywhereReal-database tests
A reminder is sent at most onceA unique key per person, type, channel and local day, so a retried job or two racing workers cannot double-sendTest with ten simultaneous attempts
Consent is a complete recordAppend-only history, with the version of the notice seen. Nothing is agreed to by defaultReal-database tests
Your data can be exported safelyThe spreadsheet export neutralises formulas (=, +, -, @) hidden in notes, the download is never cached, and the export never contains passwords, tokens or the login provider’s idUnit and real-database tests
Your data can be deleted completelyOne all-or-nothing deletion of everything, including the older v1 tables. Only a minimal record survives, so the login itself can be removedReal-database test that fills every table, deletes, and checks each one
Free text is treated as dataTyped phrases are parameterised and stored inertly; hostile input is testedReal-database test

Not done yet

These are planned (see the implementation plan) and must not be assumed:

GapPlanned
Rate limiting per user and per IPMilestone 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 actionsMilestone 12
Field-level encryption of notes, counterparties and the text people type into captureMilestone 12. Until then these are stored as plain text in the database
Removing the login at Auth0 on account deletionNeeds 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 scopingTo be decided
Webhook signature checks (WhatsApp, payments)Milestones 7 and 14
PII stripping before any AI callMilestone 8
Secrets in a managed secrets storeBefore 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 policyDecided (ADR 0005); not built yet
The web app’s dependenciesNext.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.

Last updated on