Chapter 46: Security Hardening
Security is not a feature you add at the end. It is a quality that permeates every layer of your application. This chapter covers the OWASP Top 10 threats and how to defend against them in our stack.
OWASP Top 10 for React Applications
1. Broken Access Control (#1 OWASP)
Prevention: The AuthorizationService from Chapter 29 enforces permissions server-side on every request. Never rely on frontend-only access control.
// Every API endpoint checks permissions
const handler = Effect.gen(function* () {
const authz = yield* AuthorizationService;
yield* authz.requirePermission(userId, "project:delete", organizationId);
// Only then execute the action
});
2. Injection (#3 OWASP)
Prevention: Prisma uses parameterized queries by default. Never use $queryRawUnsafe:
// ✅ Safe — parameterized
await prisma.project.findMany({
where: { name: { contains: userInput } },
});
// ✅ Safe — parameterized raw query
await prisma.$queryRaw`SELECT * FROM projects WHERE name = ${userInput}`;
// ❌ DANGEROUS — SQL injection
await prisma.$queryRawUnsafe(`SELECT * FROM projects WHERE name = '${userInput}'`);
3. XSS (Cross-Site Scripting) (#7 OWASP)
Prevention: React escapes all rendered content by default. The one danger is dangerouslySetInnerHTML:
// ✅ Safe — React escapes this
<div>{userContent}</div>
// ❌ Dangerous — renders raw HTML
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// If you MUST render HTML, sanitize it first:
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />
4. CSRF (Cross-Site Request Forgery)
Prevention: Use SameSite=Strict cookies and validate the Origin header:
// Session cookie configuration
res.cookie("session", token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: "strict", // No cross-origin requests
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
Content Security Policy (CSP)
// Nginx or middleware CSP header
const cspHeader = [
"default-src 'self'",
"script-src 'self'", // No inline scripts
"style-src 'self' 'unsafe-inline'", // Tailwind needs inline styles
"img-src 'self' data: https://avatars.githubusercontent.com",
"font-src 'self'",
"connect-src 'self' https://api.taskforge.dev", // API endpoints
"frame-ancestors 'none'", // Prevent framing
"base-uri 'self'",
"form-action 'self'",
].join("; ");
// In Nginx:
// add_header Content-Security-Policy "default-src 'self'; script-src 'self'; ..." always;
Input Validation at Every Boundary
// API route with Effect Schema validation
app.post("/api/projects", async (req, res) => {
const result = Schema.decodeUnknownEither(CreateProjectSchema)(req.body);
if (Either.isLeft(result)) {
return res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid input",
details: formatParseError(result.left),
},
});
}
// Proceed with validated data
const project = await Effect.runPromise(
createProject(result.right, req.userId)
);
res.status(201).json({ data: project });
});
Rate Limiting
// Simple in-memory rate limiter
const rateLimits = new Map<string, { count: number; resetAt: number }>();
function rateLimit(key: string, maxRequests: number, windowMs: number): boolean {
const now = Date.now();
const entry = rateLimits.get(key);
if (!entry || now > entry.resetAt) {
rateLimits.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= maxRequests) {
return false;
}
entry.count++;
return true;
}
// Usage in middleware
app.use("/api/auth/login", (req, res, next) => {
const ip = req.ip;
if (!rateLimit(`login:${ip}`, 5, 15 * 60 * 1000)) { // 5 attempts per 15 minutes
return res.status(429).json({ error: { message: "Too many requests" } });
}
next();
});
Security Headers
# nginx.conf security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always; # Modern browsers don't need this; CSP is better
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Dependency Auditing
# Check for known vulnerabilities
pnpm audit
# In CI — fail on high/critical vulnerabilities
pnpm audit --audit-level=high
# Keep dependencies updated
pnpm outdated # Show outdated packages
pnpm update --latest # Update all to latest
Security Checklist
- Authentication: Passwords hashed with scrypt/bcrypt/argon2
- Authorization: Server-side permission checks on every endpoint
- Injection: Parameterized queries only (Prisma default)
- XSS: No
dangerouslySetInnerHTMLwithout DOMPurify - CSRF: SameSite cookies + Origin header validation
- CSP: Content Security Policy configured
- HTTPS: TLS everywhere, HSTS header enabled
- Rate Limiting: Login endpoints rate-limited
- Input Validation: Effect Schema at every API boundary
- Dependencies: Regular
pnpm auditin CI - Secrets: No secrets in code,
.env.localgit-ignored - Logging: Security events logged and monitored
- Headers: Security headers configured in Nginx/CDN
Summary
- ✅ OWASP Top 10 addressed with specific defenses for each threat
- ✅ Authorization enforced server-side on every request
- ✅ Input validation with Effect Schema at trust boundaries
- ✅ CSP headers prevent XSS and data exfiltration
- ✅ Rate limiting on authentication endpoints
- ✅ Dependency auditing in CI pipeline
- ✅ Security checklist for pre-launch review