Almost every application vulnerability is one of two failures. Either data reached a position where something parsed it as instructions — a query, markup, a shell line, a serialized object — or a boundary was crossed without re-checking what the caller may actually do. Injection and cross-site scripting are the first shape; broken access control and request forgery are the second. Read this for the two shapes rather than for a list of attack names. The defenses that hold are the ones that make a failure structurally impossible, and they are a small set of moves repeated in different syntax.
Each chapter opens with the short version. Tap one to read the detail.
The two failures, and the words the field uses for them
~2 min
Security is one question asked in two places: does data cross into a position where something parses it, and does a caller cross a boundary without being re-authorized? Taxonomies and top-ten lists are vocabulary over those two shapes.
A trust boundary is any point where data or a request changes hands: browser to server, service to service, a third-party response arriving in your code. Every durable rule is about that point. Validate and authorize at each crossing, not once at the edge. Grant the least privilege that works. Deny by default, so a route added next year is refused until somebody permits it. And fail closed. That last one is concrete: a permission lookup wrapped in a try/catch that returns true when the lookup throws is an allow nobody wrote on purpose, and it fires precisely when the system is already unhealthy.
Threat modeling finds those boundaries before an attacker does. The Open Worldwide Application Security Project (OWASP) frames it as four questions; the working pair are what can go wrong and what you will do about it. Answer the first against a diagram of processes, stores, flows and boundaries, prompted by six threat kinds: spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege. Then rank on likelihood multiplied by impact, which is why a severe weakness in unreachable code is not a severe risk.
A weakness is a condition that could let a vulnerability in, and the Common Weakness Enumeration (CWE) names those classes. Common Vulnerabilities and Exposures (CVE) identifies one instance in one product version. The Top 10, currently the 2025 edition, ranks Broken Access Control first and Software Supply Chain Failures third. It calls itself an awareness document, and the Application Security Verification Standard is its testable counterpart. The common mistake is running the Top 10 as a work queue: it ranks what the industry sees everywhere, not what is reachable in your system.
Rule: find the trust boundaries first — every control in this guide is something that must happen at one of them, every time.
Authorize the object, not the route
~2 min
Authentication answers who; authorization answers what they may do — and it must be answered again for every object, on the server, on every request. The most common serious flaw in real applications is a valid token used on somebody else's record.
A token proves identity. It grants nothing. Forgetting that produces the top-ranked category in the 2025 Top 10, and it looks like this:
// authenticated, and still broken
const invoice = await db.invoices.findById(req.params.id);
return res.json(invoice); // whose invoice is it?
The route checked that the caller signed in. Nothing checked that invoice 1042 belongs to them. The 2023 API list calls this broken object level authorization (BOLA) and also puts it first; on the web it is an insecure direct object reference (IDOR). Same bug, two names.
Two false comforts follow. A random, unguessable identifier raises the cost of finding other people's rows but never checks ownership. Anything that leaks one (a log, a referrer header, a shared link) restores the flaw. And hiding the admin button is user experience: checks belong on the server, because client-side logic is trivially bypassed. The same idea governs fields one layer down. Binding a request body onto a model lets a caller set properties you never meant to expose, and filtering a full record in the browser still ships the data.
Rule: any handler that loads an object by an identifier from the request must, in the same breath, prove the caller is entitled to that specific object.
Data crossing into the instruction position
~3 min
Injection and cross-site scripting (XSS) are one bug at two ends of the system: a value you treat as data lands where some parser reads syntax. Going in, the fix is binding; coming out, it is encoding chosen for the exact destination.
An interpreter parses a string. Concatenate user input into that string and the parser reads the user's characters as syntax. Binding removes the possibility rather than reducing it: the query and the value reach the database separately, so the value is never parsed, whatever it contains. Same table, same hostile input:
bad = "x' OR '1'='1"
db.execute("select name from users where name = '" + bad + "'") # 3 rows
db.execute("select name from users where name = ?", (bad,)) # 0 rows
Binding has one hard limit, and it fails quietly: you can bind values, never identifiers (no table name, column name or sort direction). Pass one anyway and a permissive engine may take it as a constant and not sort, which reads as a UI bug. Those positions need a lookup from input to a fixed set of legal values. The shape recurs. Pass an argument vector to a subprocess, not a shell string. Canonicalize a file path, then check it is still under the base directory, because path joining silently collapses .. and walks out. And do not deserialize untrusted native formats at all, since that instantiates types and chains into code execution.
On the way out, the parser is the browser and the defense is encoding. There is no escaped string, only a string escaped for the context it enters. The guidance names five: HTML body, HTML attribute, JavaScript, URL and CSS. The wrong one is a no-op: an HTML entity encoder passes javascript:doThing() through unchanged, because none of its characters are &, <, >, " or '. Escaping that value into an href protects nothing. Serialized data is the trap that looks inert:
JSON.stringify({ bio: "</script>" });
// {"bio":"</script>"} — the sequence survives and ends the element early
Some placements no encoder rescues (inside a script element, an event-handler attribute or a URL scheme), and the guidance says to keep untrusted variables out of them. Prefer textContent over innerHTML. When the sink is in client code, in the browser's Document Object Model (DOM), the value may never reach your server for any filter to see.
Rule: never build an instruction by concatenating data, and never encode until you know the destination; "we sanitise input" is a second layer, which the guidance says outright is not a complete defense.
What the browser enforces, and what it only advises
~2 min
The same-origin policy stops one origin's script reading another's response; it does not stop the request being sent. Security headers are the rest of the browser-side toolkit — instructions the browser follows on your behalf, never controls on your server.
Start with that split. Requests still leave: images, form posts and script tags cross origins by design. Cross origin resource sharing (CORS) is the server telling the browser it may expose a particular response to a particular origin — a read permission, not access control, and nothing outside a browser consults it. Reflecting whatever Origin arrives back into the allow header re-implements a wildcard while looking specific.
A Content Security Policy (CSP) is the other half, constraining which sources may execute and so capping what an injection can do once one exists. The common mistake is allowing inline script, which restores exactly what the policy was there to block. The other is trusting a host allowlist, which gets bypassed through whatever already lives on an allowed domain; per-response nonces are sturdier.
The rest is a short list with specific values:
•
Content-Security-Policy: frame-ancestors denies framing and defeats clickjacking; X-Frame-Options: DENY is the older fallback.
•
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload makes the browser refuse plaintext for the host — and locks users out if the certificate lapses, so it is a commitment.
•
X-Content-Type-Options: nosniff stops the browser guessing a type your server declared, and Referrer-Policy: strict-origin-when-cross-origin keeps paths and query strings out of outbound referrers.
•
X-XSS-Protection is deprecated: leave it off or explicitly disabled, because the legacy filter it enabled could introduce vulnerabilities into otherwise safe pages.
Rule: headers instruct the browser, so they harden the person visiting you — every check that protects your data still runs server-side.
The confused deputy, in both directions
~2 min
Two attacks share one shape: something trusted is talked into spending its authority for somebody else. In the browser the deputy is the user's session; on the server it is your own outbound request. Neither is fixed by checking who sent the request.
In cross site request forgery (CSRF), an attacker's page causes the user's browser to issue a state-changing request to your site, and the browser attaches the session cookie because it always does. The attacker never needs to read the response, which is exactly why cross-origin read rules do not prevent this: the request is delivered regardless. The defense is a value the attacker's page cannot obtain: an unpredictable token, unique per session, bound to that session, submitted with the request. SameSite on the cookie narrows the window, but the guidance is explicit that in most deployments it is an extra layer, not a substitute for the token.
Server side request forgery (SSRF) is the mirror image. Your server fetches a URL the user supplied (a webhook target, a link preview, an image import) from a position inside your network, and its authority is reachability. The classic prize is the cloud instance metadata endpoint at 169.254.169.254, which hands out workload credentials over plain HTTP to anything that asks.
Denylists reliably lose here. An address can be written in decimal, octal or IPv6 form, a permitted host can redirect, and a name can resolve to something internal after you validated it. So the guidance prefers an allowlist of known destinations and says to disable redirect following. It warns against accepting whole URLs at all, because the parser itself can be abused. Then assume the application layer is beaten and add egress filtering, so a successful fetch reaches nothing.
Rule: when a trusted component acts on input, secure the action rather than the identity — an unguessable token for the browser, an allowlist plus egress control for the server.
Proving who, storing the proof, and carrying it around
~3 min
Three things carry the weight: a second factor that cannot be relayed, a stored password that is expensive to crack, and a token you verify on your own terms. Composition rules and forced rotation buy none of them, and current guidance has dropped both.
Policy is length, not character classes. The guidance treats passwords under 15 characters as weak without a second factor, and under 8 with one. It wants a maximum of at least 64 so passphrases fit, allows all characters including unicode and whitespace, and imposes no composition rules. Rejecting known-breached passwords outright is required, and does more than every rule it replaces.
Storage must be one-way, because whoever holds the key can reverse encryption. Fast digests are wrong here for the reason they are right elsewhere: speed helps the attacker. Preference runs Argon2id (a baseline of 19 MiB memory, 2 iterations, parallelism 1), then scrypt, then PBKDF2 where a certification demands it, with bcrypt for legacy systems. bcrypt silently ignores input past 72 bytes, and the guidance warns against the obvious workaround of pre-hashing with a fast digest.
For the second factor, phishing resistance is what matters. A hardware authenticator or passkey keeps its private key on the device and signs a challenge tied to the site, so a look-alike page has nothing to relay. A proxy site can relay typed codes in real time. Sessions need at least 64 bits of entropy and a regenerated identifier after any privilege change (mandatory against session fixation). They need server-side invalidation on logout, not just a cleared cookie, plus Secure, HttpOnly and SameSite. Return one generic failure message in comparable time, or the login form becomes a directory of your users.
A JSON Web Token (JWT) usually carries the result, and it is signed, not secret; one line reads the claims:
Both classic forgeries exploit trust in the token's own alg header. Setting it to none gets a signature-free token past a library that obeys the header. Switching RS256 to HS256 invites verification with the public key as a shared secret. So pin acceptable algorithms in the verifying code, then check issuer, audience and expiry: a valid signature from the wrong issuer is still wrong. OAuth 2.0 delegates access and is not a login protocol by itself. In its authorization code flow, state ties the callback to the request, and Proof Key for Code Exchange (PKCE) stops whoever intercepts a code from redeeming it.
Rule: make the credential expensive to crack, the second factor impossible to relay, and verification a checklist you control — never a boolean the token gets to influence.
The small amount of cryptography you actually choose
~2 min
You will seldom design a cryptosystem and constantly choose four things: a mode, a nonce, where the key lives, and how you compare secrets. That is where real systems break. The primitives themselves almost never do.
First, the two words people swap. Encoding is reversible by anyone (base64 round-trips with no key), so it is never protection. Hashing is one-way: you can only guess and compare.
The mode, not the cipher, decides what leaks. Three identical plaintext blocks in electronic codebook mode give three identical ciphertext blocks; a chained mode with a random starting vector gives three different ones.
So prefer an authenticated mode, which carries integrity and confidentiality together. Galois/Counter Mode (GCM) is the usual first choice, with AES at 128 bits and ideally 256. The nonce is where authenticated encryption most often goes wrong. Reuse one under the same key and two ciphertexts leak their relationship exactly, and nothing stops you: encrypting twice with an identical key and nonce raises no error anywhere.
Keys need a home outside the code, and a human password is not key material until it passes through a slow derivation function. Envelope encryption wraps the data key in a separate managed key, so rotation touches one small object. Do not invent constructions; the guidance on rolling your own is a flat refusal. Compare secrets in constant time, which still leaks length, since those routines reject differing lengths rather than comparing them.
Transport is decided for you. Transport Layer Security (TLS) 1.3 removed static key exchange, so every handshake gives forward secrecy, and pruned everything that was not authenticated encryption. Certificate validation is still several independent checks (hostname, dates, chain, revocation), and a library skipping one is the usual bug.
Rule: choose a vetted authenticated construction, give it a unique nonce and a key you can rotate, and let the library do the rest.
Blast radius: identity, network and the container
~2 min
Cloud and cluster incidents are rarely exotic. They are a wildcard permission, a long-lived key, a flat network and a workload running as root — each turning a small compromise into a large one. The configuration is the part that stays yours.
Identity first. A policy granting every action on every resource is the cloud equivalent of running as root, and it is usually written while debugging and never narrowed. Credential lifetime matters as much as scope: a role assumed at runtime expires by itself, while a static access key lives until somebody notices it in a repository.
Then the cluster. The 2025 Kubernetes risk list names overly permissive authorization, secrets management failures and missing network segmentation among its top entries. The platform's own documentation is blunt about the second: a Secret is base64-encoded rather than encrypted, and sits unencrypted in the API server's backing store by default. Anyone reaching that store, or able to create a pod in the namespace, can read it. Encryption at rest and narrow access rules are both opt-in.
Networking defaults are permissive the same way. Every pod can reach every pod until a policy says otherwise, and a flat network turns one compromised host into the whole estate. The control that pays for itself is outbound. Default-deny egress turns a successful request forgery or an installed implant into a dead end, because reaching in is only half of an attack. Finally, a container is not a virtual machine: namespaces partition one shared kernel. So run as a non-root user, and treat an exposed admin component with a default credential not as a step in a breach but as the breach.
Rule: assume each component will be compromised and ask what it could then reach — that question sizes permissions, network policy and egress rules.
The code you did not write, and finding bugs before shipping
~2 min
Most of what you ship is other people's code, resolved by a package manager and assembled by a runner holding your deploy credentials. Inventory comes first: you cannot answer a new advisory about a component you cannot list.
The 2025 Top 10 promoted software supply chain failures to their own category, ranked third: compromises in how software is built, distributed or updated, not in the code you wrote. Its prevention steps are unglamorous. Keep a software bill of materials (SBOM) for the whole application. Track direct and transitive dependencies against vulnerability data. Take components only from official sources, prefer signed packages, and establish artifact integrity through provenance and signing.
Two mechanics surprise people. Installation is execution: a package's preinstall and postinstall scripts run the moment it is added, before a line of your code imports it. npm ships an --ignore-scripts flag precisely because running them is the default. That is why registry worms spread through installs rather than through use. And a lockfile with integrity hashes is what makes a resolved graph reproducible; without one, a name you pinned can resolve elsewhere tomorrow. Your build pipeline belongs in the same threat model as production, because it holds the credentials that reach production.
On finding your own bugs, the testing guide is candid that no single technique works. Manual review and threat modeling are cheap and early but prove nothing about the built artifact. Source code review is thorough yet misses runtime behavior, compiled libraries and the gap between reviewed and deployed code. Penetration testing exercises what actually runs, but arrives late and samples only a fraction of the surface. Each one's blind spot is another's strength.
Rule: know exactly what you ship and where it came from, then combine testing techniques deliberately — treating any single scan as coverage is the mistake.
Secrets, logs, and the day it goes wrong
~2 min
A committed secret is compromised the moment it lands, and deleting the file does not remove it. Logs are the mirror problem — the mechanism that copies sensitive values into every store you own.
Version control does not forget. Remove a file holding a credential, commit the removal, and the value is still readable at the earlier commit and still turns up in a history search:
git rm config.env && git commit -m "remove the credential"
git log --all -p | grep api_key # still there, in every clone
So the response to exposure is revoke, rotate, replace, immediately and in that order. Rewriting history is cleanup afterwards, not the fix. A secrets manager buys one place with an access policy, an audit trail of who requested what and when it was used, and automated rotation. Better still are short-lived credentials issued on demand, worthless by the time a leaked copy is found. Environment variables are the common fallback and a weak one: readable across the process tree, and liable to end up in crash dumps and logs.
Logging is the same material pointed the other way. Record the security-relevant events: authentication outcomes, authorization failures, administrative actions. Never log tokens, passwords or card numbers, because a log line is replicated to every aggregator, backup and support tool you own.
Then assume it happens anyway. Public catalogs of attacker techniques, compiled from real-world observations, let you map what you would actually detect. Intruders persist, move sideways on reused credentials, and leave through egress unusual in volume, destination or timing rather than in filename. The standard incident guidance was rewritten in 2025 around govern, identify, protect, detect, respond and recover, replacing the older four-phase loop. It also undercuts a folk rule: holding off containment to gather evidence is a decision needing legal sign-off, not good practice, and the delay gives an attacker room to escalate.
Rule: assume every secret leaks and every log is copied — design for rotation, and for what you would need recorded, before you need either.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.