Your pipeline runs a static analyser, a dependency audit and perhaps a dynamic scan, and the dashboard is green. That is worth having, but it says very little about whether one customer can read another customer's invoices. The bugs that cause the most embarrassing breaches are usually not dangerous function calls or outdated packages; they are decisions about who is allowed to do what, and scanners do not know your rules.
What scanners are good at, and where they stop
Static analysis (SAST) looks for known-dangerous patterns in source: string-built SQL, unsafe deserialisation, hard-coded secrets, user input flowing into a shell. Dynamic scanners (DAST) send crafted requests to a running app and look for tell-tale responses. Dependency scanners match your lockfile against databases of published vulnerabilities.
All three work by recognising something they have seen before. That is why they are fast and repeatable, and why they are blind to an entire category of flaws. Consider this handler:
- It uses a parameterised query.
- It requires authentication.
- It returns JSON with the right content type.
A scanner has nothing to flag. Whether it is secure depends on a question only your domain can answer: should this user be able to see this record? Logic flaws are correct code implementing the wrong rule, or no rule at all. AI-assisted code makes this more pressing, not less, because generated handlers tend to be syntactically clean and pattern-safe while quietly omitting the ownership check nobody mentioned in the prompt.
What follows are the classes I see most often, each with a short example.
Broken object-level authorisation (IDOR)
The OWASP API Security Top 10 2023 puts this first, as API1:2023 Broken Object Level Authorization. The traditional name is insecure direct object reference. The pattern: an endpoint takes an identifier, checks the user is logged in, and fetches the object without checking the user owns it.
// Vulnerable: any logged-in user can read any invoice by changing the ID
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
res.json(await Invoice.findById(req.params.id));
});
// Fixed: the lookup itself is scoped to the caller
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({ _id: req.params.id, ownerId: req.user.id });
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});
Unguessable IDs such as UUIDs reduce casual discovery but are not access control. IDs leak through URLs, logs, exports and shared links. Scope the query; do not rely on obscurity.
Mass assignment
Frameworks make it easy to take a request body and write it straight into a model. If the model has fields the user should not control, such as role, isAdmin, emailVerified, creditBalance or tenantId, an attacker simply adds them to the request. OWASP now groups this under API3:2023 Broken Object Property Level Authorization, alongside its mirror image, returning properties the caller should not see.
// Vulnerable: the whole body is written to the user record
await User.updateOne({ _id: req.user.id }, req.body);
// Attacker sends: { "displayName": "x", "role": "admin" }
// Fixed: explicit allow-list of writable fields
const { displayName, bio } = req.body;
await User.updateOne({ _id: req.user.id }, { displayName, bio });
Use explicit input DTOs or schema validation that rejects unknown fields, and explicit output serialisers so new database columns do not appear in API responses by accident.
Trusting client-supplied roles, tenant IDs and prices
Anything the browser sends, the attacker controls. This sounds obvious, but it still shows up as:
- A
roleorisAdminflag read from a request header, cookie or unsigned token claim. - A
tenantIdtaken from the request body or query string rather than from the authenticated session. - A checkout that accepts
priceortotalfrom the client and charges that amount. - A discount percentage, shipping cost or currency passed through a hidden form field.
PortSwigger's Web Security Academy calls this excessive trust in client-side controls. Client-side validation is a usability feature. The server must look up prices, permissions and tenancy itself, from sources the user cannot edit.
Race conditions on balances, coupons and stock
Many business rules are written as check-then-act: check the coupon has not been used, apply it, mark it used. Under concurrency, two requests can both pass the check before either records the use. PortSwigger's race conditions guide calls these limit overrun flaws, a subtype of time-of-check to time-of-use bugs, and lists examples such as redeeming a gift card several times or withdrawing more than your balance.
-- Check-then-act: two parallel requests can both pass the check
SELECT balance FROM wallets WHERE id = $1; -- app checks balance >= amount
UPDATE wallets SET balance = balance - $2 WHERE id = $1;
-- Atomic: the condition and the write happen in one statement
UPDATE wallets SET balance = balance - $2
WHERE id = $1 AND balance >= $2
RETURNING balance; -- no row means insufficient funds
The fix is to make the state change atomic using the database's own guarantees: conditional updates, transactions at an appropriate isolation level, row locks, or unique constraints (one redemption row per coupon per user). Do not try to fix it with an in-memory flag or a session variable; those do not hold across processes.
Business logic abuse
Some flaws are not about who you are but about what the application assumes you will do. Two that come up constantly:
- Unexpected values. A quantity field that accepts negative numbers can turn a basket total negative, or reverse a transfer. PortSwigger's example is a funds transfer where a negative amount always passes the "sufficient funds" check. Validate ranges on the server, not just types.
- Skipping workflow steps. Multi-step flows such as checkout, sign-up with email verification, or login with two-factor authentication often assume users arrive at step three only after completing steps one and two. An attacker can request step three directly. Each step must check that the previous steps were actually completed, using server-side state.
Also look at thresholds. A discount applied when the basket exceeds a value should be recalculated if items are removed afterwards. OWASP's API6:2023 Unrestricted Access to Sensitive Business Flows covers the related problem of legitimate flows, such as buying limited stock or creating accounts, being automated at a scale that harms the business.
JWT verification mistakes
JSON Web Tokens put claims on the client, so everything depends on verifying the signature correctly. PortSwigger's JWT attacks guide covers the common failures:
- Decoding instead of verifying. Libraries usually offer both. The Node.js
jsonwebtokenpackage hasverify()anddecode(), anddecode()does not check the signature at all. - Accepting
alg: none. The token header says which algorithm was used, andnonemeans unsigned. A server that honours the header can be handed an unsigned token with any claims. - Not pinning algorithms. If the server lets the token choose between symmetric and asymmetric algorithms, algorithm confusion attacks become possible. Tell the library exactly which algorithm you expect.
- Trusting key headers. Parameters such as
jwk,jkuandkidtell the server which key to use. Unless they are tightly constrained, an attacker can point the server at their own key.
// Wrong: decode() only parses the token; it never checks the signature
const unsafeClaims = jwt.decode(token);
// Right: verify with a pinned algorithm, audience and issuer
const claims = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
audience: "api.example.com",
issuer: "https://auth.example.com/",
});
Also check expiry, and treat verified claims as identity, not as authorisation. A valid token says who someone is, not what they may touch.
SSRF through user-supplied URLs
Webhooks, link previews, image imports, PDF renderers and "fetch from URL" features all make your server request a URL someone else chose. That is server-side request forgery, listed as API7:2023 and described in detail by PortSwigger. The prize is usually internal: admin interfaces on localhost, services on private addresses, or the cloud metadata endpoint at 169.254.169.254, which can expose credentials.
The instinctive fix is a blocklist of internal addresses. It fails for well-documented reasons:
- Alternative IP encodings.
127.0.0.1can also be written as2130706433,017700000001or127.1, and IPv6 offers forms such as IPv4-mapped addresses. - Redirects. A URL on an allowed public host returns a redirect to an internal address, and the HTTP client follows it.
- DNS rebinding. A hostname resolves to a public address when you validate it and to an internal one when you connect.
- Parser disagreements. Tricks with
@,#and encoded characters make your validator and your HTTP client see different hosts.
Robust defences work at a lower level. Prefer an allow-list of destinations where the feature permits it. Resolve the hostname once, check the resulting IP against private and reserved ranges, and connect to that exact IP. Disable automatic redirects or re-validate every hop. Where you can, route these requests through an egress proxy or network policy that cannot reach internal ranges at all. The OWASP SSRF Prevention Cheat Sheet is a good reference.
Multi-tenant isolation leaks
In a SaaS application, IDOR has a bigger sibling: one organisation seeing another's data. Isolation tends to break in the places that are not the main CRUD endpoints:
- Search, autocomplete and reporting queries that forget the tenant filter.
- Background jobs and exports that run without a request context, so no tenant is applied.
- Caches keyed on a resource ID but not the tenant.
- File storage paths or signed URLs that are predictable across tenants.
- Admin or support tools that bypass the usual scoping.
The structural fix is to make tenant scoping hard to forget: derive the tenant from the authenticated session only, apply it in a single data-access layer or with database row-level security, and treat any unscoped query as a code review failure.
How to review for these
Scanners will not find these bugs, so you need a deliberate human pass. A practical approach:
- Map entry points and trust boundaries. List every route, queue consumer, webhook and scheduled job, and note for each what identity it runs as and what data it can reach.
- Test as two users. Create two accounts, ideally in two tenants. Perform every action as user A, then replay each request with user B's session. Anything that still works is a finding.
- Think like an attacker, per feature. For each feature ask: what would I want from this if I were malicious? Free goods, someone else's data, higher privileges, or a way into the internal network?
- Tamper with every input. Remove parameters, add unexpected fields, send negative and enormous numbers, change IDs, replay steps out of order, and fire the same request in parallel.
- Write a regression test for each finding. An authorisation test that asserts user B gets a 404 on user A's resource costs minutes to write and stops the bug returning in the next refactor.
A word on scope: only test systems you own or have explicit, preferably written, authorisation to test. Probing someone else's application for these flaws, even with good intentions, can be illegal and can cause real harm. Use your own staging environments or deliberately vulnerable practice labs such as the Web Security Academy.
Closing
Automated scanners are a floor, not a ceiling. They keep known-bad patterns out of your codebase, which frees your attention for the flaws only a person who understands the product can see. Most of those come down to one question asked consistently at every endpoint: does the server, independently of anything the client claims, know this caller is allowed to do this to this object, right now?
A structured method for reviewing applications this way, from scoping through to written findings, is covered in depth in the Security Audit Pro pack.