Table of Contents
- Input Validation: The First Line of Defense
- Output Encoding: Preventing XSS Attacks
- Avoiding
eval()and Unsafe Dynamic Code Execution - Secure Authentication & Session Management
- Mitigating Cross-Site Request Forgery (CSRF)
- Configuring CORS Securely
- Managing Third-Party Dependencies
- Secure Error Handling
- Leveraging Modern JavaScript Features for Security
- Testing & Tools for Secure Code
- Conclusion
- References
1. Input Validation: The First Line of Defense
What is it? Input validation ensures that data entered by users (or external systems) meets expected criteria before processing. It blocks malformed or malicious input early in the pipeline.
Why it matters: Unvalidated input is the root cause of many attacks, including XSS, SQL injection, and command injection. Even “trusted” inputs (e.g., APIs, internal services) can be compromised.
Best Practices:
- Validate on both client and server: Client-side validation improves UX (e.g., real-time feedback), but server-side validation is mandatory—client-side checks can be bypassed.
- Use strict validation rules: Define allowed data types (e.g., numbers, emails), lengths, formats (e.g., regex for phone numbers), and ranges. Reject unexpected input instead of sanitizing blindly.
- Sanitize when necessary: If input must include HTML (e.g., rich text), use a trusted sanitization library (e.g.,
DOMPurify) to remove malicious tags/attributes.
Example: Server-Side Validation with Node.js
Using validator.js to validate an email and password:
const validator = require('validator');
function validateUserInput(email, password) {
const errors = [];
// Validate email format
if (!validator.isEmail(email)) {
errors.push('Invalid email format');
}
// Validate password length and complexity
if (!validator.isLength(password, { min: 8 })) {
errors.push('Password must be at least 8 characters');
}
if (!validator.matches(password, /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/)) {
errors.push('Password must include a number, lowercase, and uppercase letter');
}
return errors;
}
// Usage
const userEmail = '[email protected]';
const userPassword = 'SecurePass123!';
const validationErrors = validateUserInput(userEmail, userPassword);
if (validationErrors.length > 0) {
console.log('Validation failed:', validationErrors);
} else {
console.log('Input is valid');
}
2. Output Encoding: Preventing XSS Attacks
What is it? Output encoding converts untrusted data into a safe format before rendering it in the browser, ensuring it’s interpreted as text (not executable code).
Why it matters: Cross-Site Scripting (XSS) occurs when untrusted data is injected into HTML, JavaScript, or CSS contexts. Encoding neutralizes scripts by escaping special characters (e.g., < → <).
Best Practices:
- Encode based on context: The encoding rules differ for HTML, JavaScript, CSS, and URLs. Use context-aware libraries (e.g.,
hefor HTML,encodeURIComponentfor URLs). - Prefer text-based APIs: Use
textContentinstead ofinnerHTMLto insert untrusted text into the DOM—it automatically escapes HTML.
Example: Safe DOM Insertion
// UNSAFE: Using innerHTML with untrusted input
const userInput = '<script>alert("XSS")</script>';
document.getElementById('user-comment').innerHTML = userInput; // Executes script!
// SAFE: Using textContent (automatically escapes HTML)
document.getElementById('user-comment').textContent = userInput; // Renders as plain text
// SAFE: Sanitizing HTML with DOMPurify (for rich text)
import DOMPurify from 'dompurify';
const sanitizedHTML = DOMPurify.sanitize(userInput); // Removes <script> tag
document.getElementById('rich-text-comment').innerHTML = sanitizedHTML;
3. Avoiding eval() and Unsafe Dynamic Code Execution
What is it? Functions like eval(), new Function(), and setTimeout(string, delay) execute strings as JavaScript code. They are inherently risky if the string contains untrusted input.
Why it matters: An attacker who controls the input string can execute arbitrary code, leading to data theft, server compromise, or full device access (in Node.js).
Best Practices:
- Never use
eval()with untrusted input: There are almost no legitimate use cases foreval()in modern JS. - Avoid dynamic code generation: Use static code or data-driven logic instead. For example, parse JSON with
JSON.parse()(safe) instead ofeval('(' + jsonString + ')')(unsafe). - If unavoidable: Sandbox the code execution environment (e.g., use a VM or isolate the process) and strictly validate/sanitize the input string.
Example: Unsafe vs. Safe JSON Parsing
const untrustedJSON = '{"name": "Alice", "admin": true}; alert("Hacked");';
// UNSAFE: eval() executes the alert()
const unsafeData = eval('(' + untrustedJSON + ')'); // Triggers alert!
// SAFE: JSON.parse() only parses JSON (rejects non-JSON code)
try {
const safeData = JSON.parse(untrustedJSON); // Throws SyntaxError due to alert()
} catch (error) {
console.error('Invalid JSON:', error);
}
4. Secure Authentication & Session Management
What is it? Authentication verifies user identity; session management maintains that identity across requests. Weaknesses here lead to account takeover, session hijacking, or password leaks.
Best Practices:
- Store passwords securely:
- Never store plaintext passwords. Use adaptive hashing algorithms like bcrypt (Node.js:
bcrypt) or Argon2 (slower, more secure against brute-force attacks). - Avoid outdated algorithms (e.g., MD5, SHA-1, even SHA-256 without a salt).
- Never store plaintext passwords. Use adaptive hashing algorithms like bcrypt (Node.js:
- Use secure session cookies:
- Mark cookies as
HttpOnly(prevents access viadocument.cookie, mitigating XSS),Secure(only sent over HTTPS), andSameSite=Strict(blocks CSRF). - Set short expiration times (e.g., 15–30 minutes) and implement session timeout on inactivity.
- Mark cookies as
- JWT Best Practices:
- Store JWTs in
HttpOnlycookies (notlocalStorage—vulnerable to XSS). - Use short expiration times (e.g., 15 minutes) and refresh tokens with stricter security.
- Sign tokens with strong algorithms (e.g., HS256 with a secret, or RS256 with asymmetric keys).
- Store JWTs in
Example: Secure Password Hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower, more secure
// Hash a password before storing
async function hashPassword(password) {
return await bcrypt.hash(password, SALT_ROUNDS);
}
// Verify a password against a stored hash
async function verifyPassword(password, storedHash) {
return await bcrypt.compare(password, storedHash);
}
// Usage
const userPassword = 'SecurePass123!';
hashPassword(userPassword).then(hash => {
console.log('Stored hash:', hash); // e.g., $2b$12$...
verifyPassword(userPassword, hash).then(isMatch => {
console.log('Password match:', isMatch); // true
});
});
5. Mitigating Cross-Site Request Forgery (CSRF)
What is it? CSRF tricks a user into executing an action on a site they’re authenticated to (e.g., transferring funds, changing email) without their consent. The attacker crafts a malicious link/form that triggers a request to the target site.
Why it matters: Authenticated users unknowingly perform actions on behalf of the attacker, leading to data loss or account takeover.
Best Practices:
- Use CSRF tokens: Generate a unique, cryptographically random token per user session. Include it in requests (e.g., form fields, headers) and validate it server-side.
- Set
SameSite=Strictcookies: Browsers block cookies from being sent in cross-site requests, preventing CSRF. - Check
Origin/Refererheaders: Reject requests where theOrigin(preferred) orRefererheader doesn’t match your domain.
Example: CSRF Token in a Form
- Server generates a token and stores it in the user’s session:
// Express.js example
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true }); // Token stored in HttpOnly cookie
app.get('/transfer-funds', csrfProtection, (req, res) => {
res.render('transfer-form', { csrfToken: req.csrfToken() }); // Pass token to form
});
- Form includes the token as a hidden input:
<form action="/transfer-funds" method="POST">
<input type="hidden" name="_csrf" value="{{ csrfToken }}"> <!-- CSRF token -->
<input type="text" name="recipient" required>
<input type="number" name="amount" required>
<button type="submit">Transfer</button>
</form>
- Server validates the token on submission:
app.post('/transfer-funds', csrfProtection, (req, res) => {
// If token is invalid, csurf middleware throws an error
processTransfer(req.body.recipient, req.body.amount);
res.send('Transfer initiated');
});
6. Configuring CORS Securely
What is it? Cross-Origin Resource Sharing (CORS) is a browser security feature that controls which domains can access your server’s resources (e.g., APIs).
Why it matters: Misconfigured CORS allows attackers to steal sensitive data (e.g., user sessions, API keys) from cross-origin requests.
Best Practices:
- Avoid
Access-Control-Allow-Origin: *: This allows any domain to access your resources. Use specific origins (e.g.,https://trusted-app.com) instead. - Restrict allowed methods/headers: Limit
Access-Control-Allow-Methodsto only what’s needed (e.g.,GET, POST) andAccess-Control-Allow-Headersto expected headers (e.g.,Content-Type). - Don’t expose sensitive data: CORS doesn’t protect data in the response body—ensure cross-origin endpoints don’t return secrets (e.g., passwords, tokens).
Example: Secure CORS Configuration
// Express.js with cors middleware
const cors = require('cors');
const corsOptions = {
origin: 'https://trusted-frontend.com', // Allow only this origin
methods: ['GET', 'POST'], // Allow only these methods
allowedHeaders: ['Content-Type', 'Authorization'], // Allow only these headers
maxAge: 86400, // Cache preflight response for 24 hours
credentials: true // Allow cookies in cross-origin requests (if needed)
};
app.use(cors(corsOptions));
7. Managing Third-Party Dependencies
What is it? Most JS projects rely on third-party packages (e.g., react, lodash). These packages can contain vulnerabilities (e.g., outdated code, backdoors).
Why it matters: 80–90% of application vulnerabilities come from dependencies (OWASP Top 10). For example, the 2018 event-stream attack compromised thousands of projects via a malicious dependency.
Best Practices:
- Audit dependencies: Use
npm audit(built into npm) orsnyk testto scan for vulnerabilities. - Keep dependencies updated: Use
npm updateor tools like Dependabot to automate updates. Avoid “version ranges” (e.g.,^1.0.0)—pin versions (e.g.,1.2.3) and usepackage-lock.jsonto ensure consistency. - Minimize dependencies: Only include packages you need. Smaller dependency trees reduce attack surface.
Example: Auditing with npm
npm audit # Scans for vulnerabilities
npm audit fix # Automatically fixes compatible vulnerabilities
8. Secure Error Handling
What is it? Error handling controls how your application responds to failures (e.g., network errors, invalid input). Poorly handled errors can leak sensitive information.
Why it matters: Exposing stack traces, database queries, or API keys to users helps attackers exploit your system. For example, a stack trace might reveal your server’s file structure or database schema.
Best Practices:
- Use generic user-facing messages: Tell users what went wrong (e.g., “Payment failed”), not why (e.g., “SQL error: duplicate key”).
- Log details server-side: Record stack traces, timestamps, and request IDs in internal logs for debugging.
- Avoid silent failures: Unhandled errors can crash your app or leave it in an inconsistent state. Use
try/catch(client/server) and global error handlers.
Example: Secure Error Handling
// Node.js route handler
app.get('/user/:id', async (req, res) => {
try {
const user = await db.getUserById(req.params.id);
if (!user) {
return res.status(404).send('User not found'); // Generic message
}
res.json(user);
} catch (error) {
// Log details internally (e.g., to Sentry, Datadog)
console.error('User fetch failed:', {
error: error.stack,
userId: req.params.id,
timestamp: new Date()
});
// Send generic message to user
res.status(500).send('An unexpected error occurred. Please try again later.');
}
});
9. Leveraging Modern JavaScript Features for Security
Strict Mode: Enforce stricter parsing and error handling with "use strict"; at the top of files/functions. Blocks unsafe practices like undeclared variables, with statements, and modifying read-only properties.
Example:
"use strict";
x = 10; // Throws ReferenceError (x is undeclared)
ES Modules (ESM): ESM (import/export) provides better scoping than CommonJS (require), reducing global pollution and accidental variable leaks.
Block Scoping: Use let/const instead of var to limit variable scope to blocks (e.g., if, for), preventing unintended global access.
10. Testing & Tools for Secure Code
Static Analysis: Use ESLint with security plugins (e.g., eslint-plugin-security) to catch issues during development:
npm install eslint eslint-plugin-security --save-dev
SAST Tools: Static Application Security Testing tools (e.g., SonarQube, Semgrep) scan code for vulnerabilities without execution.
DAST Tools: Dynamic Application Security Testing tools (e.g., OWASP ZAP, Burp Suite) simulate attacks on running apps to find runtime vulnerabilities.
Dependency Scanning: Snyk, GitHub Advanced Security, or GitLab Dependency Scanning automate vulnerability detection in dependencies.
Conclusion
Secure coding in JavaScript is a continuous process, not a one-time task. By validating input, encoding output, avoiding unsafe functions, and following the practices outlined here, you can significantly reduce your application’s attack surface. Remember: defense in depth is key—no single practice will protect you from all threats. Stay informed about new vulnerabilities (e.g., via OWASP, CVE databases) and regularly audit your code and dependencies.