This article consolidates critical security vulnerabilities that I have identified during SaaS application security audits. The risk in these scenarios is not merely technical: it involves direct financial losses, reputational damage, and serious legal implications under data privacy regulations. The objective here is to document security engineering in a practical, applicable way for real production environments.
When an application grows, the most common developer mistake is assuming modern frameworks like Next.js magically solve security out of the box. They don't. Security is a coordinated architecture built across validation, persistence, session control, data encryption, and runtime execution layers.
1) SQL injection and insecure authentication#
SQL injection vulnerabilities occur when user-supplied inputs are interpolated directly into database query strings, allowing attackers to manipulate database query logic.
Critical anti-pattern (vulnerable)#
// /pages/api/login.js - VULNERABLE
const { email, password } = req.body;
// Direct interpolation and plaintext password checking (insecure)
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
const result = await db.query(sql);
With a basic payload like ' OR 1=1 --, the attacker bypasses the password check and logs in as the first user returned by the database (usually the administrator).
Definitive fix: prepared statements + password hashing (bcrypt)#
To fix this vulnerability professionally, we separate the SQL instruction from the data parameters and validate the password using a secure hash (with bcrypt or argon2):
import bcrypt from 'bcrypt';
// 1. Prepared Statement: query only by email
const sql = "SELECT id, email, password_hash FROM users WHERE email = ?";
const result = await db.query(sql, [email]);
const user = result.rows[0];
// 2. Secure comparison of the password hash
if (user && await bcrypt.compare(password, user.password_hash)) {
// 3. Session Fixation prevention: regenerate session after successful login
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
// 4. Structured audit log (JSON format)
console.log(JSON.stringify({
event: 'LOGIN_SUCCESS',
userId: user.id,
ip: req.ip,
timestamp: new Date().toISOString()
}));
res.json({ success: true });
});
} else {
// Structured failure log
console.log(JSON.stringify({
event: 'LOGIN_FAILURE',
email: email,
ip: req.ip,
timestamp: new Date().toISOString()
}));
res.status(401).json({ error: 'Invalid credentials' });
}
The hidden danger in orms#
Even when using consolidated ORMs like Prisma, developers often turn to methods like $queryRawUnsafe for complex queries. This reopens the injection surface. In production environments:
- Prioritize native typed ORM methods (
findUnique,findFirst); - If you need raw queries, use safe tagged templates (
$queryRawwith parameterized placeholders); - Never concatenate strings originating from user requests into queries.
2) Rate limiting on critical endpoints#
Authentication endpoints exposed without rate limiting facilitate brute force attacks. We must limit login attempts per IP.
Implementing rate limiting with express-rate-limit:#
const rateLimit = require('express-rate-limit');
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes window
max: 5, // Limit to 5 attempts per IP per window
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
standardHeaders: true, // Return rate limit info in the RateLimit-* headers
legacyHeaders: false, // Disable legacy X-RateLimit-* headers
});
3) CSRF protection and content-type validation#
Cross-Site Request Forgery (CSRF) attacks force an authenticated user's browser to send malicious requests to the SaaS internal endpoints.
Implementing CSRF token validation in API routes:#
import csurf from 'csurf';
const csrfProtection = csurf({ cookie: true });
export default async function handler(req, res) {
// Validate CSRF Token on mutation requests
if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
const csrfToken = req.body._csrf || req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid or missing CSRF token.' });
}
}
// Continue processing...
}
Validating the content-type header#
Another common attack involves "Content-Type Sniffing", where attackers try to send malicious payloads disguised as other formats. We validate the header explicitly:
if (['POST', 'PUT'].includes(req.method)) {
const contentType = req.headers['content-type'];
if (!contentType || !contentType.includes('application/json')) {
return res.status(415).json({ error: 'Invalid Content-Type. Only application/json is allowed.' });
}
}
4) XSS and sanitization at the execution layer#
Stored XSS (Persistent Cross-Site Scripting) in multi-tenant environments allows malicious data saved by one user to execute scripts inside the browser of other clients or administrators.
Sanitizing with dompurify#
When dealing with outputs from rich text editors (WYSIWYG), never inject content directly without sanitizing it.
import DOMPurify from "dompurify";
// Sanitize user input on the frontend before rendering
const safeContent = DOMPurify.sanitize(userInput);
return <div dangerouslySetInnerHTML={{ __html: safeContent }} />;
Operational rules for XSS:#
- Ingestion Sanitization (Backend): Always clean forbidden HTML tags and scripts before saving to the database;
- Rendering Sanitization (Frontend): Use DOMPurify when injecting HTML strings into the DOM;
- Content Security Policy (CSP): Configure a rigid CSP to restrict third-party script execution sources.
5) Strict input validation with zod#
All inputs entering the application must pass through type, format, and maximum length validations to prevent stack overflow and Denial of Service (DoS) attacks.
Schema validation with length limits#
import { z } from 'zod';
export const userSchema = z.object({
name: z.string().min(3).max(100).trim(), // Restricts max input length
email: z.string().email().max(255).toLowerCase(),
role: z.enum(["USER", "CLIENT"]).default("USER"),
}).strict(); // strict() prevents unwanted extra parameters (Mass Assignment)
In the Next.js endpoint:
const parsed = userSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json(parsed.error.format());
}
6) HTTPS enforcement and secure session cookies#
Transport channel integrity (HTTPS) is the baseline to prevent traffic interception (man-in-the-middle).
Enforcing HTTPS in next.js middleware:#
// middleware.js
export function middleware(request) {
const forwardedProto = request.headers.get('x-forwarded-proto');
const protocol = forwardedProto || request.nextUrl.protocol;
if (protocol !== 'https:' && process.env.NODE_ENV === 'production') {
const url = request.nextUrl.clone();
url.protocol = 'https:';
return Response.redirect(url);
}
}
Configuring secure session cookies (nextauth)#
Storing sessions or JWTs in localStorage leaves tokens vulnerable to theft via XSS. Use cookies with strict directives:
const cookies = {
sessionToken: {
name: `__Secure-next-auth.session-token`, // The __Secure- prefix requires HTTPS
options: {
httpOnly: true, // Prevents reading via document.cookie in JavaScript
sameSite: 'lax', // CSRF mitigation
path: '/',
secure: true, // Transmit only over HTTPS
},
},
};
7) Safe error handling and headers in next.config.js#
Exposing database logs or internal server details in client error messages helps attackers find vulnerabilities.
Secure exception handling#
try {
const result = await db.query(sql, params);
res.json(result);
} catch (error) {
// 1. Detailed internal logging for the SRE/DevSecOps team
console.error('Database query failure:', error);
// 2. Generic and secure response for the external user
res.status(500).json({ error: 'Internal server error.' });
}
Security headers in next.config.js#
Add HTTP defense headers directly in your Next.js configuration:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
},
],
},
];
},
};
8) Security audit and testing tools#
Automate security testing and vulnerability validation in your CI/CD pipeline:
# 1. OWASP ZAP automated security scanner (baseline analysis)
docker run -t ghcr.io/zaproxy/zaproxy zap-baseline.py -t https://seu-app.com
# 2. Check for known vulnerabilities in npm dependencies
npm audit
npm audit fix
# 3. Static code analysis focusing on security (Snyk)
npx snyk test
# 4. ESLint with specific rules for security in JavaScript/TypeScript
npm install eslint-plugin-security --save-dev
Reference table: OWASP top 10 vs next.js#
| OWASP | Vulnerability | Next.js Prevention |
|---|---|---|
| A01 | Broken Access Control | Global Middleware + strict RBAC |
| A02 | Cryptographic Failures | bcrypt/argon2 hashing + HTTPS with HSTS |
| A03 | Injection | Prepared Statements + Zod Validation + secure $queryRaw |
| A04 | Insecure Design | Threat Modeling within the agile cycle |
| A05 | Security Misconfiguration | HTTP headers in next.config.js + rigid CSP |
| A06 | Vulnerable Components | Systematic auditing via npm audit and Snyk |
| A07 | Auth Failures | Multi-factor auth + Rate Limiting + Session Regeneration |
| A08 | Data Integrity | Structured input validation + Subresource Integrity |
| A09 | Logging Failures | JSON-formatted logs + SIEM central logging |
| A10 | SSRF | Validation and whitelist of external requests in SSR |
Runbook: next.js security audit#
Follow this structured runbook to audit the security of your Next.js applications:
1. Configuration and dependencies#
- [ ] Review
next.config.js(presence of essential HTTP headers and CSP rules). - [ ] Analyze
.envfiles to ensure secrets and private API keys are not committed to the repository. - [ ] Validate dependencies by running
npm auditor Snyk on the codebase.
2. Authentication and session control#
- [ ] Ensure password hashing uses robust algorithms (bcrypt with cost 12+ or argon2id).
- [ ] Validate that session credentials are stored in cookies with
HttpOnly,Secure, andSameSiteflags. - [ ] Ensure session IDs are regenerated after login to mitigate Session Fixation.
- [ ] Test for active rate limiting on authentication and password reset endpoints.
3. Data input and sanitization#
- [ ] Verify that all API routes have Zod schemas with limited string lengths (
max()) validated at the border. - [ ] Locate instances of
dangerouslySetInnerHTMLand ensure DOMPurify is used to sanitize outputs. - [ ] Ensure all database queries utilize parameterized drivers (Prepared Statements).
4. Response and monitoring#
- [ ] Test that infrastructure or database error messages are not sent to the client in production.
- [ ] Ensure failed login attempts and critical errors generate structured logs (JSON) with IPs and timestamps.
Production takeaways#
Securing a SaaS application built on Next.js requires constant vigilance over input boundaries, session key storage, and robust data handling at the database layer. Applying least-privilege practices and defense-in-depth mitigates risks before they transform into actual security incidents.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments