Most web security best practices come down to a short list of mistakes that keep recurring, and the OWASP Top 10 is the most widely used record of them. This guide takes the 2025 edition and turns each category into practices you can apply and check in a web application: the control, the reason, and a piece of code or configuration you can run. Detailed guidance comes from the OWASP Cheat Sheet Series and MDN; each section links the page it relies on.

All code blocks below were run on 2026-10-02 (Node.js 22 and 24, Python 3.12), and the printed output shown under each one is what they produced.

The OWASP Top 10:2025 at a Glance

RankCategoryCovered in
A01Broken Access ControlAccess control
A02Security MisconfigurationSecurity headers
A03Software Supply Chain FailuresDependencies
A04Cryptographic FailuresPasswords; tokens and signatures
A05InjectionInjection; cross-site scripting
A06Insecure DesignThroughout: most controls below are design decisions
A07Authentication FailuresPasswords and login; sessions
A08Software or Data Integrity FailuresTokens and signatures; dependencies
A09Security Logging and Alerting FailuresLogging and errors
A10Mishandling of Exceptional ConditionsLogging and errors

Two changes from the 2021 edition matter for planning. A03 widens what used to be “Vulnerable and Outdated Components” to all supply chain failures, not only known vulnerabilities, and A10 is new: OWASP describes it as improper error handling, logical errors and failing open, with CWEs such as error messages containing sensitive information and “Not Failing Securely” (A10:2025). Cross-site scripting has been part of the Injection category since 2021.

Access Control: Deny by Default, Check Every Object

Broken access control is A01 for the second edition in a row. The typical bug is not a missing login check but a missing ownership check: the user is logged in, requests /api/invoices/1043, and gets someone else’s invoice because the handler only looked up the ID. This is often called IDOR (insecure direct object reference).

  • Enforce access on the server for every request. Hiding a button in the UI is not a control.
  • Deny by default: a new route should be inaccessible until a rule allows it.
  • Scope queries to the caller: WHERE id = ? AND owner_id = ? instead of fetching by ID and checking later, so a forgotten check fails closed.
  • Use unguessable identifiers where the ID is visible, but treat them as a second layer. A random UUID that leaks through a shared link still needs an ownership check behind it.
  • Test with two accounts: log in as user A, replay user B’s requests, and expect 403 or 404.

Injection: Parameterized Queries

SQL injection happens when user input is concatenated into a query string, so the database reads data as code. The fix in the OWASP SQL Injection Prevention Cheat Sheet is the first one it lists: prepared statements with parameterized queries. The difference is easy to see in a few lines of Python with the standard sqlite3 module:

import sqlite3
db = sqlite3.connect(':memory:')
db.execute('CREATE TABLE users (id INTEGER, email TEXT, role TEXT)')
db.executemany('INSERT INTO users VALUES (?, ?, ?)',
               [(1, 'ana@example.com', 'admin'), (2, 'bo@example.com', 'user')])
email = "nobody@example.com' OR '1'='1"
unsafe = f"SELECT id, role FROM users WHERE email = '{email}'"
print('string-built:', db.execute(unsafe).fetchall())
print('parameterized:', db.execute('SELECT id, role FROM users WHERE email = ?', (email,)).fetchall())
string-built: [(1, 'admin'), (2, 'user')]
parameterized: []

The string-built query returns every user; the parameterized one looks for a user whose email really is nobody@example.com' OR '1'='1 and finds none. Escaping quotes by hand is not a substitute: OWASP lists it last, as “strongly discouraged”, and says it “CANNOT guarantee” protection. Placeholders cannot stand in for table or column names, so map those through an allow-list. The same principle covers shell commands (pass an argument array, not a string, to execFile or subprocess.run) and LDAP or NoSQL queries.

Cross-Site Scripting: Encode Output, Then Add CSP

XSS is injection into HTML. The primary defence in the OWASP XSS Prevention Cheat Sheet is output encoding for the context the data lands in. Templating engines such as React JSX, Vue templates, Jinja2 and Go html/template do this by default; the risk sits in the escape hatches (dangerouslySetInnerHTML, v-html, |safe, innerHTML).

