Security Review — Sample Report
Summary, in plain English
We had someone go through the entire application — the website, the parts of it that run behind the scenes, and the database rules that decide who's allowed to see or change what — the way an attacker would. We were looking for the classic ways an app like this gets broken into or abused: people paying for nothing, people seeing each other's private information, systems that trust the wrong thing.
We found several serious problems. The worst ones came down to a single root cause: important account information — what plan someone is paying for, whether they're an administrator, whether they'd already used their free trial — could be changed by the person who owns the account, directly, without going through the app's own screens or paying anything. That's the digital equivalent of a hotel guest being able to reprint their own room key to open the manager's office.
We fixed everything in this report except two low-priority items that were flagged but explicitly left for a later pass (called out below). Every fix was checked two ways: first by reading the code carefully, and second, wherever practical, by actually trying to break it again on a real test account against the live site. One fix looked correct on paper, passed every automated check, and still failed the second, live test — it only became solid on the second attempt. That story is below, because it's the most important lesson in this report: code review alone is not enough. You have to try to break the real thing.
Nothing in this review found evidence that any real customer's data was actually stolen or misused — this was a proactive check, not a response to an incident. But several of the problems, if left unfixed, would have let any free user upgrade themselves to a paid tier for nothing, and would have let any signed-up user read private, sensitive information (including health/mood check-ins and messages about children) belonging to every other user of the app, not just their own.
Findings
Severity follows standard practice: Critical (an attacker gets money, data, or control they should never get, with little effort), High (serious but needs slightly more effort or has a narrower blast radius), Medium (real but limited impact), Low (best-practice cleanup, not directly dangerous).
The Status column is the honest answer to “do we actually know this is fixed?” — not just “did we write a fix.”
| # | Finding | Severity | What an attacker could actually do | Fix | Status |
|---|---|---|---|---|---|
| 1 | Paid plan could be unlocked for free by editing a web address. After a payment, the app sent the browser back to a page like yoursite.com/?upgraded=diamond — and the app trusted that address literally, upgrading the account on the spot with no check that a payment had happened. | Critical | Any signed-up user could type that address themselves — no payment, no special tools, no technical skill beyond editing a URL — and get the most expensive tier for free, permanently. | The app no longer sets the plan from that address at all. The real payment provider (Stripe) confirms the payment on our own server first, and the plan is only ever set from that server-verified confirmation. The web address is now just used to show a “please wait” screen while it double-checks with the server. | Verified in code, and confirmed no plan-setting from the browser was left in that flow. Not separately re-tested live in isolation from finding #2 below, since the same underlying fix (see #2) closed the whole family of “set your own plan” bugs and was live-tested there. |
| 2 | A user could directly rewrite their own account record to grant themselves any plan, or attempt to make themselves an administrator. (See the full story below — this is the most important finding in the report.) | Critical | Using nothing more than the browser's own built-in developer tools (no hacking software required), any signed-up free user could send a request that set their own account to the top paid tier, instantly, for free — bypassing the entire payment system. The same technique was tested against the “administrator” designation. | Round 1 added a rule so a user could only edit their own account record, not someone else's. Round 2 (after live testing showed round 1 wasn't enough) moved the sensitive fields — plan, admin-status, trial-status — off the everyday account record entirely, into a separate internal record that literally no user, including the account's own owner, is allowed to read or write. Only the app's own trusted server-side processes can touch it. | Verified live, on a real test account, after both rounds — see the full story below. |
| 3 | Almost every type of stored data had no “this is mine, not yours” rule at the database level. Student profiles, daily mood/energy check-ins, homework assignments, private messages between parents and coaches, and recruiting contact lists were all missing a basic protection that says “you may only read and write your own records.” Two coach/parent-facing screens were actually written in a way that only makes sense if that protection was missing — they pulled everyone's data down to the browser and then picked out the relevant rows there, in full view of the user's own device. | Critical | If the underlying protection was as permissive as the app's own code implied, any signed-up user — including a free account with no special access — could pull up private mood and stress data, full student profiles, and private parent-coach messages for every other person using the app, not just their own family or team. This is especially serious because much of this data concerns minors. | Added an explicit “your data only” rule to every one of those data types. The two screens that legitimately need to look at another person's data (a coach viewing their own roster, a parent viewing their own linked child) were rewritten so that check happens on our own trusted server, which first confirms the coach/parent relationship is real before releasing anything — instead of the browser being trusted to only ask for the right thing. | Verified in code and confirmed the app still builds and runs correctly. Not yet tested live with two separate real accounts — that's the test that would prove one user genuinely cannot see another's data, and it hasn't been run yet. |
| 4 | Any user could send a fake system notification to any other user's inbox, or silence someone else's real alerts. The in-app notification system had no rule limiting who could create a notification “for” another user, or edit/delete someone else's. | High | An attacker could plant a convincing fake alert (for example, a fake “your payment failed, click here to fix it” message) into a stranger's notification feed — a realistic phishing setup — or quietly mark someone else's real warnings as read to hide them. | Added a rule so a notification can only be created, read, updated, or deleted by the person it belongs to. | Verified in code only. Not yet tested live. |
| 5 | Two background jobs (automated tasks the app runs on a schedule) had no login check at all. One sends daily reminder notifications to every user; the other sends a new user's welcome emails. Both were reachable directly over the internet with no proof of who was asking. | High | Anyone who found the web address for these background jobs could trigger them repeatedly — spamming every user with reminder notifications, or sending a specific person's welcome emails on demand by guessing/knowing their account id. | Both jobs now require either a secret password known only to our own systems, or a logged-in administrator account — never an anonymous request. | Fix verified in code. Open question, not yet resolved: we could not confirm whether the platform's own “run this job on a schedule” feature is able to supply that secret password automatically. If it can't, the scheduled jobs may need to be reconnected a different way — this needs to be checked on the live schedule the next time it's due to run. |
| 6 | The payment-confirmation receiver would accept an unsigned, unverified message as if it were a real payment, if a certain security setting happened to be missing. Normally every message from the payment provider is digitally signed and checked; the code had a fallback path that skipped that check instead of refusing the request. | High | If that one setting were ever accidentally left blank (during setup, a migration, or human error), anyone could send a fake “payment succeeded” message directly to the server and grant themselves any plan, bypassing the payment provider entirely. | The receiver now refuses every request outright if that setting isn't present, instead of falling back to trusting an unverified message. | Fix verified in code. Not tested against a real payment-provider event, since that requires either a real transaction or careful use of the provider's test tools. |
| 7 | A “referral reward” field had the exact same self-editing problem as the plan field (finding #2), and wasn't caught until after the plan fix shipped. The referral program grants a temporary bonus plan upgrade, stored right alongside the same account record. | Critical | Same as finding #2: a user could directly grant themselves a bonus “upgraded” plan through the referral field, with no real referral ever happening, completely bypassing the referral program's intended limits. | Moved into the same protected internal record as plan/admin-status/trial fields — locked to server-only access, same as finding #2. | Verified live on a real test account: the direct edit attempt was accepted and left a harmless leftover value sitting on the visible account record, but the app's real decision-making logic never reads that value anymore and was confirmed unchanged and correct. |
| 8 | A few outdated or unused external code libraries were bundled with the app, including one with a known, published security weakness — but it wasn't actually being used anywhere in the app's own code. | Low | No direct attack path found — this is a “reduce your exposed surface area” cleanup, not an active hole. Carrying unused code with known weaknesses is still a bad habit, since a future change could accidentally start using it. | Removed the unused libraries entirely. | Verified — the app still builds correctly with them removed. |
Not fixed in this pass (flagged, intentionally deferred)
- Daily free-tier usage limit (e.g. “5 messages per day”) is enforced only in the browser, not on the server. A technically inclined free user could bypass the daily cap and use the paid AI feature for free, indefinitely. Not fixed yet — lower urgency than the findings above because it costs the business money rather than exposing anyone's data, but it should be addressed.
- A one-time internal migration tool exists to move old account data into the newer, protected record format described in finding #2/#7, but it was never actually run, by the client's own choice during this engagement. If there are any real user accounts (beyond the test account used here) with an existing paid plan, admin status, or referral bonus, that data has not yet been carried over, and should be before this is considered fully closed out operationally.
The plan-escalation story (finding #2), in detail
This is worth walking through in full because it's the clearest illustration of why “we fixed it and the code looks right” is not the same as “it's actually fixed.”
The bug: an ordinary account record — the same one that stores your name and email — also stored what plan you're on and whether you're an administrator. The app's rule was “you may only edit your own account record.” That sounds reasonable. It is not enough.
Why it wasn't enough: “you may only edit your own record” says nothing about which fields on that record you're allowed to change. A user is always allowed to edit their own name. The same permission, on this platform, also let them edit their own “plan” field — because the underlying system has no way to say “you can touch this field but not that one” on the same record.
Round 1 fix: We added the “only your own record” rule described above. It passed a full code review. It passed every automated check we could run (the code compiled, the tests passed, the logic looked internally consistent).
Then we tested it live, on a real test account, using nothing more exotic than the browser's own built-in developer console — no hacking tools. We sent one request: “set my plan to the most expensive tier.” It worked. Full priced-tier access, granted instantly, for free, with a fix already shipped and passing every check we had.
Round 2 fix: Since the platform has no way to protect individual fields on a record, the only real fix was to stop storing those fields on that record at all. We moved plan, admin-status, and trial-status into a separate, completely locked internal record — one that flatly refuses every read and every write from any regular user, including the account's own owner. The only way to touch it is through our own trusted server-side code, which is the only thing that ever gets special access.
We tested that live too. The user could still send the same “set my plan to diamond” request as before — and it was accepted, HTTP-success and everything — but it landed on the now-meaningless leftover field on the old record, which nothing in the app reads anymore. We then checked the app's real, authoritative answer to “what plan is this user on,” through the same path the app itself uses, and it correctly still said “free.” The escalation no longer works. We reverted the harmless leftover value afterward, so no stray data was left behind.
While testing this, we also tried the same trick against the “administrator” designation specifically, and found something worth knowing on its own: the underlying platform has its own built-in rule that already blocks anyone from changing that particular field this way, regardless of anything in this app's own configuration. That protection is not something we built — it came with the platform — and it should not be relied on for any other field, since it appears to be specific to that one designation.
What this review could not verify
Being upfront about the edges of what we checked:
- Whether the platform's per-account-type data separation actually behaves the way its configuration says it should, for the coach/parent-visibility fix (finding #3). We changed the configuration and confirmed the app still works, but we did not have two independent real accounts available to prove, live, that one truly cannot see the other's data end-to-end.
- Whether the platform can supply a secret password to its own scheduled background jobs (finding #5). If it can't, those jobs may silently stop running after this fix, and that would only be caught by watching the schedule.
- Whether the payment-provider security setting referenced in finding #6 is actually turned on in the live environment — we made the code fail safely either way, but didn't have access to confirm the live setting itself.
- Whether any other, currently-unknown account on the platform (outside the single test account used throughout this engagement) has an existing paid plan, admin status, or referral bonus that would be affected by the still-unrun migration mentioned above.
- Server configuration outside the application's own code — network-level protections, security headers, and hosting-platform settings were not part of this review, since they live entirely inside the hosting provider's infrastructure rather than in the application's own code.
- This was a point-in-time review. It reflects the state of the application as of the date of this report and does not cover any changes made afterward.
This is a redacted sample. The original engagement identified the specific screens, files, and technical details involved; those have been removed here and replaced with plain-English descriptions suitable for sharing outside a technical team.