The OWASP Top 10 is the industry's most widely referenced ranking of web application security risks, published by the Open Worldwide Application Security Project and last revised in 2021. It does not list individual bugs; it groups the categories of weakness that cause the most real-world damage, from Broken Access Control — the number-one risk — through Injection, Security Misconfiguration, and Server-Side Request Forgery. Security is not a feature you bolt on before launch; it is a property of how you design, write, and operate an application, and avoiding all ten categories is the floor of professional web development, not the ceiling. This guide walks through every 2021 category the way you actually meet it in production: what each risk is, a concrete vulnerable example, and the fix — oriented to modern stacks like React and Next.js on the front end and .NET or Node.js on the back end, ending with a prioritized checklist.
The single most important mindset shift is this: the server is the only trust boundary you control. Anything that happens in the browser — validation, hidden fields, disabled buttons, JWT decoding — is a suggestion, not a guarantee. Every example below assumes an attacker who can craft arbitrary HTTP requests with Burp Suite, curl, or the browser dev tools.
The OWASP Top 10 is a starting point, not a compliance checklist. Shipping code that avoids all ten categories is the floor of professional web development, not the ceiling.
A01:2021 — Broken Access Control
Broken Access Control moved to the number-one position in 2021, and it remains the most common serious flaw in web apps. It happens whenever a user can act outside their intended permissions: reading another user's data, editing records they do not own, or reaching admin functionality by guessing a URL. The classic form is IDOR — Insecure Direct Object Reference — where an ID in the request maps straight to a database row without an ownership check.
The typical mistake is enforcing authorization in the UI (hiding a button) while the API happily serves anyone who calls the endpoint directly.
// VULNERABLE: any authenticated user can read any invoice by changing the id
[Authorize]
[HttpGet("api/invoices/{id}")]
public async Task<IActionResult> GetInvoice(int id)
{
var invoice = await _db.Invoices.FindAsync(id);
return Ok(invoice); // no check that this invoice belongs to the caller
}The fix is to scope every query to the authenticated principal and deny by default. Never trust an ID from the client to imply ownership — derive the user identity from the validated token on the server and filter by it.
// FIXED: ownership is enforced server-side, deny by default
[Authorize]
[HttpGet("api/invoices/{id}")]
public async Task<IActionResult> GetInvoice(int id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var invoice = await _db.Invoices
.FirstOrDefaultAsync(i => i.Id == id && i.OwnerId == userId);
if (invoice is null) return NotFound(); // 404, not 403 — do not leak existence
return Ok(invoice);
}Practical access-control defenses:
- Enforce authorization on the server for every request, never only in the client.
- Deny by default: a route with no explicit permission check should reject, not allow.
- Scope data queries to the authenticated user (WHERE owner_id = @me) rather than filtering after fetching.
- Use role and policy checks ([Authorize(Policy = "...")] in .NET, middleware guards in Node) at the boundary, and re-check ownership at the data layer.
- Prefer opaque or UUID identifiers over sequential integers to reduce trivial enumeration — but treat this as defense in depth, not a substitute for checks.
A02:2021 — Cryptographic Failures
Formerly called "Sensitive Data Exposure," this category is about failures in how data is protected — weak or missing encryption, hard-coded keys, storing passwords reversibly, or transmitting secrets over plaintext. The most common developer mistake is hashing passwords with a fast, general-purpose hash like MD5 or SHA-256, which an attacker with a leaked database can brute-force at billions of guesses per second.
// VULNERABLE: fast, unsalted hash — trivially cracked from a database leak
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));Passwords must be stored with a slow, salted, adaptive hashing algorithm designed for the purpose: bcrypt, scrypt, or Argon2id. These are intentionally expensive to compute, which makes offline cracking impractical.
// FIXED: bcrypt automatically salts and is deliberately slow
// dotnet add package BCrypt.Net-Next
string hash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
// verification
bool ok = BCrypt.Net.BCrypt.Verify(password, hash);Cryptographic defenses that matter in practice:
- Hash passwords with Argon2id, scrypt, or bcrypt — never MD5, SHA-1, or a bare SHA-256.
- Enforce TLS everywhere; redirect HTTP to HTTPS and send HSTS (Strict-Transport-Security) so browsers refuse plaintext.
- Encrypt sensitive data at rest (PII, tokens) and use authenticated encryption (AES-GCM) rather than raw AES-CBC.
- Never invent your own crypto or use ECB mode; use vetted libraries and let them manage IVs and salts.
- Classify data first — you cannot protect what you have not identified as sensitive.
A03:2021 — Injection (SQL, NoSQL, and XSS)
Injection happens when untrusted input is interpreted as code or a command. SQL injection is the archetype, but the same class covers NoSQL injection, OS command injection, and — because it is injecting into an HTML/JS context — Cross-Site Scripting (XSS). The root cause is always the same: mixing data with commands instead of keeping them strictly separate.
SQL Injection
// VULNERABLE: string concatenation — input "' OR '1'='1" dumps every row
var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
var users = await _db.Users.FromSqlRaw(sql).ToListAsync();The fix is parameterized queries. Parameters are sent to the database separately from the query text, so user input can never change the query's structure. With an ORM like Entity Framework Core, LINQ queries are parameterized automatically.
// FIXED: parameterized — the value is bound, never interpolated into SQL
var users = await _db.Users
.Where(u => u.Email == email) // EF Core parameterizes this
.ToListAsync();
// If you must write raw SQL, still parameterize:
var byEmail = await _db.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Email = {email}")
.ToListAsync();NoSQL Injection
MongoDB and similar stores are just as vulnerable when you pass unvalidated objects straight into a query. If a login handler accepts a JSON body and forwards it into a find call, an attacker can send an operator object instead of a string.
// VULNERABLE: body { "email": "[email protected]", "password": { "$ne": null } }
// matches any user whose password is not null — auth bypass
const user = await User.findOne({
email: req.body.email,
password: req.body.password
});
// FIXED: coerce to primitives and validate shape before querying
const email = String(req.body.email);
const password = String(req.body.password);
const user = await User.findOne({ email }); // then verify hash separately
if (user && await bcrypt.compare(password, user.passwordHash)) { /* ... */ }Cross-Site Scripting (XSS)
XSS injects script into a page that other users view. React protects you by default — it escapes any value rendered inside JSX. The danger appears the moment you reach for dangerouslySetInnerHTML, or render server-provided HTML in Next.js.
// VULNERABLE: renders attacker-controlled HTML, executing injected <script>
function Comment({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// FIXED: sanitize with DOMPurify before injecting, or render as plain text
import DOMPurify from 'dompurify';
function Comment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// Safest of all when you do not need markup:
function PlainComment({ text }: { text: string }) {
return <div>{text}</div>; // React escapes automatically
}Output encoding is the primary XSS defense, but a Content Security Policy (CSP) is a powerful second layer: even if an injection slips through, a strict CSP can stop the injected script from executing. More on CSP under Security Misconfiguration below.
A04:2021 — Insecure Design
Insecure Design was new in 2021 and it is different in kind from the others: it is about flaws that no amount of clean implementation can fix, because the weakness is baked into the requirements or architecture. A perfectly coded password-reset flow that emails the new password in plaintext is insecure by design. A checkout that trusts a price sent from the client is insecure by design.
// INSECURE BY DESIGN: server trusts the price the client sends
app.post('/api/checkout', async (req, res) => {
const { productId, price, quantity } = req.body;
await charge(req.user, price * quantity); // attacker sends price: 0.01
});
// SECURE DESIGN: the server is the source of truth for price
app.post('/api/checkout', async (req, res) => {
const { productId, quantity } = req.body;
const product = await db.products.findById(productId);
if (!product) return res.status(404).end();
const amount = product.price * Math.max(1, Math.min(quantity, 10));
await charge(req.user, amount);
});Designing for security means:
- Threat model early — ask "what could an attacker do here?" during design, not after a pentest.
- Treat the client as untrusted: prices, totals, roles, and limits must be computed or verified server-side.
- Build in secure defaults and business-logic limits (max quantity, rate limits, spending caps).
- Use secure design patterns and reusable, hardened components rather than reinventing auth or payments per feature.
- Write abuse cases alongside user stories so the "unhappy path" is a first-class requirement.
A05:2021 — Security Misconfiguration
This is the broadest category and the easiest to fall into: default credentials left in place, verbose error pages that leak stack traces, unnecessary features enabled, overly permissive CORS, and missing security headers. Modern frameworks ship reasonably safe defaults, but every environment override is a chance to reopen a hole.
A frequent culprit is CORS configured to reflect any origin with credentials — effectively disabling the same-origin policy for your API.
// VULNERABLE: reflects any origin AND allows credentials
app.use(cors({ origin: true, credentials: true }));
// FIXED: allow only known origins
const allowed = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
origin: (origin, cb) =>
!origin || allowed.includes(origin)
? cb(null, true)
: cb(new Error('Not allowed by CORS')),
credentials: true
}));Security headers, especially a Content Security Policy, close a wide range of client-side attacks. In Next.js you can set them centrally in the config so every response is covered.
// next.config.ts — security headers applied to all routes
const securityHeaders = [
{ key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }
];
export default {
async headers() {
return [{ source: '/:path*', headers: securityHeaders }];
}
};# What a hardened response should look like on the wire
HTTP/2 200
content-security-policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-originConfiguration hardening checklist:
- Disable detailed error pages and stack traces in production; log details server-side only.
- Remove default accounts, sample apps, and unused endpoints or ports.
- Set a strict CSP and the core security headers on every response.
- Lock CORS to an allowlist of known origins; never combine wildcard origin with credentials.
- Automate configuration so dev, staging, and production are consistent and reviewable in version control.
A06:2021 — Vulnerable and Outdated Components
Modern apps are mostly other people's code. A single project can pull in hundreds of transitive npm or NuGet packages, and any one of them can carry a known CVE. When a component with a public exploit sits unpatched in your dependency tree, you inherit its vulnerability. The Log4Shell incident showed how one library could compromise a huge swath of the internet overnight.
The good news is that this category is highly automatable. You should not be auditing dependencies by hand — you should have tooling do it on every build.
# Node / npm — fail the build on high-severity issues
npm audit --audit-level=high
# .NET — list packages with known vulnerabilities, including transitive ones
dotnet list package --vulnerable --include-transitive
# Keep dependencies current continuously (Dependabot / Renovate in CI)Managing dependency risk:
- Run npm audit and dotnet list package --vulnerable in CI and fail on high/critical findings.
- Enable automated update PRs (Dependabot or Renovate) so patches land continuously, not once a year.
- Remove unused dependencies — the smallest dependency tree is the easiest to secure.
- Prefer actively maintained libraries; an unmaintained package is a future CVE with no fix coming.
- Pin versions with a lockfile and generate an SBOM so you can answer "are we affected?" the day a CVE drops.
A07:2021 — Identification and Authentication Failures
This category covers weaknesses in confirming who a user is: permitting weak passwords, exposing endpoints to credential stuffing and brute force, mishandling sessions, and building JWT flows incorrectly. A very common mistake in SPA/JWT stacks is storing tokens in localStorage, where any XSS can read them, and never rotating or revoking them.
// VULNERABLE: token in localStorage is readable by any injected script
localStorage.setItem('accessToken', token);
// BETTER: short-lived access token in memory, refresh token in an
// HttpOnly, Secure, SameSite cookie the JS cannot read
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh'
});Authentication hardening:
- Offer and encourage multi-factor authentication; it defeats the majority of credential-stuffing attacks.
- Rate-limit and add exponential backoff or lockouts on login and password-reset endpoints.
- Check new passwords against known-breached lists (e.g. Have I Been Pwned k-anonymity API) instead of enforcing arbitrary complexity rules.
- Keep access tokens short-lived, store refresh tokens in HttpOnly cookies, and support server-side revocation.
- Regenerate the session identifier on login and invalidate sessions on logout and password change.
A08:2021 — Software and Data Integrity Failures
Also new in 2021, this category is about trusting code, updates, or data whose integrity you have not verified. It includes insecure deserialization, auto-updates without signature checks, and — the modern headline — compromised CI/CD pipelines and dependencies (supply-chain attacks). Pulling a build script from an unpinned third-party source, or loading a script from a CDN without an integrity hash, both fall here.
<!-- VULNERABLE: if the CDN is compromised, you serve malicious JS -->
<script src="https://cdn.example.com/lib.js"></script>
<!-- FIXED: Subresource Integrity pins the exact expected hash -->
<script src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>Protecting integrity:
- Verify digital signatures on updates, packages, and artifacts before trusting them.
- Use lockfiles and, where possible, pin dependencies by hash to prevent silent substitution.
- Add Subresource Integrity (SRI) to any externally hosted script or stylesheet.
- Never deserialize untrusted data into arbitrary types; use safe formats (JSON with a strict schema) over binary/native serialization.
- Harden CI/CD: least-privilege tokens, protected branches, and reviewed pipeline changes — the build system is production-adjacent.
A09:2021 — Security Logging and Monitoring Failures
You cannot respond to an attack you never saw. Industry breach reports consistently show that intrusions go undetected for weeks or months, usually because the right events were never logged or nobody was watching. This category is about the absence of visibility: failed logins that vanish, access-control denials that are never recorded, and no alerting when something is clearly wrong.
The counterpart risk is over-logging: writing passwords, tokens, or full card numbers into your logs turns your log store into a breach waiting to happen. Log security-relevant events with enough context to investigate, and nothing sensitive.
// Structured, security-relevant logging (Serilog) — log the event, not the secret
_logger.LogWarning(
"Failed login attempt for {Email} from {IpAddress}. Attempt {Count}",
email, ipAddress, attemptCount);
_logger.LogInformation(
"Access denied: user {UserId} tried to access invoice {InvoiceId}",
userId, invoiceId);
// NEVER: _logger.LogInformation("Login with password {Password}", password);Logging and monitoring essentials:
- Log authentication events, access-control failures, input-validation failures, and server-side errors.
- Use structured logging (Serilog, pino) so events are queryable and correlatable across services.
- Centralize logs and set alerts on suspicious patterns (spikes in 401/403, repeated failures from one IP).
- Never log secrets, passwords, tokens, or full PII; redact sensitive fields at the sink.
- Ensure logs are tamper-resistant and retained long enough to support incident investigation.
A10:2021 — Server-Side Request Forgery (SSRF)
SSRF was added in 2021, chosen by the community survey, and it is increasingly relevant in a cloud-native world. It occurs when your server fetches a URL supplied by the user without validating it. The attacker points that URL at something they should not be able to reach — internal services, admin panels, or the cloud metadata endpoint (169.254.169.254) that can hand out credentials.
// VULNERABLE: server fetches any URL the user provides
app.post('/api/fetch-preview', async (req, res) => {
const { url } = req.body;
const data = await fetch(url); // url could be http://169.254.169.254/...
res.send(await data.text());
});The defense is to validate the target against an allowlist, reject internal address ranges, and resolve the hostname before connecting to prevent DNS-rebinding tricks. An allowlist of permitted hosts is far safer than a denylist of blocked ones.
import { lookup } from 'dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['images.example.com', 'cdn.example.com']);
async function isSafeUrl(raw: string): Promise<boolean> {
let url: URL;
try { url = new URL(raw); } catch { return false; }
if (url.protocol !== 'https:') return false;
if (!ALLOWED_HOSTS.has(url.hostname)) return false;
// Resolve and reject private / link-local / loopback ranges
const { address } = await lookup(url.hostname);
const range = ipaddr.parse(address).range();
return !['private', 'loopback', 'linkLocal', 'uniqueLocal', 'reserved'].includes(range);
}
app.post('/api/fetch-preview', async (req, res) => {
if (!(await isSafeUrl(req.body.url))) return res.status(400).end();
const data = await fetch(req.body.url);
res.send(await data.text());
});SSRF defenses:
- Validate user-supplied URLs against an allowlist of hosts and schemes (https only).
- Block requests to private, loopback, and link-local IP ranges, including the cloud metadata IP.
- Resolve DNS and re-check the resolved IP to defeat DNS rebinding.
- Disable unneeded URL schemes (file://, gopher://, ftp://) at the fetch layer.
- Segment the network so application servers cannot reach sensitive internal services at all.
Prioritized Security Checklist
You cannot fix everything at once. Work top-down: the items near the top prevent the most common and most damaging breaches, and each one compounds the value of the rest.
Do these first — highest impact:
- Enforce access control on the server for every endpoint; scope every query to the authenticated user and deny by default.
- Use parameterized queries or a mature ORM everywhere — zero string-concatenated SQL.
- Hash passwords with Argon2id or bcrypt, enforce TLS + HSTS, and add MFA to authentication.
- Never trust client-supplied prices, roles, or limits — recompute and verify server-side.
Do these next — strong hardening:
- Sanitize any HTML you inject and ship a strict Content Security Policy plus core security headers.
- Automate dependency scanning (npm audit, dotnet list package --vulnerable) and updates in CI.
- Store refresh tokens in HttpOnly/Secure/SameSite cookies; keep access tokens short-lived and revocable.
- Lock CORS to an allowlist and validate all user-supplied URLs to prevent SSRF.
Do these continuously — operational maturity:
- Log security-relevant events with structure, centralize them, and alert on anomalies — without logging secrets.
- Add SRI to external scripts, verify artifact signatures, and harden the CI/CD pipeline against supply-chain attacks.
- Threat model new features and write abuse cases alongside user stories.
- Run periodic dependency, configuration, and penetration reviews so drift is caught early.
Security is a continuous discipline, not a milestone. The OWASP Top 10 tells you where attackers look first — make sure that is exactly where your defenses are strongest.
The categories in this list are stable because the underlying mistakes are human, not technological. Frameworks get safer defaults every year, yet broken access control, injection, and misconfiguration keep topping the charts because they come from assumptions rather than from missing features. Treat the server as the only trust boundary, validate and encode at every edge, automate what you can, and monitor what you cannot prevent — and you will already be ahead of the majority of applications on the web.
