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
| Rank | Category | Covered in |
|---|---|---|
| A01 | Broken Access Control | Access control |
| A02 | Security Misconfiguration | Security headers |
| A03 | Software Supply Chain Failures | Dependencies |
| A04 | Cryptographic Failures | Passwords; tokens and signatures |
| A05 | Injection | Injection; cross-site scripting |
| A06 | Insecure Design | Throughout: most controls below are design decisions |
| A07 | Authentication Failures | Passwords and login; sessions |
| A08 | Software or Data Integrity Failures | Tokens and signatures; dependencies |
| A09 | Security Logging and Alerting Failures | Logging and errors |
| A10 | Mishandling of Exceptional Conditions | Logging 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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, <img src=x onerror=alert(document.cookie)></p>
<input value="" autofocus onfocus="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 appliesLaxto 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-siteornone). The OWASP CSRF Prevention Cheat Sheet recommends rejecting non-safe methods when it iscross-site, with anOrigincheck as a mandatory fallback for browsers that do not send it. Go 1.25 ships this ashttp.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:
| Algorithm | Minimum parameters | When |
|---|---|---|
| Argon2id | 19 MiB memory, 2 iterations, parallelism 1 | first choice |
| scrypt | N = 2^17, r = 8, p = 1 | when Argon2id is unavailable |
| bcrypt | work factor 10 or more, input limit 72 bytes | legacy systems |
| PBKDF2-HMAC-SHA-256 | 600,000 iterations | when 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
| Practice | Why | Source |
|---|---|---|
| Session IDs with at least 64 bits of entropy from a CSPRNG | prevents guessing | OWASP Session Management |
| New session ID after login and any privilege change | prevents session fixation | same |
__Host- prefix: Secure, Path=/, no Domain | stops subdomains from setting or overriding it | same; MDN Set-Cookie |
HttpOnly | script cannot read it after an XSS | MDN |
| Idle and absolute timeouts, server-side invalidation on logout | limits stolen sessions | OWASP |
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
includeSubDomainsandpreloadonly when every subdomain serves HTTPS, because the preload list is built into browsers. - CSP
frame-ancestorsmakesX-Frame-Optionsobsolete for clickjacking protection in supporting browsers; OWASP recommendsframe-ancestorswhere possible andX-Frame-Options: DENYotherwise. X-XSS-Protection: 0turns 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
ServerandX-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(orpip install --require-hashes) in CI, so a build cannot silently pick up a new version. - Run
npm audit,pip-auditor 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
integrityvalue 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
| Check | How to verify |
|---|---|
| Every object lookup is scoped to the caller | replay another account’s request; expect 403 or 404 |
| No query is built by string concatenation | grep for SQL keywords next to + or template strings |
| Templates escape by default; raw-HTML escape hatches are reviewed | grep for innerHTML, dangerouslySetInnerHTML, v-html, |safe |
| CSP with nonces is enforced | response headers contain Content-Security-Policy with 'nonce- |
| Cross-site POSTs are rejected | send a POST with Sec-Fetch-Site: cross-site; expect 403 |
| Passwords use Argon2id, scrypt or bcrypt at OWASP minimums | read the stored hash prefix and parameters |
Session cookie is __Host-, Secure, HttpOnly, SameSite | inspect Set-Cookie after login; session ID changes at login |
HSTS, nosniff, Referrer-Policy are present | curl -sI plus the HTTP Header Analyzer |
| JWT algorithm is pinned and claims are checked | send a token with "alg":"none"; expect 401 |
| Lockfile is enforced and audits run in CI | CI log shows npm ci and an audit step |
| Logs contain no secrets | search 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.