Introduction
Web security is not a feature — it is a discipline. Every developer shipping code to the internet in 2026 must understand the threat landscape, the tools to defend against it, and the patterns that turn fragile applications into hardened systems. This guide covers the topics that matter most: from OWASP Top 10 vulnerabilities to production-grade security headers, from JWT best practices to dependency scanning with SBOMs.
Whether you are building a Next.js dashboard, a FastAPI backend, or a static Jamstack site, the principles here apply directly to your stack.
---
1. OWASP Top 10 (2025 Edition) Overview
The OWASP Top 10 is the industry-standard awareness document for web application security. Here is the 2025 list with practical examples and mitigations.
| Rank | Category | Example |
|------|----------|---------|
| 1 | Broken Access Control | User A reads User B's private data by changing an ID in the URL |
| 2 | Cryptographic Failures | Storing passwords in plaintext or using MD5 |
| 3 | Injection | SQL injection via unsanitized search input |
| 4 | Insecure Design | No rate limiting on password reset endpoints |
| 5 | Security Misconfiguration | Default credentials left in production |
| 6 | Vulnerable Components | Using a library with a known CVE |
| 7 | Auth & Session Failures | JWT secrets leaked in client-side code |
| 8 | Data Integrity Failures | Accepting unsigned serialized objects (e.g., Pickle) |
| 9 | Logging & Monitoring Failures | No alerting on repeated 403s |
| 10 | SSRF | Server fetches attacker-controlled URL internally |
**Example: Broken Access Control (the #1 risk)**
# BAD — no ownership check
@app.get("/api/orders/{order_id}")
def get_order(order_id: int):
order = db.query(Order).get(order_id)
return order
# GOOD — enforce ownership
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user=Depends(get_current_user)):
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == user.id
).first()
if not order:
raise HTTPException(status_code=404)
return order
**Key takeaway:** Never trust user-supplied identifiers. Always verify ownership server-side.
---
2. Authentication Security
Authentication is the most attacked surface area on any web application. Here are the patterns that hold up in production.
JWT Best Practices
JSON Web Tokens are everywhere, but they are easy to misuse.
// GOOD: JWT configuration for production
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{
sub: user.id,
role: user.role,
session_id: session.uuid // allow server-side revocation
},
process.env.JWT_SECRET, // never hardcode
{
algorithm: 'RS256', // asymmetric — never 'none' or 'HS256' in distributed systems
expiresIn: '15m', // short-lived access tokens
issuer: 'myapp.example.com',
audience: 'myapp-api'
}
);
**JWT checklist:**
Session Management Comparison
| Approach | Best For | Weakness |
|----------|----------|----------|
| HTTP-only cookies + session ID | Server-rendered apps | Requires server-side store |
| JWT access + refresh tokens | SPAs, mobile apps | Revocation is harder |
| OAuth2 + PKCE | Third-party auth | Complexity |
| WebAuthn / Passkeys | High-security apps | Browser support gaps |
OAuth2 with PKCE (Recommended Flow)
// Authorization code flow with PKCE (Proof Key for Code Exchange)
const crypto = require('crypto');
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// Step 1: Redirect user with code_challenge
// GET https://provider.com/auth?response_type=code&code_challenge=<challenge>&code_challenge_method=S256
// Step 2: Exchange code for token (verifier must match)
// POST https://provider.com/token
// Body: { code, code_verifier: verifier, client_id, redirect_uri }
**Never use the implicit grant (deprecated).** Always use the authorization code flow with PKCE, even for first-party apps.
---
3. Content Security Policy (CSP)
CSP is your last line of defense against XSS. It tells the browser what sources are allowed to load scripts, styles, fonts, and other resources.
Baseline CSP Header
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://images.example.com;
connect-src 'self' https://api.example.com;
font-src 'self' https://fonts.gstatic.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
How to Set CSP in Popular Frameworks
// Express.js
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"
);
next();
});
# FastAPI middleware
from starlette.middleware.base import BaseHTTPMiddleware
class CSPSecurityMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
return response
CSP Pitfalls to Avoid
| Anti-Pattern | Why It Hurts |
|---|---|
| `script-src 'unsafe-inline'` | Defeats CSP — any inline script runs |
| `script-src 'unsafe-eval'` | Allows `eval()` and similar |
| Using `https:` as a whitelist | Too broad — allows any HTTPS-hosted script |
| Missing `object-src 'none'` | Flash/Silverlight plugins bypass CSP |
| Not using `report-uri` / `report-to` | You never know when CSP blocks something legitimate |
**Pro tip:** Deploy CSP in report-only mode first (`Content-Security-Policy-Report-Only`), monitor violations, then enforce. This prevents breaking your production site.
---
4. CORS Configuration Best Practices
CORS is not a security boundary — it is a browser-enforced access control mechanism. It does not protect your API from direct requests (curl, Postman), only from cross-origin browser requests.
Correct CORS Configuration
// Express.js — production CORS setup
const cors = require('cors');
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS.split(','), // explicit, not '*'
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['X-Request-ID'],
credentials: true, // only if you need cookies
maxAge: 86400 // preflight cache — 24 hours
};
app.use(cors(corsOptions));
# FastAPI
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
allow_credentials=False,
max_age=86400,
)
CORS Rules of Thumb
---
5. SQL Injection Prevention
SQL injection is the oldest trick in the book, and it still works because developers concatenate user input into SQL strings.
Never Do This
# VULNERABLE — never concatenate user input
username = request.form["username"]
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
An attacker sends `' OR '1'='1` and the WHERE clause becomes `WHERE username = '' OR '1'='1'` — every row in the table is returned.
Always Do This
# SAFE — parameterized queries
cursor.execute(
"SELECT * FROM users WHERE username = %s",
(username,)
)
// SAFE — parameterized queries with node-postgres
const result = await db.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
ORMs Are Not Magic Bullets
ORMs protect against basic SQLi but can still leak data through:
// VULNERABLE — NoSQL injection
app.get('/api/users', async (req, res) => {
const { username } = req.query;
// If username is { "$ne": "" }, this returns ALL users
const users = await User.find({ username });
res.json(users);
});
**Fix:** Validate that the input is a string, not an object or operator expression.
---
6. XSS Types and Prevention
Cross-Site Scripting (XSS) remains pervasive. There are three distinct types.
Reflected XSS
The payload is part of the request (e.g., URL query parameter) and reflected in the response immediately.
Example: https://example.com/search?q=<script>alert('xss')</script>
Stored XSS
The payload is persisted on the server (e.g., in a comment, user bio) and served to every visitor.
Example: A comment containing <script>fetch('/api/steal').then(...)</script>
DOM-Based XSS
The vulnerability lives entirely in client-side JavaScript. The server response is clean, but the browser executes attacker-controlled input via `innerHTML`, `document.write`, or `eval`.
// VULNERABLE — DOM XSS
const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('greeting').innerHTML = `Hello, ${name}`;
// SAFE — use textContent, not innerHTML
document.getElementById('greeting').textContent = `Hello, ${name}`;
XSS Prevention Table
| Context | Safe Approach | Dangerous Approach |
|---------|--------------|-------------------|
| HTML body | `textContent`, template escaping | `innerHTML`, `outerHTML` |
| HTML attribute | `setAttribute()` with safe values | String concatenation into `onclick` or `href` |
| JavaScript string | JSON.stringify + proper encoding | Direct concatenation into `eval` or `setTimeout` string |
| CSS | Use CSS custom properties | Dynamic `url()` or `expression()` |
| URL | Validate against allowlist | `javascript:` URLs in `` |
**Defense in depth:** CSP + output encoding + input validation. No single layer is enough.
---
7. HTTPS/TLS Fundamentals
HTTPS is non-negotiable in 2026. Every site should be HTTPS-only with HSTS.
What Every Developer Needs to Know
| Concept | What It Means |
|---------|---------------|
| TLS 1.3 | Current standard. Faster handshake, removed insecure ciphers. |
| Certificate validation | The client verifies the cert chain against trusted CAs |
| SNI | Server Name Indication — lets one server host multiple TLS certs |
| HSTS | HTTP Strict Transport Security — tells browsers to always use HTTPS |
| OCSP Stapling | Server sends proof of cert validity during handshake |
Setting Up HSTS
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Redirect HTTP to HTTPS
// Express.js
app.use((req, res, next) => {
if (!req.secure && req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
TLS Configuration Check (2026 Minimum)
# nginx TLS config — modern profile
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
Test your TLS setup with: `openssl s_client -connect example.com:443 -tls1_3`
---
8. Dependency Scanning and SBOM
Supply chain attacks are the fastest-growing threat vector. In 2026, the US Executive Order on cybersecurity and global regulations require Software Bills of Materials (SBOM) for production software.
Dependency Scanning Tools
| Tool | Scope | Integration |
|------|-------|-------------|
| `npm audit` / `yarn audit` | Node.js packages | CI/CD pipeline gate |
| `pip-audit` | Python packages | pre-commit hooks |
| `trivy` | Containers, OS packages, IaC | GitHub Actions, GitLab CI |
| `snyk` | Multi-language, container | PR checks |
| `dependabot` | GitHub-hosted repos | Auto-PRs for vulnerable deps |
| `grype` | Containers, filesystem | Fast, free, OSS |
Running a Scan in CI
# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
format: 'spdx-json'
output-file: 'sbom.spdx.json'
What Goes Into an SBOM
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"metadata": {
"component": {
"name": "my-app",
"version": "1.2.3",
"type": "application"
}
},
"components": [
{
"name": "express",
"version": "4.18.2",
"type": "library",
"purl": "pkg:npm/express@4.18.2"
}
]
}
**Action item:** Run `pip freeze > requirements.txt` or `npm list --json > deps.json` and pipe it through an SBOM generator in your CI pipeline. Store the SBOM alongside your release artifact.
---
9. Security Headers Checklist
Apply these HTTP response headers to every page and every API response.
| Header | Value | Purpose |
|--------|-------|---------|
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains` | Enforce HTTPS |
| `Content-Security-Policy` | See Section 3 | Prevent XSS and data injection |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
| `X-Frame-Options` | `DENY` | Prevent clickjacking |
| `X-XSS-Protection` | `0` | Disable legacy XSS filter (does more harm than good) |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer data leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Restrict browser API access |
| `Cache-Control` | `no-store, no-cache, must-revalidate` | Prevent sensitive data caching |
Apply Them in One Shot
// Express.js helmet — sets most security headers automatically
const helmet = require('helmet');
app.use(helmet());
// Or set manually for fine control
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
# FastAPI with SecureHeaders middleware
from secure import SecureHeaders
secure_headers = SecureHeaders()
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
secure_headers.framework.fastapi(response)
return response
---
10. API Security Basics
APIs are the backbone of modern web applications and a prime attack target.
Rate Limiting
// Express.js with express-rate-limit
const rateLimit = require('express-rate-limit');
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests' }
});
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 attempts per minute
message: { error: 'Too many auth attempts' }
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
Input Validation
Use a schema validation library. Never trust incoming data.
// Zod validation — reject unexpected fields and bad types
const { z } = require('zod');
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(12).max(128),
role: z.enum(['user', 'admin']).default('user'),
}).strict(); // strips or rejects extra fields
app.post('/api/users', (req, res) => {
const parsed = CreateUserSchema.parse(req.body);
// parsed is now type-safe and validated
});
Proper Error Handling
Never leak stack traces, database schemas, or internal paths to API consumers.
# BAD — leaks internals
@app.exception_handler(Exception)
async def debug_error(request, exc):
return JSONResponse(
status_code=500,
content={"error": str(exc), "traceback": traceback.format_exc()}
)
# GOOD — sanitized errors with logging
import logging
logger = logging.getLogger(__name__)
@app.exception_handler(HTTPException)
async def http_error(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail}
)
@app.exception_handler(Exception)
async def generic_error(request, exc):
logger.error("Internal error", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": "An internal error occurred"}
)
API Security Checklist
---
11. Production Security Checklist
A practical runbook to harden any web application before launch.
Infrastructure
Application
Process
---
Conclusion
Web security in 2026 is about layers. No single header, library, or practice will protect your application. The combination of CSP + parameterized queries + short-lived tokens + dependency scanning + proper CORS configuration + HSTS creates a defense-in-depth posture that raises the bar for attackers.
Start with the production checklist above and work through each item. Automate everything you can — security scanning in CI, dependency updates, SBOM generation. The goal is not perfect security (it does not exist), but rather making your application resilient enough that attackers move on to an easier target.
**Remember:** security is not a one-time audit. It is a continuous practice embedded into your development workflow. Ship safely.
---
*Last updated: May 2026*