Manual Code Review
A scanner finds patterns. A reviewer finds the thing the pattern was standing in for — the missing ownership check, the workflow that can be entered at step three, the sanitiser applied to the wrong context. This page is the pass you make with your own eyes, and how to make it repeatable.
What this page assumes
Review plan
A review without a plan becomes a tour of the files that looked interesting. Write the plan before opening the repository, and order it by consequence rather than by directory: what is internet-facing, what touches money or identity, what handles untrusted files. The time-box matters as much as the order — an unbounded review of a large codebase stops at 60% of the authentication module and never reaches the payment path.
A plan that holds up has five lines: scope (which repositories and branches), entry points (what can be called from outside), the highest-consequence flows (login, payment, admin, file handling), the language and framework versions in use, and what is explicitly out of scope. That last line is what stops a reviewer from being asked why they did not audit the vendored library.
| Area | What to check | CWE |
|---|---|---|
| Authentication | Session issuance, token validation, password reset flow, remember-me | CWE-287, CWE-384 |
| Authorisation | Object-level checks on every read and write, role checks in the handler | CWE-639, CWE-862 |
| Input handling | Every parameter, header, cookie and body field, including JSON and multipart | CWE-20 |
| Data access | Query construction, ORM escape hatches, raw SQL in migrations and reports | CWE-89 |
| Crypto | Algorithm choice, key storage, IV and nonce handling, random source | CWE-327, CWE-330 |
| Business logic | Price and quantity trust, multi-step workflow state, concurrency on shared rows | CWE-840, CWE-362 |
Architecture and trust boundaries
Every finding needs an attacker on one side of a boundary and a capability on the other. Draw the boundaries before reading the code: browser to API, API to database, API to internal service, worker to queue, admin console to everything. Then, for each crossing, ask what the receiver validates and who it trusts to have validated already. The defects cluster exactly where two teams each assumed the other had checked.
Who can call this?
Anonymous, authenticated, admin, another service. The answer decides severity before any code is read.
Who validated it?
The gateway, the framework or nobody. "The gateway validates" is a claim to verify, not a control to assume.
Who may see the result?
Authorisation is a data question. The interesting one is per-object, not per-route.
Authentication and authorisation
Broken access control has been the top entry in the OWASP Top 10 for two editions running, and in source it almost always looks like this: a handler that authenticates correctly and then fetches an object by identifier without asking who owns it. The route guard proves the caller is logged in. It says nothing about whether this invoice is theirs.
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
@GetMapping("/{id}")
public Invoice get(@PathVariable Long id, Principal caller) {
// authenticated, but whose invoice is it?
return invoiceRepository.findById(id).orElseThrow();
}
}@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
@GetMapping("/{id}")
public Invoice get(@PathVariable Long id, Principal caller) {
// authenticated, but whose invoice is it?
return invoiceRepository.findById(id).orElseThrow();
}
}@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceRepository invoices;
@GetMapping("/{id}")
public Invoice get(@PathVariable Long id, Principal caller) {
Invoice invoice = invoices.findById(id).orElseThrow();
// ownership is part of the query, not a follow-up check
if (!invoice.getOwnerId().equals(currentUserId(caller))) {
throw new AccessDeniedException("not your invoice");
}
return invoice;
}
}@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceRepository invoices;
@GetMapping("/{id}")
public Invoice get(@PathVariable Long id, Principal caller) {
Invoice invoice = invoices.findById(id).orElseThrow();
// ownership is part of the query, not a follow-up check
if (!invoice.getOwnerId().equals(currentUserId(caller))) {
throw new AccessDeniedException("not your invoice");
}
return invoice;
}
}The same shape appears in every framework, and the fix is the same: make ownership part of the query where you can, and an explicit check where you cannot. What does not work is a check in the template, a filter on the list endpoint, or a client-side hide — all three are bypassed by calling the API directly.
Input handling
The most common review note in the world is "sanitise your input", and it is usually wrong. Input validation is an allowlist at the point where a value enters the system — type, length, range, format. It cannot make a value safe for SQL, HTML and a shell at the same time, because those three contexts have incompatible escaping rules. Encoding belongs at the sink, chosen for that sink's context.
// one sanitiser, three contexts, no idea which one is right
String clean = input.replace("<", "").replace(">", "");
String html = "<div>" + clean + "</div>";
String sql = "SELECT * FROM users WHERE name = '" + clean + "'";
String shell = "ping -c 1 " + clean;// one sanitiser, three contexts, no idea which one is right
String clean = input.replace("<", "").replace(">", "");
String html = "<div>" + clean + "</div>";
String sql = "SELECT * FROM users WHERE name = '" + clean + "'";
String shell = "ping -c 1 " + clean;// encode per sink: HTML escape, bind parameters, pass argv
String html = "<div>" + HtmlUtils.htmlEscape(input) + "</div>";
String sql = "SELECT * FROM users WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, input);
// no shell string at all: argv, no interpretation
Process p = new ProcessBuilder("ping", "-c", "1", input).start();// encode per sink: HTML escape, bind parameters, pass argv
String html = "<div>" + HtmlUtils.htmlEscape(input) + "</div>";
String sql = "SELECT * FROM users WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, input);
// no shell string at all: argv, no interpretation
Process p = new ProcessBuilder("ping", "-c", "1", input).start();Read for the inverse too: code that escapes on the way in and then stores the escaped value, which double-encodes on the way out and corrupts data. Validation is a property of the boundary; encoding is a property of the sink.
Crypto and secrets
In review, crypto findings are almost never about the mathematics. They are about four repeats: a key in the source, a hash chosen for passwords (MD5, SHA-1, or unsalted SHA-256), a cipher mode without authentication, and a random source that is not a CSPRNG. Searching for these four is faster by grep than by reading, and the hits are high-signal because production code rarely contains a hash function by accident.
Business logic
Logic flaws have no sink to grep for. They appear where the code trusts something it should recompute: a price sent by the client, a discount applied twice, a workflow step reachable without its predecessors, a balance updated without a lock. Read these flows as a state machine and ask what state the attacker can force.
| Pattern | What the code does | What to ask |
|---|---|---|
| Client-supplied value | Price, role or quantity arrives in the request body | Is it recomputed from the database before use? |
| Check-then-act | Balance read, compared, then updated in a second statement | Is the row locked, or the update conditional? |
| Step skipping | Each step is a separate endpoint with its own guard | Can step three be called first? Is state stored server-side? |
| Negative quantity | Quantity validated as a number, never as a positive number | Does the total go down when the quantity does? |
| Check | Expected | Where to look |
|---|---|---|
| Every entry point in the plan has been walked at least once | Controllers, message consumers, schedulers, CLI commands and file parsers each traced to the code they call | The review plan, ticked off |
| Authorisation is enforced in the handler, not only in a route table | Each object read or written has a check that the caller may touch that specific object | The handler source, quoted in the finding |
| Encoding is applied at the sink with the correct context | HTML escaping for HTML, parameter binding for SQL, argument arrays for exec — not one sanitiser reused for all three | The call site, next to the sink it protects |
| Findings carry a reproduction path | A request or input that reaches the sink, plus the line numbers of source and sink, so a second person can repeat it | The ticket body |
Evidence Collection
A manual finding has to survive a developer saying "show me". Record these while you are still in the file:
Source and sink with line numbers: Quote both, from the same commit. A path quoted across two branches is not a path.
The request that reaches it: Method, route, parameter, and the authentication state required. If it needs an admin, say so — that changes severity.
What the control was supposed to be: The comment, the framework feature or the route annotation that looks like a check but is not on the path.
Bedrock Questions Before Filing
The four questions that eliminate most manual-review false positives:
- Can an unprivileged user reach it?: A sink behind an authenticated admin route is a defence-in-depth finding, not a critical one. Grade it for the audience that can reach it.
- Is there a filter earlier in the chain?: Spring interceptors, Express middleware, Rails before_action and API gateways all sit between the route and the handler. Read the chain, not the handler alone.
- Does the framework already handle it?: Auto-escaping templates, ORM parameter binding and CSRF tokens are controls. A finding that ignores one is a finding the developer will reject.
- Is the code on the path at all?: Feature flags, dead branches and test-only wiring are the most common reason a real defect is not a real risk today. Record it as latent, with the switch named.
Manual Review Checklist
Authentication
- ☐ Session tokens are random, rotated on privilege change, and invalidated on logout.
- ☐ Password reset tokens are single-use, expiring and bound to the account they were issued for.
- ☐ MFA state is checked on the server for every request that matters, not only at login.
Authorisation
- ☐ Every object access checks the object, not just the route.
- ☐ Role and permission checks live in the handler or the service, not only in the client.
- ☐ Administrative functions are separated by path and by check, not by a hidden menu item.
Input and output
- ☐ One validation point per parameter, allowlist-based, on the server.
- ☐ Output encoding is context-specific: HTML, attribute, URL, JavaScript, SQL, shell.
- ☐ File uploads check type by content, size by policy and store outside the web root.
Crypto and secrets
- ☐ No key, password or token in source, committed config or image layers.
- ☐ AEAD or a correct encrypt-then-MAC construction; no ECB, no static IV.
- ☐ Randomness comes from a CSPRNG for anything security-relevant.
Business logic
- ☐ Prices, quantities and totals are recomputed server-side from trusted data.
- ☐ Multi-step workflows cannot be entered at step three.
- ☐ Shared rows are locked or versioned where concurrent updates change money or access.
Fixing What You Found
A reviewer who hands back "sanitise your input" has handed back nothing. Each finding needs a destination:
Reading a large codebase without reading all of it
Start from the routes file and the OpenAPI document, not from the front door. Both give you the external surface in one place, and both are usually wrong in ways that are themselves findings — a route that exists in code and not in the spec is a route nobody reviews again. Then follow only the paths that touch identity, money or files. Everything else gets a scanner and a skim.
Why reviewers miss the same bug twice
Because the second reviewer reads the diff, not the path. If the first review filed a finding against a helper, later patches to its callers are invisible to a review scoped to the changed lines. Keep a list of open findings and re-walk each path when anything on it changes — that is the only technique that reliably catches a fix that was applied to one of five call sites.
Manual Review Practice Targets
Applications small enough to read end to end, with known defects you can find by hand before checking the answer.
Reporting findings
The review is only as good as what survives it. Two reviewers looking at the same defect produce different tickets, and the difference is whether the ticket carries a path. Source line, sink line, request, preconditions, and the control that was assumed to be there — that is the whole of it. The rest of the lifecycle, from severity to the regression test that closes the finding, is on Triage and Remediation.
Related Source Code Review Guides
SAST Fundamentals
The methodology this page applies by hand — taint, sinks and triage.
Triage and Remediation
What happens to findings after the review: severity, handoff and regression tests.
Access Control
The fix catalogue for the authorisation defects this page hunts for.
Secure Architecture
How trust boundaries should be drawn, which is what the mapping pass is comparing against.
Internal Pentest Methodology
The black-box counterpart: what a reviewer with source access can skip.