
The techniques that still work are the ones your tests do not cover: object ids, injection, XSS, and stolen sessions.
A top-ten list is a reading order. It is not a configuration. Broken access control stays first because apps keep returning the next row when the id changes.
The usual mistake is teaching ‘hacking’ as a toolkit demo and never writing the two-account test that would have caught the bug.
This page maps the common techniques to the control that closes them, with links to the deeper guides on this site.
The OWASP Top 10:2025 introduction still ranks Broken Access Control at A01. The contributed data said 3.73 percent of tested apps had at least one of the 40 CWEs in that bucket. Injection sits at A05 and still has the greatest number of CVEs among its 38 CWEs.
A03 in 2025 is Software Supply Chain Failures, a new Top 10 row. That is a lockfile and review job, not a fifth web class on this map. Keep this page for the four sinks. Open the dependency guide when the hatch arrived in a tarball you did not write.
Classes, not a lab
People search this slug for a list of “hacking techniques.” A list without a lock is a glossary. A glossary does not close a sink. The useful split is the class: which interpreter trusted data it should have treated as text, and which control you write first. Four names cover most web tickets that actually ship in 2026.
CLASS SINK LOCK XSS HTML / JS in the browser encodeBio for that sink CSRF cookie on a foreign POST fetchSiteOk on mutations IDOR client-named object id invoiceForOrg in SQL Injection SQL / shell / ORM hatch bind plus SORT_KEYS
CWE-79 is XSS. CWE-352 is CSRF. CWE-639 is IDOR. CWE-89 is SQL injection, CWE-78 is OS command, CWE-94 is code. Rankings do not pick your ticket. The sink does. MITRE’s 2025 CWE Top 25 still ranks CWE-79 first. That is prevalence, not a reason to skip the object-id query.
73 percent figure and the injection CVE count, and MITRE for the 2025 rank. The next four sections show the lock only.
XSS: untrusted text became the page
CWE-79 is this row. Stored, reflected, and DOM are textbook labels for the same miss: bytes you did not mint became HTML, an attribute, a JavaScript string, CSS, or a URL. Each sink has its own encoding. A comment that says “we escape” next to innerHTML is still the bug.
Firefox 148 shipped setHTML in February 2026. The name looks like a safe cousin of innerHTML. It is a sanitizing writer, not a free pass. A friendly name is not a policy. Trusted Types then CSP without unsafe-inline are the belt. HttpOnly does not close this class. Script in your origin can still fire any request the user is allowed to make and read anything the page can already see.
// Express 5: encode for the HTML body sink
function encodeBio(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
app.get("/profile", (req, res) => {
const bio = encodeBio(req.user.bio);
res.type("html").send(`<p>${bio}</p>`);
});
Identifiers stay encodeBio and bio so they match a test. If the product must accept markup, that is a sanitizer job on the XSS page, not a second innerHTML. Do not invent a regex that “strips script tags.” That is how this class comes back on an attribute or an event handler.
CSRF: the cookie rode a foreign POST
CWE-352 is the cookie row. The server sees a cookie it minted and treats the request as the user. The user never submitted that form. A cookie-authenticated app that changes state is the whole surface. Bearer tokens in Authorization are not auto-attached, so they fall out of this class. Cookies do not.
Chrome treated cookies with no SameSite as Lax starting with the Chrome 80 rollout in February 2020. That closed the old foreign POST-with-cookies shape for most people. It did not close GET state changes, sibling-subdomain requests, or clients that never send Fetch Metadata. MDN marks Sec-Fetch-Site Baseline widely available since March 2023. The browser sets it. Frontend JavaScript cannot.
csurf 1.11.0 last published on 19 January 2020. Express TC deprecated it on 16 May 2025. Do not install it. The lock on a cookie-authenticated POST is: allow same-origin and none, treat same-site like cross-site unless you have listed every hostname on the eTLD+1, and fail closed when the header is missing.
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
function fetchSiteOk(req) {
const site = req.get("sec-fetch-site");
if (site === "same-origin" || site === "none") return true;
if (!site) return false;
return SAFE.has(req.method);
}
app.use((req, res, next) => {
if (SAFE.has(req.method)) return next();
if (fetchSiteOk(req)) return next();
res.status(403).send("Forbidden");
});
Identifiers stay fetchSiteOk and SAFE. Tokens stay for login and for old clients. __Host-session is the cookie prefix that refuses Domain. SameSite is a site boundary, not an origin lock. The long CSRF guide has the Origin fallback, Gitpod CVE-2024-21583, and the curl proof. Here the work is the header check only.
IDOR: the query asked only for the id
CWE-639 is the object-id row. Authentication answered who. This class is which row. A UUID does not change that. OWASP API1:2023 says object ids can be integers, UUIDs, or strings, and that comparing the session user id to the parameter is not enough when the object is an invoice, a file, or a thread.
More than 64 million McHire applicants sat behind a login that still answered any inbox id. Public reporting on that case clustered in June 2025. Suno.com’s October 2025 disclosure was a valid user and the wrong song id. The shared miss is the query that only asked for the id.
// scoped read: org and invoice together
async function invoiceForOrg(db, orgId, invoiceId) {
const invoice = await db.query(
`SELECT id, total_cents, status
FROM invoices
WHERE org_id = $1 AND id = $2`,
[orgId, invoiceId],
);
if (!invoice.rowCount) {
return null;
}
return invoice.rows[0];
}
app.get("/invoices/:invoiceId", async (req, res) => {
const row = await invoiceForOrg(db, req.session.orgId, req.params.invoiceId);
if (!row) return res.status(404).send("Not found");
res.json(row);
});
Identifiers stay invoiceForOrg, orgId, and invoiceId. Return 404, not 403, so a known id is not an existence oracle. Strip owner_id, org_id, and role from PATCH bodies. Load-then-compare is how writes get missed: you fetched the row unscoped, then asked a policy function, and a new route skipped the function. Put the tenant in SQL. The long IDOR page has Postgres RLS, mass assignment, and the two-account replay. The map ends once the WHERE is scoped.
Injection: data became grammar
The class is a string or object you did not mint, parsed as grammar by a system that was supposed to treat it as data. CWE-89 is SQL. CWE-78 is OS command. CWE-94 is code. XSS lives in the injection bucket in the 2025 Top 10. Treat it as its own class on this page because the lock is encoding, not a bind.
CVE-2026-24908 is a CVSS 10.0 in OpenEMR’s Patient REST API. NVD published the record on 25 February 2026. AISLE’s writeup, dated 28 April 2026, said the product sits in front of more than 100,000 providers. The _sort query parameter was concatenated into ORDER BY. A prepared statement would not have saved a concatenated column name. An allowlist would.
const SORT_KEYS = new Set(["created_at", "last_name", "uuid"]);
function sortClause(sortKey) {
const key = String(sortKey || "created_at");
if (!SORT_KEYS.has(key)) {
throw new Error("unsupported sort");
}
return `ORDER BY ${key} ASC`;
}
const rows = await db.query(
`SELECT id, last_name FROM patients WHERE org_id = $1 ${sortClause(req.query.sort)}`,
[req.session.orgId],
);
Identifiers stay SORT_KEYS and sortClause. Bind org_id. Allowlist the column. Never concatenate req.query.sort into SQL. On the ORM side, the hatches that turn the stack back into a string builder are $queryRawUnsafe, Sequelize.literal, whereRaw, and TypeORM query(). Grep those before you trust the model. Prefer execFile over exec, then -- so a leading-dash filename cannot become a flag. The long injection page has OpenEMR’s hatch, Express 5 query shape, and the CI greps. Bind plus allowlist is the whole first lock.
Brute force, phishing, DoS: pick the real ticket
Those are real. They are not a fifth, sixth, and seventh web class with a unique lock on this map. They fold into tickets you already have.
| Old name | Real ticket | First lock |
|---|---|---|
| Brute force | A07 Authentication Failures | rate limit plus MFA, not a longer password tip |
| Cookie theft | session + XSS | __Host-session, HttpOnly, Secure, then encode the page |
| Phishing | A07 plus human process | phishing-resistant MFA, not “be careful of emails” |
| Denial of service | capacity and auth | timeouts, auth on expensive routes, not a cloud slogan |
| SQL injection | this page’s injection class | sortClause plus binds |
A padlock in the address bar is TLS. It does not close any of the four sinks on this map. That is wrong. HTTPS stops a path observer from reading the cookie in transit. It does not stop your origin from writing untrusted HTML, from accepting a foreign POST, from answering the wrong invoice id, or from concatenating ORDER BY.
I am not writing a brute-force lab, a phishing kit, or a flood recipe. When the report is “someone guessed the password,” lock the login: lockout after a bounded number of failures, phishing-resistant MFA, and no shared admin password. When the report is “someone mailed a lookalike invoice,” that is process plus MFA, not a regex on the inbox. When the search box concatenated SQL, that is this page’s injection class.
Prove you named the class
You are not walking an exploit. You are proving your own handlers encode, refuse a cross-site POST, scope the row, and bind the value.
- Pick a profile or comment field you already render. Put an ampersand and a less-than in your own account. View source. You want entities, not raw markup. A raw
<means the sink skippedencodeBio. - In DevTools, copy as cURL a state-changing request you already make. Keep your Cookie header. Add
Sec-Fetch-Site: cross-site. Replay against your origin. Expect 403. A 200 meansfetchSiteOknever ran. - With two of your own test accounts, replay A’s session against B’s
invoiceId. Expect 404. A 200 with B’s total is the missingorg_id. - Grep for
$queryRawUnsafe,Sequelize.literal,whereRaw, TypeORMquery(, and string-builtORDER BY. Each hit needs a bind orSORT_KEYS. OpenEMR’s miss was the allowlist.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/account/email" \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
-H "Sec-Fetch-Site: cross-site" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example"
# Expect: HTTP/2 403
Run the same copy with the Sec-Fetch-Site line deleted. If you fail closed, that is also 403. A first-party job that must pass should send Sec-Fetch-Site: none on purpose. Then grep routers for app.get handlers that write to the database. Lax will send the cookie on those.
If one request trips more than one row, open the sink ticket first. A stored XSS in an admin queue is also a CSRF plus an object-id problem once the script runs in a privileged origin. Encode first. Then walk the others.
Questions we keep getting
Why not put every control on this page?
Because the long guides already do, and a map that copies them will rot in two places. This page names the class, shows the lock, and hands you the matching flagship URL. If you need Trusted Types, Gitpod cookie toss, Postgres RLS, or Express 5 query shape, open that guide.
Is a WAF the first lock?
No. A WAF is a tripwire in front of a sink you have not closed. Encode, header-check, scope, and bind on the origin you own. Then a WAF can watch for the cases you missed. It cannot replace those four.
Does OWASP 2025 change which class I open first?
A01 is still Broken Access Control, so IDOR stays a first-week ticket. A05 is still Injection, and XSS still lives there. CSRF is not its own Top 10 row. It is still a cookie-authenticated mutation. Rankings do not change the sink test above. A03 is the supply-chain row: that is a lockfile, not this map.