const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const name = '<img src=x onerror=alert(document.cookie)>';
console.log(`<p>Hello, ${name}</p>`);
console.log(`<p>Hello, ${escapeHtml(name)}</p>`);
const attr = '" autofocus onfocus="alert(1)';
console.log(`<input value="${escapeHtml(attr)}">`);
<p>Hello, <img src=x onerror=alert(document.cookie)></p>
<p>Hello, &lt;img src=x onerror=alert(document.cookie)&gt;</p>
<input value="&quot; autofocus onfocus=&quot;alert(1)">

HTML encoding is right for element content and quoted attributes. It is wrong for a URL in href (check the scheme: javascript: survives HTML encoding), for JavaScript inside a <script> block, and for CSS. When users may submit HTML, sanitize it with a maintained library such as DOMPurify instead of writing filters.

A Content Security Policy is the second layer: if an injection slips through, the browser refuses to run the script. A nonce-based strict policy, as recommended on web.dev, needs a new random nonce on every response:

import { randomBytes } from 'node:crypto';
const nonce = randomBytes(16).toString('base64');
const csp = [
  `script-src 'nonce-${nonce}' 'strict-dynamic'`,
  "object-src 'none'",
  "base-uri 'none'",
  "frame-ancestors 'none'",
].join('; ');
console.log(nonce.length, csp.startsWith("script-src 'nonce-"));
console.log(csp.replace(nonce, '{nonce}'));

Each allowed <script> then carries nonce="…" with the same value. Start with Content-Security-Policy-Report-Only to see what would break. The CSP Header Generator builds this policy from its Strict preset, warns that its 'nonce-{RANDOM}' placeholder must be replaced by the server on each response, and outputs Express and Nginx snippets.

CSRF: SameSite Cookies and Fetch Metadata

Cross-site request forgery makes a logged-in user’s browser send a state-changing request from another site. Two browser features now carry most of the defence:

  • SameSite cookies. With SameSite=Lax, the session cookie is not sent on cross-site POSTs, and Chrome applies Lax to cookies that do not set the attribute (MDN: SameSite). Set it explicitly; other browsers’ defaults differ.
  • Fetch Metadata. Browsers send Sec-Fetch-Site (same-origin, same-site, cross-site or none). The OWASP CSRF Prevention Cheat Sheet recommends rejecting non-safe methods when it is cross-site, with an Origin check as a mandatory fallback for browsers that do not send it. Go 1.25 ships this as http.CrossOriginProtection.
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function csrfDecision(method, headers, expectedOrigin) {
  const site = headers['sec-fetch-site'];
  if (site !== undefined) {
    return site === 'cross-site' && !SAFE_METHODS.has(method) ? 'reject' : 'allow';
  }
  const origin = headers['origin']; // browsers without Fetch Metadata: compare Origin
  if (origin === undefined) return SAFE_METHODS.has(method) ? 'allow' : 'reject';
  return origin === expectedOrigin ? 'allow' : 'reject';
}
const me = 'https://app.example.com';
console.log(csrfDecision('POST', { 'sec-fetch-site': 'same-origin' }, me));
console.log(csrfDecision('POST', { 'sec-fetch-site': 'cross-site' }, me));
console.log(csrfDecision('GET', { 'sec-fetch-site': 'cross-site' }, me));
console.log(csrfDecision('POST', { origin: 'https://evil.example' }, me));

Rejecting a POST that carries neither header is a choice; OWASP leaves it to the application. This only works if GET requests never change state. Synchronizer tokens remain the standard answer when you must support old clients or same-site attackers (a compromised subdomain counts as same-site).

Passwords and Login

The OWASP Password Storage Cheat Sheet gives concrete minimums:

AlgorithmMinimum parametersWhen
Argon2id19 MiB memory, 2 iterations, parallelism 1first choice
scryptN = 2^17, r = 8, p = 1when Argon2id is unavailable
bcryptwork factor 10 or more, input limit 72 byteslegacy systems
PBKDF2-HMAC-SHA-256600,000 iterationswhen FIPS-140 compliance is required

Plain SHA-256, even with a salt, is not on the list: it is designed to be fast, which helps an attacker with a stolen database. Node.js 22 has no built-in Argon2, but scrypt is in node:crypto:

