The OWASP Top 10 is the most widely recognized list of critical web application security vulnerabilities, updated by the Open Web Application Security Project based on real-world attack data. Understanding it is table stakes for any developer shipping production web applications.
This post maps each OWASP Top 10 vulnerability to specific Next.js App Router patterns, shows you what the vulnerable code looks like, and provides the secure implementation.
A1: Broken Access Control
The #1 vulnerability in 2026. Access control fails when your application does not enforce what authenticated users are permitted to do.
Vulnerable Pattern
// ❌ Trusting the client to pass the correct userId
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId'); // Attacker changes this to any ID
const userData = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
return NextResponse.json(userData.rows[0]);
}Secure Implementation
// ✅ Always derive identity from the server-side session
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// User can only access their own data — ID comes from session, not client
const userData = await db.query(
'SELECT id, email, name FROM users WHERE id = $1',
[session.user.id] // ← This is the fix
);
return NextResponse.json(userData.rows[0]);
}A2: Cryptographic Failures
Sensitive data exposed through weak encryption or no encryption.
Common Mistakes in Next.js
// ❌ Storing passwords in plain text
await db.query('INSERT INTO users (email, password) VALUES ($1, $2)', [email, password]);
// ❌ Using MD5 or SHA1 for password hashing (broken)
const hashedPassword = crypto.createHash('md5').update(password).digest('hex');
// ❌ Storing sensitive tokens in localStorage
localStorage.setItem('auth_token', token); // Accessible to any JS
Secure Implementation
// ✅ Use bcrypt with appropriate cost factor
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12; // Adjust based on server performance
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ✅ Store auth tokens in HttpOnly cookies only
cookieStore.set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24,
});
A3: Injection (SQL, Command, Template)
Injection vulnerabilities occur when untrusted data is sent to an interpreter.
// ❌ SQL injection — user input directly in query string
const userId = req.query.id; // Could be: "1; DROP TABLE users; --"
await db.query(`SELECT * FROM users WHERE id = ${userId}`);
// ❌ NoSQL injection in MongoDB
await User.find({ username: req.body.username }); // Could be: { $gt: '' }
// ✅ Always use parameterized queries
await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// ✅ Validate types before querying
const validated = z.string().uuid().parse(req.query.id);
await db.query('SELECT * FROM users WHERE id = $1', [validated]);A4: Insecure Design
Security is designed in, not bolted on. Missing rate limiting, no lockout on failed logins, and missing CSRF protection are design failures.
// ✅ Rate limiting on authentication endpoints
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
});
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
const { success, remaining } = await ratelimit.limit(`login:${ip}`);
if (!success) {
return NextResponse.json(
{ error: 'Too many login attempts. Please wait 1 minute.' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
}
// Proceed with login...
}A5: Security Misconfiguration
Next.js has default settings that need to be hardened for production.
// next.config.ts — security headers
const nextConfig = {
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data: https:",
"object-src 'none'",
"frame-ancestors 'none'",
].join('; '),
},
],
}];
},
// Never expose source maps in production
productionBrowserSourceMaps: false,
};A6: Vulnerable and Outdated Components
Unpatched npm packages are the most common supply chain attack vector.
# .github/workflows/security.yml
- name: Audit dependencies
run: pnpm audit --audit-level=high
- name: Check for known malicious packages
uses: SocketDev/socket-security-action@v1
with:
api-key: ${{ secrets.SOCKET_API_KEY }}# Run locally before every release
npm audit --audit-level=moderate
npx snyk testA7: Identification and Authentication Failures
// ❌ Weak session management
const sessionId = Math.random().toString(); // Predictable!
// ✅ Cryptographically secure session IDs
import { randomBytes } from 'crypto';
const sessionId = randomBytes(32).toString('hex'); // 256-bit entropy
// ✅ Validate JWT signature on every request — never just decode
import { jwtVerify } from 'jose';
async function validateToken(token: string) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
maxTokenAge: '24h',
});
return payload;
}A8: Software and Data Integrity Failures (Supply Chain)
# Always use lockfiles and verify integrity
npm ci # Installs exactly what's in package-lock.json
# Verify package integrity
npm install --ignore-scripts # Prevent malicious install scripts
# Use Subresource Integrity for CDN-loaded scripts
# <script sr="https://cdn.example.com/lib.js"
# integrit="sha384-abc123..."
# crossorigi="anonymous"></script>
A9: Security Logging and Monitoring Failures
// ✅ Log security-relevant events
async function logSecurityEvent(event: {
type: 'login_failed' | 'login_success' | 'unauthorized_access' | 'rate_limited';
userId?: string;
ip: string;
details?: object;
}) {
await db.query(
'INSERT INTO security_audit_log (event_type, user_id, ip_address, details, created_at) VALUES ($1, $2, $3, $4, NOW())',
[event.type, event.userId, event.ip, JSON.stringify(event.details)]
);
// Alert on critical events
if (event.type === 'unauthorized_access') {
await notifySecurityTeam(event);
}
}A10: Server-Side Request Forgery (SSRF)
// ❌ Fetching user-provided URLs without validation
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const data = await fetch(url!); // Attacker can target internal services
}
// ✅ Allowlist valid domains only
const ALLOWED_DOMAINS = ['api.github.com', 'api.stripe.com', 'fonts.googleapis.com'];
function validateExternalUrl(url: string): boolean {
try {
const parsed = new URL(url);
return ALLOWED_DOMAINS.includes(parsed.hostname) && parsed.protocol === 'https:';
} catch {
return false;
}
}
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get('url');
if (!url || !validateExternalUrl(url)) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
}
const data = await fetch(url);
return NextResponse.json(await data.json());
}Security Audit Checklist for Next.js
- [ ] A1: All data access scoped to authenticated user's ID from session.
- [ ] A2: Passwords hashed with bcrypt (12+ rounds). Tokens in HttpOnly cookies.
- [ ] A3: All database queries parameterized. Input validated with Zod.
- [ ] A4: Rate limiting on authentication. Account lockout after N failed attempts.
- [ ] A5: Security headers configured in
next.config.ts. - [ ] A6:
npm auditin CI. Dependency lockfiles committed. - [ ] A7: JWT signatures verified on every request. Sessions use crypto-random IDs.
- [ ] A8:
npm ciused in production. No dynamicevalorrequire. - [ ] A9: Security events logged to audit table. Alerts configured.
- [ ] A10: External URL fetching uses domain allowlist.
Conclusion
The OWASP Top 10 is not a theoretical list—every item on it represents a vulnerability that has caused real breaches in production applications. For Next.js developers, the highest-priority fixes are: scoping data access to session identity (A1), parameterizing all database queries (A3), adding security headers (A5), and validating JWT signatures on every request (A7). These four alone eliminate the majority of common attack surfaces. Apply the full checklist before every significant release.