Apply modern web development best practices for security, compatibility, and code quality. Use when asked to "apply best practices", "security audit", "modernize code", "code quality review", or "check for vulnerabilities".
Avoid protocol-relative URLs (//example.com/...) — they're an HTTP-era pattern with no benefit on HTTPS-only sites and hide the actual scheme from reviewers.
<script nonce="abc123">
// This inline script is allowed
</script>
Trusted Types (modern DOM-XSS defense)
A strict CSP blocks loading untrusted script files, but it doesn't stop a string from reaching innerHTML, eval, or other DOM-XSS sinks. Trusted Types — Baseline across all major browsers since early 2026 — closes that hole by making sinks reject raw strings and accept only typed objects produced by a named policy.
// One central policy that does the sanitization
const escape = trustedTypes.createPolicy('default', {
createHTML: (s) => DOMPurify.sanitize(s, { RETURN_TRUSTED_TYPE: true })
});
// ❌ This now throws TypeError under enforcement
element.innerHTML = userInput;
// ✅ Goes through the policy
element.innerHTML = escape.createHTML(userInput);
Roll out with Content-Security-Policy-Report-Only first to find every sink usage in your app, then flip to enforcement. Angular has built-in Trusted Types support; React 19+ produces TrustedHTML when Trusted Types are enforced; for everything else, DOMPurify is the de-facto sanitizer.
Subresource Integrity (SRI) for third-party scripts
Pin every <script> and <link rel="stylesheet"> you load from a CDN you don't control. If the CDN is compromised — as happened to polyfill.io in 2024 — the browser refuses to execute a file whose hash doesn't match.
integrity accepts space-separated hashes; include the next version's hash before rotating to avoid downtime. Generate with openssl dgst -sha384 -binary file.js | openssl base64 -A. SRI requires crossorigin and an Access-Control-Allow-Origin response header from the CDN.
Security headers
# Prevent clickjacking — prefer CSP `frame-ancestors` (above); X-Frame-Options
# is the legacy fallback for older browsers.
X-Frame-Options: DENY
# Prevent MIME type sniffing
X-Content-Type-Options: nosniff
# Do NOT send X-XSS-Protection. The legacy browser XSS auditor was deprecated
# and removed (Chrome 78, Edge 17), and in some cases it introduced its own
# vulnerabilities. Use a strict CSP + Trusted Types (below) instead.
# Control referrer information
Referrer-Policy: strict-origin-when-cross-origin
# Permissions policy (formerly Feature-Policy)
Permissions-Policy: geolocation=(), microphone=(), camera=()
No vulnerable libraries
# Check for vulnerabilities
npm audit
yarn audit
# Auto-fix when possible
npm audit fix
# Check specific package
npm ls lodash
// ❌ Recursive merges of untrusted input can pollute Object.prototype
// via __proto__, constructor, or prototype keys.
_.merge(target, userInput); // lodash <4.17.20
$.extend(true, {}, target, userInput); // jQuery deep extend
Object.assign(target, ...userInputs); // safe by itself (shallow), but unsafe
// when target IS Object.prototype-derived
// and userInput contains __proto__
// ✅ For untrusted bags, use a null-prototype object so __proto__ is just a key
const safe = Object.create(null);
Object.assign(safe, userInput); // shallow, no recursion → safe by construction
// ✅ For deep copies, structuredClone drops __proto__ and functions
const deepSafe = structuredClone(userInput);
// ✅ For deep merges, use a library that explicitly blocks dangerous keys
// (e.g. lodash ≥4.17.21 _.mergeWith with a customizer, or deepmerge-ts).
Input sanitization
// ❌ XSS vulnerable
element.innerHTML = userInput;
document.write(userInput);
// ✅ Safe text content
element.textContent = userInput;
// ✅ If HTML needed, sanitize
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);
<!-- ❌ Missing or invalid doctype -->
<HTML>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">
<!-- ✅ HTML5 doctype -->
<!DOCTYPE html>
<html lang="en">
Character encoding
<!-- ❌ Missing or late charset -->
<html>
<head>
<title>Page</title>
<meta charset="UTF-8">
</head>
<!-- ✅ Charset as first element in head -->
<html>
<head>
<meta charset="UTF-8">
<title>Page</title>
</head>
// ❌ Browser detection (brittle)
if (navigator.userAgent.includes('Chrome')) {
// Chrome-specific code
}
// ✅ Feature detection
if ('IntersectionObserver' in window) {
// Use IntersectionObserver
} else {
// Fallback
}
// ✅ Using @supports in CSS
@supports (display: grid) {
.container {
display: grid;
}
}
@supports not (display: grid) {
.container {
display: flex;
}
}
Polyfills (when needed)
Prefer bundling polyfills at build time (Babel/SWC + core-js, or @vitejs/plugin-legacy) targeted by your supported-browsers list. This eliminates the runtime check entirely and avoids shipping polyfill bytes to modern browsers.
If you must load a polyfill at runtime, append a script element — never use document.write (it blocks the parser and is broken in async/deferred contexts):
<script>
if (!('fetch' in window)) {
const s = document.createElement('script');
s.src = '/polyfills/fetch.js';
s.defer = true;
document.head.appendChild(s);
}
</script>
// ❌ Errors in production
console.log('Debug info'); // Remove in production
throw new Error('Unhandled'); // Catch all errors
// ✅ Proper error handling
try {
riskyOperation();
} catch (error) {
// Log to error tracking service
errorTracker.captureException(error);
// Show user-friendly message
showErrorMessage('Something went wrong. Please try again.');
}
// ❌ Source maps exposed in production
// webpack.config.js
module.exports = {
devtool: 'source-map', // Exposes source code
};
// ✅ Hidden source maps (uploaded to error tracker)
module.exports = {
devtool: 'hidden-source-map',
};
// ✅ Or no source maps in production
module.exports = {
devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
};
Strip sourcesContent from production maps when uploading to your error tracker. By default, bundlers embed the full original source inside the .map file — anyone who obtains the map (including via a misconfigured upload step) gets your unminified code. Configure your bundler to omit sourcesContent, or use a Sentry/Bugsnag CLI flag that does so when uploading.
For Vite, prefer sourcemap: 'hidden' over 'true' so the //# sourceMappingURL= comment isn't emitted into the bundle.
<!-- ❌ Distorted images -->
<img src="photo.jpg" width="300" height="100">
<!-- If actual ratio is 4:3, this squishes the image -->
<!-- ✅ Preserve aspect ratio -->
<img src="photo.jpg" width="300" height="225">
<!-- Actual 4:3 dimensions -->
<!-- ✅ CSS object-fit for flexibility -->
<img src="photo.jpg" style="width: 300px; height: 200px; object-fit: cover;">
Permissions & privacy
Request permissions properly
// ❌ Request on page load (bad UX, often denied)
navigator.geolocation.getCurrentPosition(success, error);
// ✅ Request in context, after user action
findNearbyButton.addEventListener('click', async () => {
// Explain why you need it
if (await showPermissionExplanation()) {
navigator.geolocation.getCurrentPosition(success, error);
}
});
Permissions policy
<!-- Restrict powerful features -->
<meta http-equiv="Permissions-Policy"
content="geolocation=(), camera=(), microphone=()">
<!-- Or allow for specific origins -->
<meta http-equiv="Permissions-Policy"
content="geolocation=(self 'https://maps.example.com')">