import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
// OWASP minimum for scrypt: N = 2^17, r = 8, p = 1 (128 MiB per hash).
const params = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
function hashPassword(password) {
  const salt = randomBytes(16);
  const key = scryptSync(password.normalize('NFC'), salt, 32, params);
  return `scrypt$17$8$1$${salt.toString('base64')}$${key.toString('base64')}`;
}
function verifyPassword(password, stored) {
  const [, , , , salt, key] = stored.split('$');
  const actual = scryptSync(password.normalize('NFC'), Buffer.from(salt, 'base64'), 32, params);
  return timingSafeEqual(actual, Buffer.from(key, 'base64'));
}
const stored = hashPassword('correct horse battery staple');
console.log(stored.split('$').length, stored.length);
console.log(verifyPassword('correct horse battery staple', stored), verifyPassword('Correct horse battery staple', stored));

Storing the parameters with the hash lets you raise them later and rehash on the next successful login. The default maxmem of 32 MiB is too small for these parameters, so it is raised here. With bcrypt, remember the 72-byte limit: a password in Japanese or Korean reaches it after 24 characters in UTF-8. The Bcrypt Generator & Checker on this site hashes and checks with a cost factor from 4 to 31 (default 12) in a Web Worker and shows how long each cost takes on your hardware.

For the login flow itself, NIST SP 800-63B (revision 4) asks verifiers to require at least 15 characters when the password is the only factor, to accept at least 64, to check new passwords against breached and common ones, and to drop composition rules and forced periodic changes (SP 800-63B). Add rate limiting or lockout on failed attempts, return the same message for “unknown user” and “wrong password”, and offer a second factor. Time-based one-time passwords (RFC 6238) are the common one; the TOTP Generator shows the code for a secret and can reproduce the RFC 6238 test vectors at a fixed time, which helps when an implementation disagrees with an authenticator app. The Password Generator makes random passwords for test accounts and service credentials.

Sessions and Cookies

PracticeWhySource
Session IDs with at least 64 bits of entropy from a CSPRNGprevents guessingOWASP Session Management
New session ID after login and any privilege changeprevents session fixationsame
__Host- prefix: Secure, Path=/, no Domainstops subdomains from setting or overriding itsame; MDN Set-Cookie
HttpOnlyscript cannot read it after an XSSMDN
Idle and absolute timeouts, server-side invalidation on logoutlimits stolen sessionsOWASP
import { randomBytes } from 'node:crypto';
const sessionId = randomBytes(32).toString('base64url'); // 256 bits, OWASP minimum is 64
console.log(sessionId.length);
console.log(`Set-Cookie: __Host-session=${'<id>'}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800`);

OWASP’s own example uses SameSite=Strict, which also withholds the cookie when a user follows a link to your site from elsewhere, so they arrive logged out. Lax keeps those links working and still blocks cross-site POSTs; pick Strict for admin areas. To read what a browser actually sends, paste a Cookie header into the Cookie Parser.

Security Headers

Missing headers are a large share of A02, Security Misconfiguration. The values below are those in the OWASP HTTP Headers Cheat Sheet:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: script-src 'nonce-…' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 0
  • HSTS makes the browser use HTTPS for the domain for the given time (two years here). Add includeSubDomains and preload only when every subdomain serves HTTPS, because the preload list is built into browsers.
  • CSP frame-ancestors makes X-Frame-Options obsolete for clickjacking protection in supporting browsers; OWASP recommends frame-ancestors where possible and X-Frame-Options: DENY otherwise.
  • X-XSS-Protection: 0 turns off the old XSS filter, which OWASP and MDN note can create XSS holes in otherwise safe pages; rely on CSP instead.
  • Remove or minimise Server and X-Powered-By, which only help an attacker fingerprint the stack.

To check a deployment, run curl -sI https://your.site/ and paste the response into the HTTP Header Analyzer, which describes 88 headers and flags weak values such as an HSTS max-age under a year, 'unsafe-inline' in a CSP, or a cookie without HttpOnly or Secure.

Tokens and Signatures

JWT. A JSON Web Token is only as trustworthy as its verification. Pin the algorithm you issue instead of reading it from the token: libraries that trusted the header once accepted "alg":"none" and tokens signed with an RSA public key used as an HMAC secret (RFC 8725 §2.1). Also check exp, nbf, iss and aud.

