JavaScript Security Basics 2026: Protect Your Apps from the Top 10 Threats
Every JavaScript application is a target. Cross-site scripting, injection attacks, broken authentication, and data exposure are not theoretical problems — they are exploited daily against real applications. Security is not something you add at the end. It is a mindset that shapes every line of code you write. This guide covers the security fundamentals every JavaScript developer needs, from frontend to backend.
This lesson ties together many concepts from earlier lessons: DOM manipulation, fetch API, Express.js, and environment configuration. Security vulnerabilities often hide at the boundaries where these systems interact.
Cross-Site Scripting (XSS)
XSS is the most common web vulnerability. It happens when an attacker injects malicious JavaScript into your page through user-supplied data. There are three types: Stored (saved in database, served to all users), Reflected (embedded in a URL, executed when victim clicks), and DOM-based (manipulated entirely in the browser).
// VULNERABLE: inserting user input as HTML
const username = getUserInput(); // "<script>steal(document.cookie)</script>"
element.innerHTML = `Welcome, ${username}!`; // Executes the script!
// SAFE: use textContent for plain text
element.textContent = `Welcome, ${username}!`; // Displays as text, not HTML
// VULNERABLE: building HTML strings
const comment = userComment; // Contains malicious HTML
container.innerHTML += `<div class="comment">${comment}</div>`;
// SAFE: create elements programmatically
const div = document.createElement('div');
div.className = 'comment';
div.textContent = comment; // Automatically escaped
container.appendChild(div);
The golden rule: Never insert untrusted data as HTML. Use textContent instead of innerHTML. If you must render HTML, use a sanitization library like DOMPurify:
import DOMPurify from 'dompurify';
// Sanitize user-supplied HTML (strips dangerous tags/attributes)
const cleanHTML = DOMPurify.sanitize(userInput);
element.innerHTML = cleanHTML; // Safe: malicious code removed
On the server side (Express), escape HTML in responses and set the Content-Type header correctly. Never reflect user input directly in HTML without encoding.
Cross-Site Request Forgery (CSRF)
CSRF tricks a user’s browser into making unwanted requests to a site where they are authenticated. The attacker’s page submits a form to your API, and the browser automatically includes the user’s cookies.
// Attacker's page:
// <form action="https://yourbank.com/transfer" method="POST">
// <input name="to" value="attacker-account">
// <input name="amount" value="10000">
// </form>
// <script>document.forms[0].submit()</script>
// PROTECTION 1: CSRF tokens
import crypto from 'node:crypto';
// Generate a token per session
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
// Middleware: attach token to session, verify on mutations
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process transfer...
});
// PROTECTION 2: SameSite cookies (modern approach)
res.cookie('session', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // Not sent on cross-site requests
maxAge: 3600000,
});
SameSite=Strict cookies are the simplest CSRF protection — the browser refuses to send the cookie on cross-origin requests. For APIs, using custom headers (like X-Requested-With) also works because browsers do not send custom headers in cross-origin form submissions without CORS preflight.
Injection Attacks
Injection attacks insert malicious code into queries, commands, or templates. SQL injection is the classic example, but JavaScript apps are also vulnerable to NoSQL injection, command injection, and template injection.
// VULNERABLE: SQL injection
const query = `SELECT * FROM users WHERE email = '${userInput}'`;
// If userInput is: ' OR 1=1 --
// Query becomes: SELECT * FROM users WHERE email = '' OR 1=1 --'
// Returns ALL users!
// SAFE: parameterized queries
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[userInput]
);
// VULNERABLE: NoSQL injection (MongoDB)
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password,
});
// If password is { "$gt": "" }, this matches any password!
// SAFE: validate and sanitize types
const email = String(req.body.email);
const password = String(req.body.password);
const user = await db.collection('users').findOne({ email, password });
// VULNERABLE: command injection
const { exec } = require('child_process');
exec(`ping ${userInput}`); // If input is "google.com; rm -rf /"
// SAFE: use execFile with arguments array
import { execFile } from 'node:child_process';
execFile('ping', ['-c', '4', userInput]);
The pattern is consistent: never build queries, commands, or templates by concatenating user input. Use parameterized queries for databases, argument arrays for commands, and validated types for NoSQL.
Authentication & Session Security
// NEVER store passwords in plain text
import bcrypt from 'bcrypt';
// Registration: hash the password
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(userPassword, saltRounds);
// Store hashedPassword in database
// Login: compare password against hash
const isValid = await bcrypt.compare(userPassword, storedHash);
if (!isValid) {
// Use a generic message — don't reveal which field is wrong
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT best practices
import jwt from 'jsonwebtoken';
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{
expiresIn: '15m', // Short-lived access tokens
algorithm: 'HS256',
}
);
// Verify tokens in middleware
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Use short-lived access tokens (15 minutes) with refresh tokens for re-authentication. Store refresh tokens in httpOnly cookies, not localStorage — localStorage is accessible to any JavaScript on the page, including XSS payloads.
CORS: Cross-Origin Resource Sharing
CORS controls which domains can make requests to your API. Without CORS headers, browsers block cross-origin requests by default.
import cors from 'cors';
// BAD: allow all origins (disables browser security)
app.use(cors()); // Access-Control-Allow-Origin: *
// GOOD: whitelist specific origins
app.use(cors({
origin: ['https://myapp.com', 'https://staging.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
}));
// Dynamic origin based on environment
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
}));
Never use Access-Control-Allow-Origin: * with credentials: true. This combination is blocked by browsers for good reason — it would let any website make authenticated requests to your API. Always specify exact origins in production.
Content Security Policy (CSP)
CSP tells the browser which resources are allowed to load on your page. It is the strongest defense against XSS because even if an attacker injects a script tag, the browser refuses to execute it if it violates the CSP.
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"], // Only same-origin by default
scriptSrc: ["'self'", "https://cdn.example.com"], // Scripts from self + CDN
styleSrc: ["'self'", "'unsafe-inline'"], // Styles (inline needed for some frameworks)
imgSrc: ["'self'", "https:", "data:"], // Images from HTTPS sources
connectSrc: ["'self'", "https://api.example.com"], // API calls
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"], // Block Flash/Java
upgradeInsecureRequests: [], // Force HTTPS
},
}));
Start with a strict CSP and loosen it only when needed. Use Content-Security-Policy-Report-Only header first to test without breaking your site — it logs violations without blocking them.
Input Validation & Sanitization
Validate on both client (for UX) and server (for security). Client-side validation is easily bypassed — anyone can send requests directly to your API.
import { z } from 'zod';
// Define strict schemas
const createUserSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(128),
name: z.string().min(1).max(100).trim(),
age: z.number().int().min(13).max(150).optional(),
});
// Validate in middleware
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req.body = result.data; // Use parsed/sanitized data
next();
};
}
app.post('/api/users', validate(createUserSchema), createUser);
Always validate: data types (string, number, boolean), length limits, format constraints (email, URL), ranges (min/max), and enum values. Reject anything unexpected. The Zod library makes this declarative and type-safe.
Dependency Security
Your node_modules folder contains thousands of packages written by strangers. Any one of them could contain malicious code or known vulnerabilities.
# Audit your dependencies for known vulnerabilities
npm audit
# Fix vulnerabilities automatically
npm audit fix
# Check for outdated packages
npm outdated
# Use lockfiles (package-lock.json) to pin exact versions
# ALWAYS commit your lockfile to Git
Best practices: Run npm audit in your CI pipeline and fail builds with critical vulnerabilities. Use Socket or Snyk for deeper supply chain analysis. Minimize dependencies — every package you install is an attack surface. Review new dependencies before installing: check download counts, maintenance activity, and whether the package does what you actually need.
HTTPS & Security Headers
HTTPS encrypts data in transit. Security headers protect against various attack classes. Use the helmet middleware for Express to set them all at once.
import helmet from 'helmet';
app.use(helmet());
// Sets these headers automatically:
// Strict-Transport-Security: max-age=15552000; includeSubDomains
// X-Content-Type-Options: nosniff
// X-Frame-Options: DENY
// X-XSS-Protection: 0 (deprecated, CSP is better)
// Referrer-Policy: no-referrer
// And more...
Strict-Transport-Security (HSTS) tells browsers to only use HTTPS. X-Content-Type-Options: nosniff prevents browsers from guessing file types (which can turn an uploaded text file into an executed script). X-Frame-Options: DENY prevents clickjacking by blocking your site from being embedded in iframes.
Security Checklist
Use this checklist for every JavaScript application you deploy:
Frontend: Escape all user input before rendering (use textContent, not innerHTML). Implement CSP headers. Never store sensitive data in localStorage. Validate input on the client for UX, but never trust it for security. Use SRI (Subresource Integrity) hashes for CDN scripts.
Backend: Use parameterized queries — never concatenate user input into queries. Hash passwords with bcrypt (cost factor 12+). Set httpOnly, secure, and sameSite flags on cookies. Validate and sanitize all input with a schema validator. Implement rate limiting on authentication endpoints. Use CORS with specific origins, not wildcards.
Infrastructure: Enforce HTTPS everywhere. Keep Node.js and dependencies updated. Run npm audit in CI. Use environment variables for secrets — never hardcode them. Set security headers with helmet. Enable logging for security events.
Authentication: Use short-lived JWTs (15 minutes). Store refresh tokens in httpOnly cookies. Implement account lockout after failed login attempts. Use generic error messages — never reveal whether an email exists. Add multi-factor authentication for sensitive operations.
Security is not a feature you ship once — it is an ongoing practice. Stay informed about new vulnerabilities, keep your dependencies updated, and treat every piece of user input as potentially hostile. The difference between a secure application and a breached one is often just a few lines of code.