Offensive security in practice: XSS, SQL injection, and zero-trust architecture in next.js
Back to blog

Offensive security in practice: XSS, SQL injection, and zero-trust architecture in next.js

6/7/2026 · 5 min · Cybersecurity

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:


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:#

  1. Ingestion Sanitization (Backend): Always clean forbidden HTML tags and scripts before saving to the database;
  2. Rendering Sanitization (Frontend): Use DOMPurify when injecting HTML strings into the DOM;
  3. 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#

OWASPVulnerabilityNext.js Prevention
A01Broken Access ControlGlobal Middleware + strict RBAC
A02Cryptographic Failuresbcrypt/argon2 hashing + HTTPS with HSTS
A03InjectionPrepared Statements + Zod Validation + secure $queryRaw
A04Insecure DesignThreat Modeling within the agile cycle
A05Security MisconfigurationHTTP headers in next.config.js + rigid CSP
A06Vulnerable ComponentsSystematic auditing via npm audit and Snyk
A07Auth FailuresMulti-factor auth + Rate Limiting + Session Regeneration
A08Data IntegrityStructured input validation + Subresource Integrity
A09Logging FailuresJSON-formatted logs + SIEM central logging
A10SSRFValidation 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#

2. Authentication and session control#

3. Data input and sanitization#

4. Response and monitoring#


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:

CC BY-NC

This post is licensed under CC BY-NC.

Comments

Join the discussion below.

0 comments