I have run read-only security audits on 7 live applications. Small ones: solo builds and two-person teams, the kind with real users and no security budget. The same five things come up almost every time, and none of them are exotic.
No application is named here, and there are no reproduction steps. Some of what I found is still being fixed, and a writeup that points at live holes is not a writeup, it is a map. The categories below are documented by the vendors whose products they involve. The specifics stay private.
1. Row-level security is on, and enforcing nothing
This is the most common one and the most dangerous, because the dashboard says green.
Enabling row-level security on a table does not protect it. It means no rows are returned unless a policy permits them, which the vendor documentation says plainlyand which is still the most commonly misread sentence in it. So people write a policy to make the app work again, and the policy they write is usually too broad. A policy written without a role clause applies to every role, including the anonymous one, and the anonymous key ships inside the page bundle by design. Anyone can read it out of view-source.
The failure is that your application layer filters correctly and the attacker does not use your application layer. They call the database API directly with a key you published.
What to check: for every policy, ask which roles it applies to. If the answer is "I did not specify," the answer is all of them.
2. Paid API routes with no authentication
Modern small apps ship server routes that call something metered: a language model, a maps API, a transcription service. The route works, the feature ships, and nobody adds authentication because during development you are the only one who knows the URL.
The URL is in your JavaScript bundle.
An unauthenticated route that calls a paid API is not a data breach, it is a billing surface. Someone loops it and your card pays for their traffic. It also fails silently in the worst possible direction: you find out from the invoice.
Do the console fixes first. Spend caps and budget alerts on every provider, and referrer or IP restrictions on any key that supports them. Free, immediate, and they bound the damage while the code fix waits for a session where you have time to do it properly.
3. An auth cookie that is not authentication
Somewhere in the build, a gate got stubbed. A cookie is set on login, and the check is whether the cookie exists, or whether it equals a known constant.
That is not a session. Anyone can set a cookie. If your check is a string comparison against something a client controls, the gate is decorative, and it is usually protecting whatever the developer considered too sensitive for the public routes.
Test: open a private window, set the cookie in the console, and load the protected page. If you are in, so is everyone.
4. Sequential identifiers as authorisation
A record is at /thing/41. A logged-in user requests /thing/42. Does anything stop them?
In an app where the identifier is also the only thing separating one user's data from another's, incrementing an integer is the entire attack. This shows up constantly in voting, ordering and messaging features, where the developer reasoned about the interface rather than about the endpoint.
Rule: knowing an identifier is not permission to use it. Authorisation is checked server-side on every request, every time, or it is not checked at all.
5. Input that becomes markup
User text rendered as HTML rather than as text. A display name, a review, a business description, anything that goes in and comes back out. It has been on the OWASP Top Ten for two decades and it is still here.
Frameworks escape by default now, which has made this rarer and also more dangerous, because the one place it survives is wherever someone deliberately opted out to get some formatting working. Search your codebase for whatever your framework calls "render this as raw HTML" and check where each one gets its input.
How to actually run one of these
Read-only, always. The point is to produce a list, not to fix things live at two in the morning.
Every finding gets three parts, and it is not a finding without all three:
And keep two lists. Findings you verified against source, and findings you suspect but could not confirm. Mixing them is how an audit loses its credibility: one confident claim that turns out to be wrong makes a reader discount the four that were right.
The uncomfortable part
Every one of these was in an app whose author is competent. That is the point. None of these are caused by not knowing better. They are caused by the gap between the thing working and the thing being safe, and that gap is invisible from inside the code that works.
Which is the argument for having someone else look. Not because they know more, but because they did not write it and therefore do not know what it is supposed to do.
If you run a small app with real users and you would like a second pair of eyes on it, that offer is open and it does not cost anything: elijah@purcell-ventures.com.
Common questions
What does enabling row-level security actually do?
It means no rows are returned unless a policy permits them, which is not the same as protecting the table. People then write a policy to make the application work again, and a policy written without a role clause applies to every role including the anonymous one, whose key ships in the page bundle by design.
Why are unauthenticated API routes dangerous even without user data?
Because a route calling a metered service is a billing surface rather than a data breach. The URL is in your JavaScript bundle, somebody loops it, and your card pays for their traffic. It also fails in the worst direction, which is that you find out from the invoice.
How do I know if my authentication is real?
Open a private window, set the cookie in the console, and load the protected page. If the check is a string comparison against something the client controls, the gate is decorative, and it is usually protecting whatever the developer considered too sensitive for the public routes.