const ALLOWED = new Set(['ES256']); // the one algorithm this service issues
function checkHeader(token) {
  const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
  if (!ALLOWED.has(header.alg)) throw new Error(`rejected alg ${header.alg}`);
  return header.alg;
}
const forged = Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url') + '.' +
  Buffer.from('{"sub":"admin"}').toString('base64url') + '.';
try { checkHeader(forged); } catch (e) { console.log(e.message); }
const hs = Buffer.from('{"alg":"HS256"}').toString('base64url') + '.e30.sig';
try { checkHeader(hs); } catch (e) { console.log(e.message); }

The JWT Decoder shows the header and claims of a token you are debugging; decoding is not verification, and anyone can decode a JWT, so never put secrets in the payload.

Webhook signatures. Services such as GitHub sign webhook bodies with HMAC-SHA256. Verify against the raw request body, before any JSON parsing, and compare in constant time:

import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = 'demo-webhook-secret';
const body = '{"event":"order.paid","id":42}';
const header = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
function verify(rawBody, signatureHeader) {
  const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'));
  const received = Buffer.from(signatureHeader);
  return received.length === expected.length && timingSafeEqual(received, expected);
}
console.log(header);
console.log(verify(body, header), verify(body + '\n', header), verify(JSON.stringify(JSON.parse(body), null, 2), header));

A trailing newline or re-serialized JSON breaks the signature, which is the most common reason a correct secret “does not match”. The HMAC Generator computes the same value (GitHub format, secret as text) and, when a pasted signature differs, tries the usual causes such as a trailing newline or reformatted JSON.

Dependencies and the Supply Chain

A03 covers vulnerable components and tampered builds. The practices that pay off most:

  • Commit the lockfile and install with npm ci (or pip install --require-hashes) in CI, so a build cannot silently pick up a new version.
  • Run npm audit, pip-audit or GitHub Dependabot, and treat fixes for exploitable advisories like any other bug.
  • Grant CI tokens the least scope they need and pin third-party GitHub Actions to a commit SHA.
  • For scripts loaded from a CDN, add Subresource Integrity. The integrity value is the base64 SHA-384 of the exact file:
import { createHash } from 'node:crypto';
const script = 'console.log("hello");\n';
console.log('sha384-' + createHash('sha384').update(script).digest('base64'));

openssl dgst -sha384 -binary file.js | openssl base64 -A gives the same value for the same bytes. If the CDN serves a different file, the browser refuses to run it. Note that the Hash Generator outputs hexadecimal, which is the right format for checksums but not for integrity.

Logging and Errors

A09 and A10 are two sides of the same coin: record enough to notice an attack, and fail without giving one away.

  • Log authentication failures, access-control denials and input validation failures with a timestamp, user and source, and alert on spikes.
  • Never log passwords, session IDs, tokens or full payment data. Before pasting a log into a ticket or an AI assistant, run it through the Secret Redactor, which replaces keys and tokens with placeholders.
  • Return generic errors to clients and keep stack traces server-side.
  • Fail closed: when an authorization service times out or a signature check throws, deny the request instead of falling through to “allowed”.
  • Make multi-step operations atomic. OWASP’s A10 advice is that a transaction interrupted part way must be rolled back completely, not resumed, so an exception does not leave, for example, a debit without the matching credit.

A Checklist You Can Verify

CheckHow to verify
Every object lookup is scoped to the callerreplay another account’s request; expect 403 or 404
No query is built by string concatenationgrep for SQL keywords next to + or template strings
Templates escape by default; raw-HTML escape hatches are reviewedgrep for innerHTML, dangerouslySetInnerHTML, v-html, |safe
CSP with nonces is enforcedresponse headers contain Content-Security-Policy with 'nonce-
Cross-site POSTs are rejectedsend a POST with Sec-Fetch-Site: cross-site; expect 403
Passwords use Argon2id, scrypt or bcrypt at OWASP minimumsread the stored hash prefix and parameters
Session cookie is __Host-, Secure, HttpOnly, SameSiteinspect Set-Cookie after login; session ID changes at login
HSTS, nosniff, Referrer-Policy are presentcurl -sI plus the HTTP Header Analyzer
JWT algorithm is pinned and claims are checkedsend a token with "alg":"none"; expect 401
Lockfile is enforced and audits run in CICI log shows npm ci and an audit step
Logs contain no secretssearch logs for Authorization: and password

Run through it before each release, and again when the next edition of the OWASP Top 10 changes the categories.