Most developers know the OWASP Top 10 exists. Fewer have actually read it. Fewer still have checked their own codebase against it. Here's a practical walkthrough of the most common vulnerabilities in Next.js apps and how to prevent them.
1. Injection (SQL, Command, NoSQL)
// VULNERABLE -- string interpolation in raw SQL
const users = await db.$queryRaw(`SELECT * FROM users WHERE email = '${email}'`)
// SAFE -- parameterized query
const users = await db.$queryRaw`SELECT * FROM users WHERE email = ${email}`
// SAFE -- Prisma ORM
const users = await db.user.findMany({ where: { email } })
// VULNERABLE -- command injection
exec(`convert ${filename} output.pdf`) // filename could be '; rm -rf /'
// SAFE -- use a library, validate inputs
import { execFile } from 'child_process'
execFile('convert', [sanitizedFilename, 'output.pdf'])
2. Broken Authentication
// VULNERABLE -- weak session secrets
NEXTAUTH_SECRET=secret123
// SAFE -- 32+ char random secret
NEXTAUTH_SECRET=$(openssl rand -base64 32)
// VULNERABLE -- JWT without expiry
jwt.sign({ userId }, secret) // never expires
// SAFE -- short-lived tokens
jwt.sign({ userId }, secret, { expiresIn: '15m' })
3. Sensitive Data Exposure
// VULNERABLE -- returning full user object
return Response.json(user) // includes password hash, PII
// SAFE -- explicit field selection
const { id, name, email } = user
return Response.json({ id, name, email })
// VULNERABLE -- logging sensitive data
console.log('User data:', user) // logs password hash
// SAFE -- log only safe fields
logger.info({ userId: user.id, action: 'login' })
4. Insecure Direct Object References
// VULNERABLE -- anyone can access any document
const doc = await db.document.findUnique({ where: { id: params.id } })
// SAFE -- scope to authenticated user's org
const doc = await db.document.findFirst({
where: {
id: params.id,
organizationId: session.user.organizationId, // ownership check
},
})
if (!doc) return Response.json({ error: 'Not found' }, { status: 404 })
5. Cross-Site Scripting (XSS)
// VULNERABLE -- dangerouslySetInnerHTML with user content
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// SAFE -- sanitize before rendering
import DOMPurify from 'dompurify'
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />
// BETTER -- use a markdown renderer with sanitization
import { marked } from 'marked'
import { sanitize } from 'dompurify'
const html = sanitize(marked(userMarkdown))
React's JSX escapes by default. dangerouslySetInnerHTML bypasses this — treat it as a security boundary.
6. Security Misconfiguration
// next.config.js -- security headers
const securityHeaders = [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // tighten in production
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
].join('; '),
},
]
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }]
},
}
7. Mass Assignment
// VULNERABLE -- spread request body directly
await db.user.update({
where: { id },
data: { ...req.body }, // attacker can set isAdmin: true
})
// SAFE -- explicit allowed fields with Zod
const schema = z.object({
name: z.string().max(100),
bio: z.string().max(500).optional(),
// role, isAdmin, stripeCustomerId NOT included
})
const data = schema.parse(req.body)
await db.user.update({ where: { id }, data })
The MCP Security Scanner at whoffagents.com scans MCP servers for injection, path traversal, command execution, and 19 other vulnerability classes. $29 one-time.
Top comments (0)