Chapter 23: Security 101 🔒

Chapter 23: Security 101 🔒

"Security isn't a feature you add to your SaaS—it's a fundamental consideration that must be woven into every aspect of your application from day one."

Building a successful SaaS isn't just about creating great features; it's about protecting your users' data and maintaining their trust. In this chapter, we'll explore essential security practices for your Next.js application, covering server/client component security considerations, proper environment variable management, API endpoint controls, and middleware protection.

React Server/Client Component Security 🛡️

Next.js 13+ introduced a powerful Server Components model that has significant security implications. Understanding the security boundaries between server and client components is crucial for protecting sensitive data and operations.

Server Components: Your First Line of Defense

Server Components render exclusively on the server, never exposing their code to the client browser. This provides powerful security benefits:



jsx
// app/admin/dashboard/page.jsx - Server Component (default)
import { getServerSession } from 'next-auth/next';
import { fetchSensitiveData } from '@/lib/data';
import AdminDashboard from '@/components/AdminDashboard';

export default async function AdminPage() {
// ✅ Secure: This code never reaches the client
  const session = await getServerSession();

// ✅ Secure: API keys and credentials stay on the server
  const API_KEY = process.env.ADMIN_API_KEY;

// ✅ Secure: Sensitive data fetching happens server-side
  const sensitiveData = await fetchSensitiveData(API_KEY);

// Only filtered/sanitized data is passed to client components
  return <AdminDashboard data={sanitizeForClient(sensitiveData)} />;
}

// Helper function to sanitize data before sending to client
function sanitizeForClient(data) {
// Remove sensitive fields
  return data.map(item => ({
    id: item.id,
    name: item.name,
// Notice we're excluding sensitive fields like:// - internalNotes// - personalIdentifiers// - paymentDetails
  }));
}

Security Benefits of Server Components

  1. API Keys and Secrets Stay Protected: Server Component code never reaches the client, keeping your credentials secure.
  1. Reduced Attack Surface: Less JavaScript shipped to the browser means fewer potential vulnerabilities.
  1. Sensitive Data Protection: Process and filter sensitive data on the server before sending results to the client.
  1. Authorization Logic Isolation: Keep permissions and access control logic on the server where it can't be tampered with.

Client Components: Areas of Caution

Client Components run in the user's browser, which means all their code is accessible to users. They're marked with the "use client" directive:



jsx
"use client";
// components/UserProfileForm.jsx
import { useState } from 'react';

export default function UserProfileForm({ onSubmit, initialData }) {
  const [formData, setFormData] = useState(initialData);

// ⚠️ Caution: This code runs in the browser and can be inspected

  function handleSubmit(e) {
    e.preventDefault();

// ⚠️ Caution: Client-side validation can be bypassed// Always re-validate on the server
    if (!formData.name) {
      alert('Name is required');
      return;
    }

// Send data to server for processing
    onSubmit(formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
    </form>
  );
}

Security Risks with Client Components

  1. Exposed Validation Logic: Attackers can bypass client-side validation.
  1. No Secrets Allowed: Never include API keys or sensitive values in client components.
  1. State Manipulation: Browser state can be manipulated through dev tools.
  1. Network Requests Exposure: All fetch requests can be intercepted and analyzed.

Secure Patterns for Client/Server Interaction

Follow these patterns to maintain security across the client/server boundary:

1. Data Sanitization

Always sanitize data before passing it from server to client components:



jsx
// Server Component
import { getSensitiveUserData } from '@/lib/data';
import UserProfile from '@/components/UserProfile';// Client component

export default async function ProfilePage({ params }) {
// Fetch complete data on server
  const userData = await getSensitiveUserData(params.id);

// Extract and pass only what the client needs
  const clientSafeData = {
    id: userData.id,
    name: userData.name,
    publicProfile: userData.publicProfile,
// Not passing: userData.internalNotes, userData.socialSecurityNumber, etc.
  };

  return <UserProfile userData={clientSafeData} />;
}

2. Server Actions for Sensitive Operations

Use Server Actions (introduced in Next.js 13.4) for secure form submissions:



jsx
// app/actions.js
"use server";

import { revalidatePath } from 'next/cache';
import { getServerSession } from 'next-auth/next';
import { z } from 'zod';

// This function runs on the server even when called from client components
export async function updateProfile(formData) {
// 1. Server-side authentication check
  const session = await getServerSession();
  if (!session) {
    return { error: "Unauthorized" };
  }

// 2. Server-side validation
  const schema = z.object({
    name: z.string().min(2).max(50),
    bio: z.string().max(500).optional(),
  });

  const validationResult = schema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
  });

  if (!validationResult.success) {
    return {
      error: "Validation failed",
      issues: validationResult.error.issues,
    };
  }

// 3. Perform secure operation
  try {
    await db.user.update({
      where: { id: session.user.id },
      data: validationResult.data,
    });

// 4. Revalidate the cache for this user's profile
    revalidatePath(`/profile/${session.user.id}`);

    return { success: true };
  } catch (error) {
    console.error(error);
    return { error: "Failed to update profile" };
  }
}

Then use it in your client component:



jsx
"use client";
// components/ProfileForm.jsx
import { updateProfile } from '@/app/actions';
import { useFormState } from 'react-dom';

export default function ProfileForm({ initialData }) {
  const [state, formAction] = useFormState(updateProfile, { submitted: false });

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          defaultValue={initialData.name}
          required
        />
        {state?.issues?.find(i => i.path[0] === 'name') && (
          <p className="text-red-500">
            {state.issues.find(i => i.path[0] === 'name').message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea
          id="bio"
          name="bio"
          defaultValue={initialData.bio}
        />
      </div>

      {state?.error && (
        <div className="bg-red-100 p-3 rounded">
          {state.error}
        </div>
      )}

      <button type="submit">Update Profile</button>
    </form>
  );
}

3. Progressive Enhancement with Server Components

Build UI with Server Components first, then add interactivity with Client Components:



jsx
// components/UserProfile.jsx - Server Component
import EditProfileButton from './EditProfileButton';// Client Component

export default function UserProfile({ user }) {
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h1 className="text-2xl font-bold">{user.name}</h1>
      <p className="text-gray-600">{user.bio || 'No bio yet'}</p>

      {/* Only the button is a Client Component */}
      <EditProfileButton userId={user.id} />
    </div>
  );
}


jsx
// components/EditProfileButton.jsx - Client Component
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function EditProfileButton({ userId }) {
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  function handleClick() {
    setIsLoading(true);
    router.push(`/profile/${userId}/edit`);
  }

  return (
    <button
      onClick={handleClick}
      disabled={isLoading}
      className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
    >
      {isLoading ? 'Loading...' : 'Edit Profile'}
    </button>
  );
}

Environment Variables 🔐

Environment variables are a secure way to store configuration data and secrets. When set up correctly, they keep sensitive information out of your codebase and prevent accidental exposure.

Types of Environment Variables in Next.js

Next.js has two types of environment variables:

  1. Server-Only Variables: Only accessible on the server
  1. Public Variables: Accessible on both client and server (prefixed with NEXT_PUBLIC_)


javascript
// .env.local example
# Server-only variables (secure)
DATABASE_URL="postgresql://username:password@localhost:5432/mydb"
API_SECRET_KEY="super_secret_api_key"
JWT_SECRET="your_jwt_signing_secret"

# Public variables (exposed to browser)
NEXT_PUBLIC_API_URL="https://api.yourdomain.com"
NEXT_PUBLIC_SITE_NAME="My SaaS App"

How to Use Environment Variables Safely

Here's how to properly use environment variables in Next.js:

In Server Components or API Routes



jsx
// Server Component or API Route
export default async function AdminDashboard() {
// ✅ Safe: This runs only on the server
  const apiKey = process.env.API_SECRET_KEY;
  const databaseUrl = process.env.DATABASE_URL;

// Use these secure variables for server operations
  const data = await fetchDataWithApiKey(apiKey);

  return <DashboardUI data={data} siteName={process.env.NEXT_PUBLIC_SITE_NAME} />;
}

In Client Components



jsx
"use client";
// Client Component
export default function SiteHeader() {
// ✅ Safe: Only public variables are accessible
  const siteName = process.env.NEXT_PUBLIC_SITE_NAME;

// ❌ Will not work: Server-only variables are undefined on the client
  const apiKey = process.env.API_SECRET_KEY;// undefined

  return <header>{siteName}</header>;
}

Environment Variable Best Practices

  1. Never Commit Secrets to Git Always include .env*.local files in your .gitignore:
    
    
    # .gitignore
    .env
    .env.local
    .env*.local
    
  1. Use Different Files for Different Environments
    • .env.local: For local development (not committed)
    • .env.development: Development defaults (can be committed)
    • .env.production: Production defaults (can be committed)
    • .env.test: Testing environment (can be committed)
  1. Strict Naming Convention Use clear, descriptive names that indicate usage:
    
    
    # Clear purpose in the name
    AUTH0_CLIENT_SECRET="..."
    STRIPE_SECRET_KEY="..."
    
    # Avoid generic names
    # ❌ BAD: SECRET="..." or KEY="..."
    
  1. Validate Environment Variables at Startup Check required variables early to fail fast:
    
    
    javascript
    // lib/env.js
    function validateEnv() {
      const requiredEnvVars = [
        'DATABASE_URL',
        'JWT_SECRET',
        'STRIPE_SECRET_KEY'
      ];
    
      for (const envVar of requiredEnvVars) {
        if (!process.env[envVar]) {
          throw new Error(`Missing required environment variable: ${envVar}`);
        }
      }
    }
    
    // Call this during app initialization
    validateEnv();
    
  1. Use a Secret Manager in Production For production deployments, consider using a secret manager like:
    • Vercel Environment Variables if deploying on Vercel
    • AWS Secrets Manager
    • GCP Secret Manager
    • HashiCorp Vault
  1. Rotate Secrets Regularly Implement a process to rotate sensitive credentials periodically.

API Endpoint Controls 🛂

API endpoints are a common attack vector, as they're directly exposed to the internet. Implementing proper controls is essential for protecting your data and functionality.

Securing API Routes in Next.js App Router

With Next.js App Router, API endpoints are defined in route.js files:



javascript
// app/api/users/route.js
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { z } from 'zod';
import { db } from '@/lib/db';

// This handles GET requests to /api/users
export async function GET(request) {
  try {
// 1. Authentication check
    const session = await getServerSession();
    if (!session) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

// 2. Authorization check
    if (session.user.role !== 'admin') {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

// 3. Data retrieval with proper pagination limits
    const { searchParams } = new URL(request.url);
    const page = parseInt(searchParams.get('page') || '1');
    const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100);

    const users = await db.user.findMany({
      take: limit,
      skip: (page - 1) * limit,
      select: {
        id: true,
        name: true,
        email: true,
        role: true,
        createdAt: true,
// Not including password or other sensitive fields
      }
    });

    return NextResponse.json({ users });
  } catch (error) {
    console.error('API error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// This handles POST requests to /api/users
export async function POST(request) {
  try {
// 1. Authentication check
    const session = await getServerSession();
    if (!session?.user?.email) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

// 2. Authorization check for user creation
    if (session.user.role !== 'admin') {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

// 3. Input validation with Zod
    const schema = z.object({
      name: z.string().min(2).max(50),
      email: z.string().email(),
      role: z.enum(['user', 'admin']),
    });

    const json = await request.json();
    const result = schema.safeParse(json);

    if (!result.success) {
      return NextResponse.json(
        {
          error: 'Validation failed',
          issues: result.error.issues
        },
        { status: 400 }
      );
    }

// 4. Rate limiting check (simplified example)
    const apiKey = request.headers.get('x-api-key');
    const isRateLimited = await checkRateLimit(apiKey || session.user.email);

    if (isRateLimited) {
      return NextResponse.json(
        { error: 'Too many requests' },
        { status: 429 }
      );
    }

// 5. Database operation with error handling
    const existingUser = await db.user.findUnique({
      where: { email: result.data.email }
    });

    if (existingUser) {
      return NextResponse.json(
        { error: 'User with this email already exists' },
        { status: 409 }
      );
    }

    const newUser = await db.user.create({
      data: result.data
    });

// 6. Audit logging
    await logAuditEvent({
      action: 'user_created',
      performedBy: session.user.email,
      targetId: newUser.id,
      metadata: { role: newUser.role }
    });

    return NextResponse.json(
      {
        id: newUser.id,
        name: newUser.name,
        email: newUser.email
      },
      { status: 201 }
    );
  } catch (error) {
    console.error('API error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// Helper function for rate limiting
async function checkRateLimit(identifier) {
// Implementation would check a Redis store or similar// to track request counts within a time window
  return false;// Simplified return
}

// Helper function for audit logging
async function logAuditEvent({ action, performedBy, targetId, metadata }) {
// Implementation would log to database or external service
  console.log(`AUDIT: ${action} by ${performedBy} on ${targetId}`);
}

API Security Checklist

Here's a comprehensive checklist for securing API endpoints:

1. Authentication and Authorization

  • ✅ Verify user identity before processing requests
  • ✅ Check permission levels for each operation
  • ✅ Use OAuth 2.0 or JWT tokens for stateless authentication
  • ✅ Implement token expiration and rotation

2. Input Validation

  • ✅ Validate all input data using a schema validator (like Zod or Yup)
  • ✅ Sanitize inputs to prevent injection attacks
  • ✅ Validate query parameters and URL segments
  • ✅ Set strict content type requirements

3. Rate Limiting and Throttling

  • ✅ Limit requests per IP/user/API key
  • ✅ Implement exponential backoff for repeated failures
  • ✅ Add headers to indicate rate limit status
  • ✅ Use Redis or a similar tool to track request counts

4. Request and Response Handling

  • ✅ Set proper content security headers
  • ✅ Only return necessary data (no overfetching)
  • ✅ Use pagination for large data sets
  • ✅ Set appropriate HTTP status codes
  • ✅ Implement request timeouts

5. Error Handling

  • ✅ Catch all exceptions and return appropriate status codes
  • ✅ Don't leak sensitive information in error messages
  • ✅ Log errors for monitoring but sanitize sensitive data
  • ✅ Return consistent error formats

6. Data Protection

  • ✅ Use HTTPS for all API traffic
  • ✅ Implement proper CORS headers
  • ✅ Never expose sensitive database fields
  • ✅ Encrypt sensitive data at rest and in transit

Implementing an API Key System

For machine-to-machine authentication, an API key system is often appropriate. Here's how to implement one:



javascript
// lib/api-key.js
import { db } from '@/lib/db';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';

// Middleware function to validate API key
export async function validateApiKey(request) {
// Get API key from headers
  const headersList = headers();
  const apiKey = headersList.get('x-api-key');

// Return early if no API key provided
  if (!apiKey) {
    return {
      isValid: false,
      response: NextResponse.json(
        { error: 'API key required' },
        { status: 401 }
      )
    };
  }

// Find API key in database
  const apiKeyRecord = await db.apiKey.findUnique({
    where: { key: apiKey },
    include: { user: true }
  });

// Check if API key exists and is not expired
  if (!apiKeyRecord || (apiKeyRecord.expires && apiKeyRecord.expires < new Date())) {
    return {
      isValid: false,
      response: NextResponse.json(
        { error: 'Invalid or expired API key' },
        { status: 401 }
      )
    };
  }

// Check if the API key has the required scope for this endpoint// (example: the key might only have read permissions)
  const requiredScope = request.method === 'GET' ? 'read' : 'write';

  if (!apiKeyRecord.scopes.includes(requiredScope) && !apiKeyRecord.scopes.includes('admin')) {
    return {
      isValid: false,
      response: NextResponse.json(
        { error: 'Insufficient permissions for this operation' },
        { status: 403 }
      )
    };
  }

// API key is valid, return the associated user
  return {
    isValid: true,
    user: apiKeyRecord.user
  };
}

Now use it in your API routes:



javascript
// app/api/data/route.js
import { validateApiKey } from '@/lib/api-key';
import { NextResponse } from 'next/server';

export async function GET(request) {
// Validate API key
  const { isValid, response, user } = await validateApiKey(request);

  if (!isValid) {
    return response;
  }

// Proceed with API request with authenticated user
  const data = await fetchDataForUser(user.id);

  return NextResponse.json({ data });
}

Middleware / Firewall 🧱

Next.js middleware runs before a request is completed, making it an ideal place to implement security controls that apply across your application.

Creating a Security Middleware

Here's a comprehensive security middleware that implements multiple protections:



javascript
// middleware.js
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request) {
  const response = NextResponse.next();

// 1. Set security headers for all responses// See: https://securityheaders.com
  const headers = {
// Prevent clickjacking
    'X-Frame-Options': 'DENY',

// Enable Cross-Site Scripting filter in browsers
    'X-XSS-Protection': '1; mode=block',

// Prevent MIME type sniffing
    'X-Content-Type-Options': 'nosniff',

// Strict HTTPS (including subdomains)
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',

// Restrict where resources can be loaded from
    'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.example.com; connect-src 'self' https://api.example.com; img-src 'self' data: https://images.example.com; style-src 'self' 'unsafe-inline';",

// Control browser features
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=(self), interest-cohort=()'
  };

// Apply all security headers
  Object.entries(headers).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

// 2. Check for authenticated routes
  const path = request.nextUrl.pathname;

// Define protected paths that require authentication
  const protectedPaths = [
    '/dashboard',
    '/account',
    '/api/user',
    '/api/data'
  ];

// Check if current path starts with any protected path
  const isProtectedPath = protectedPaths.some(protectedPath =>
    path.startsWith(protectedPath)
  );

// Only check authentication for protected paths
  if (isProtectedPath) {
// Get the token using next-auth
    const token = await getToken({ req: request });

// If no token, redirect to login
    if (!token) {
      const url = new URL('/login', request.url);
      url.searchParams.set('callbackUrl', path);
      return NextResponse.redirect(url);
    }

// 3. Role-based access control// Check if the path requires admin permissions
    const adminOnlyPaths = ['/dashboard/admin', '/api/admin'];
    const isAdminPath = adminOnlyPaths.some(adminPath =>
      path.startsWith(adminPath)
    );

// Redirect non-admin users
    if (isAdminPath && token.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
  }

// 4. Rate limiting (simplified example)// In a real implementation, you would use Redis or a similar service// to track request counts across multiple serverless instances
  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  const isRateLimited = await checkRateLimit(ip, path);

  if (isRateLimited) {
    return new NextResponse(
      JSON.stringify({ error: 'Too many requests' }),
      { status: 429, headers: { 'Content-Type': 'application/json' } }
    );
  }

// 5. Bot protection
  const userAgent = request.headers.get('user-agent') || '';
  if (isMaliciousBot(userAgent)) {
    return new NextResponse(
      JSON.stringify({ error: 'Access denied' }),
      { status: 403, headers: { 'Content-Type': 'application/json' } }
    );
  }

// 6. IP blocking (for known malicious IPs)
  if (await isBlockedIP(ip)) {
    return new NextResponse(
      JSON.stringify({ error: 'Access denied' }),
      { status: 403, headers: { 'Content-Type': 'application/json' } }
    );
  }

  return response;
}

// Helper function to check rate limits
async function checkRateLimit(ip, path) {
// Implementation would use Redis or similar to count requests// Return true if rate limit exceeded, false otherwise
  return false;// Simplified for this example
}

// Helper function to detect malicious bots
function isMaliciousBot(userAgent) {
  const maliciousBotPatterns = [
    /malicious-bot/i,
    /evil-crawler/i,
// Add more patterns for known bad bots
  ];

  return maliciousBotPatterns.some(pattern => pattern.test(userAgent));
}

// Helper function to check for blocked IPs
async function isBlockedIP(ip) {
// Implementation would check a database or API for blocked IPs// Return true if IP is blocked, false otherwise
  return false;// Simplified for this example
}

// Configure which routes use this middleware
export const config = {
  matcher: [
/*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public folder
     */
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
};

Enhanced Authentication with Multi-Factor Authentication (MFA)

For applications handling sensitive data, implementing MFA provides an additional layer of security:



javascript
// lib/mfa.js
import { totp } from 'otplib';
import QRCode from 'qrcode';

// Generate a new secret for a user
export function generateMfaSecret(userId) {
  const secret = totp.generateSecret();
  return {
    secret: secret.base32,
    otpauth_url: totp.keyuri(userId, 'YourSaaSApp', secret.base32)
  };
}

// Generate a QR code for MFA setup
export async function generateQrCode(otpauthUrl) {
  return QRCode.toDataURL(otpauthUrl);
}

// Verify a TOTP token
export function verifyToken(token, secret) {
  return totp.verify({
    token,
    secret
  });
}

Integrate with your authentication flow:



javascript
// app/api/auth/mfa/verify/route.js
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { verifyToken } from '@/lib/mfa';
import { db } from '@/lib/db';

export async function POST(request) {
  try {
// Get the authenticated user
    const session = await getServerSession();
    if (!session) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

// Get the token from the request
    const { token } = await request.json();

// Get the user's MFA secret from the database
    const user = await db.user.findUnique({
      where: { id: session.user.id },
      select: { mfaSecret: true }
    });

    if (!user?.mfaSecret) {
      return NextResponse.json(
        { error: 'MFA not set up for this user' },
        { status: 400 }
      );
    }

// Verify the token
    const isValid = verifyToken(token, user.mfaSecret);

    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid token' },
        { status: 400 }
      );
    }

// Mark the session as MFA-verified
    await db.session.update({
      where: { id: session.id },
      data: { mfaVerified: true }
    });

    return NextResponse.json({
      success: true,
      message: 'MFA verification successful'
    });
  } catch (error) {
    console.error('MFA verification error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Web Application Firewall (WAF) Rules

While full WAF implementation is beyond the scope of middleware alone, you can implement basic protection against common attacks:



javascript
// lib/waf.js
export function detectXssAttempt(input) {
// Check for common XSS patterns
  const xssPatterns = [
    /<script\b[^>]*>([\s\S]*?)<\/script>/gi,
    /javascript\s*:/gi,
    /onerror\s*=/gi,
    /onload\s*=/gi,
    /onclick\s*=/gi,
    /alert\s*\(/gi
  ];

  return xssPatterns.some(pattern => pattern.test(input));
}

 export function detectSqlInjection(input) {
 // Check for common SQL injection patterns
 const sqlInjectionPatterns = [
   /\b(union|select|insert|update|delete|drop|alter)\b/i,
   /['"].*;--/i,
   /or\s+1=1/i,
   /'\s+or\s+'1'='1/i,
   /--\s+/i,
   /;\s*$/i
 ];
 
 return sqlInjectionPatterns.some(pattern => pattern.test(input));
}

export function detectPathTraversal(path) {
 // Check for directory traversal attempts
 const pathTraversalPatterns = [
   /\.\.\//g,
   /\.\.\\$/g,
   /%2e%2e\//ig,
   /%252e%252e\//ig
 ];
 
 return pathTraversalPatterns.some(pattern => pattern.test(path));
}

Use these WAF functions in your API routes and middleware:



javascript
// Extend middleware.js with WAF checks
import { detectXssAttempt, detectSqlInjection, detectPathTraversal } from '@/lib/waf';

// Inside your middleware function, add:
export async function middleware(request) {
// Get the URL path and query parameters
  const { pathname, search } = request.nextUrl;
  const queryParams = Object.fromEntries(request.nextUrl.searchParams);

// Check for path traversal in URL path
  if (detectPathTraversal(pathname)) {
    console.warn(`Path traversal attempt detected: ${pathname}`);
    return new NextResponse(
      JSON.stringify({ error: 'Invalid request' }),
      { status: 400, headers: { 'Content-Type': 'application/json' } }
    );
  }

// Check query parameters for malicious content
  for (const [key, value] of Object.entries(queryParams)) {
    if (typeof value === 'string') {
      if (detectXssAttempt(value) || detectSqlInjection(value)) {
        console.warn(`Potential attack in query param ${key}: ${value}`);
        return new NextResponse(
          JSON.stringify({ error: 'Invalid request parameters' }),
          { status: 400, headers: { 'Content-Type': 'application/json' } }
        );
      }
    }
  }

// Continue with other middleware checks...
}

Putting It All Together 🧩

Let's implement a full security solution for a SaaS application, combining all the concepts we've covered.

1. Security Configuration File

Start by creating a central security configuration file:



javascript
// lib/security/config.js
export const securityConfig = {
// CORS configuration
  cors: {
    allowedOrigins: [
      'https://yoursaas.com',
      'https://app.yoursaas.com',
// Add your domains here
    ],
    allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
    credentials: true
  },

// Content Security Policy
  csp: {
    directives: {
      'default-src': ["'self'"],
      'script-src': ["'self'", "'unsafe-inline'", "https://analytics.yoursaas.com"],
      'style-src': ["'self'", "'unsafe-inline'"],
      'img-src': ["'self'", "data:", "https://images.yoursaas.com"],
      'font-src': ["'self'", "https://fonts.gstatic.com"],
      'connect-src': ["'self'", "https://api.yoursaas.com"],
      'frame-ancestors': ["'none'"],
      'form-action': ["'self'"]
    }
  },

// Authentication settings
  auth: {
    passwordResetExpiryMinutes: 15,
    mfaEnabled: true,
    sessionDurationDays: 7,
    inactivityTimeoutMinutes: 30
  },

// Rate limiting configuration
  rateLimit: {
    public: {
      windowMs: 15 * 60 * 1000,// 15 minutes
      max: 100// 100 requests per windowMs
    },
    api: {
      windowMs: 60 * 1000,// 1 minute
      max: 60// 60 requests per windowMs
    },
    login: {
      windowMs: 60 * 60 * 1000,// 1 hour
      max: 5// 5 failed login attempts per windowMs
    }
  },

// Protected routes configuration
  protectedRoutes: {
    authenticated: [
      '/dashboard',
      '/account',
      '/settings',
      '/api/user'
    ],
    admin: [
      '/admin',
      '/api/admin'
    ]
  }
};

2. Comprehensive Middleware

Create a robust middleware that applies security controls consistently:



javascript
// middleware.js
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { securityConfig } from '@/lib/security/config';
import { rateLimit } from '@/lib/security/rate-limit';

export async function middleware(request) {
  const response = NextResponse.next();
  const { pathname } = request.nextUrl;

// 1. Apply security headers to all responses
  applySecurityHeaders(response);

// 2. Set up CORS for API routes
  if (pathname.startsWith('/api')) {
    applyCorsHeaders(request, response);

// Early return for preflight requests
    if (request.method === 'OPTIONS') {
      return response;
    }
  }

// 3. Rate limiting
  const limiter = getRateLimiter(pathname);
  const { success, limit, remaining, reset } = await limiter.check(request);

// Set rate limit headers
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', remaining.toString());
  response.headers.set('X-RateLimit-Reset', reset.toString());

  if (!success) {
    return new NextResponse(
      JSON.stringify({ error: 'Too Many Requests' }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString()
        }
      }
    );
  }

// 4. Authentication check for protected routes
  if (isProtectedRoute(pathname)) {
    const token = await getToken({ req: request });

// No token for protected route
    if (!token) {
      if (pathname.startsWith('/api')) {
        return new NextResponse(
          JSON.stringify({ error: 'Unauthorized' }),
          { status: 401, headers: { 'Content-Type': 'application/json' } }
        );
      } else {
// Redirect to login for non-API routes
        const url = new URL('/login', request.url);
        url.searchParams.set('callbackUrl', pathname);
        return NextResponse.redirect(url);
      }
    }

// 5. Role-based access control
    if (isAdminRoute(pathname) && token.role !== 'admin') {
      if (pathname.startsWith('/api')) {
        return new NextResponse(
          JSON.stringify({ error: 'Forbidden' }),
          { status: 403, headers: { 'Content-Type': 'application/json' } }
        );
      } else {
        return NextResponse.redirect(new URL('/unauthorized', request.url));
      }
    }

// 6. Check for MFA verification if required
    if (
      securityConfig.auth.mfaEnabled &&
      token.mfaRequired &&
      !token.mfaVerified &&
      !pathname.startsWith('/api/auth/mfa')
    ) {
      if (pathname.startsWith('/api')) {
        return new NextResponse(
          JSON.stringify({ error: 'MFA required', code: 'MFA_REQUIRED' }),
          { status: 403, headers: { 'Content-Type': 'application/json' } }
        );
      } else {
        return NextResponse.redirect(new URL('/auth/mfa', request.url));
      }
    }
  }

  return response;
}

// Helper functions
function applySecurityHeaders(response) {
// Construct CSP header from config
  const csp = Object.entries(securityConfig.csp.directives)
    .map(([key, values]) => `${key} ${values.join(' ')}`)
    .join('; ');

// Set security headers
  const headers = {
    'Content-Security-Policy': csp,
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'X-XSS-Protection': '1; mode=block',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=(self)'
  };

  Object.entries(headers).forEach(([key, value]) => {
    response.headers.set(key, value);
  });
}

function applyCorsHeaders(request, response) {
  const origin = request.headers.get('origin');

// Check if origin is allowed
  if (securityConfig.cors.allowedOrigins.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
  } else if (securityConfig.cors.allowedOrigins.includes('*')) {
    response.headers.set('Access-Control-Allow-Origin', '*');
  }

// Set other CORS headers
  response.headers.set(
    'Access-Control-Allow-Methods',
    securityConfig.cors.allowedMethods.join(', ')
  );
  response.headers.set(
    'Access-Control-Allow-Headers',
    securityConfig.cors.allowedHeaders.join(', ')
  );

  if (securityConfig.cors.credentials) {
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
}

function getRateLimiter(pathname) {
// Select the appropriate rate limiter based on the route
  if (pathname.startsWith('/api')) {
    return rateLimit(securityConfig.rateLimit.api);
  } else if (pathname.startsWith('/login') || pathname.startsWith('/auth/login')) {
    return rateLimit(securityConfig.rateLimit.login);
  } else {
    return rateLimit(securityConfig.rateLimit.public);
  }
}

function isProtectedRoute(pathname) {
  return securityConfig.protectedRoutes.authenticated.some(route =>
    pathname === route || pathname.startsWith(`${route}/`)
  );
}

function isAdminRoute(pathname) {
  return securityConfig.protectedRoutes.admin.some(route =>
    pathname === route || pathname.startsWith(`${route}/`)
  );
}

// Configure which routes use this middleware
export const config = {
  matcher: [
// Match all paths except static assets
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

3. Rate Limiting Implementation

Create a Redis-based rate limiter:



javascript
// lib/security/rate-limit.js
import { Redis } from '@upstash/redis';

// Create Redis client (using Upstash Redis in this example)
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL,
  token: process.env.UPSTASH_REDIS_TOKEN,
});

export function rateLimit(options) {
  const { windowMs, max } = options;

  return {
    async check(request) {
// Get identifier from IP and optional user ID or API key
      const identifier = getIdentifier(request);
      const key = `ratelimit:${identifier}`;

// Get current count and time
      const now = Date.now();
      const windowStart = now - windowMs;

// Remove old entries and count current ones
      const multi = redis.multi();
      multi.zremrangebyscore(key, 0, windowStart);
      multi.zadd(key, { score: now, member: now.toString() });
      multi.zrange(key, 0, -1);
      multi.expire(key, Math.ceil(windowMs / 1000));

      const [, , currentRequests] = await multi.exec();

// Check if limit is exceeded
      const count = currentRequests.length;
      const remaining = Math.max(0, max - count);
      const reset = now + windowMs;
      const success = count <= max;

      return {
        success,
        limit: max,
        remaining,
        reset
      };
    }
  };
}

function getIdentifier(request) {
// Prioritize authenticated user ID or API key if available
  const authHeader = request.headers.get('authorization');
  const apiKey = request.headers.get('x-api-key');

  if (apiKey) {
    return `apikey:${apiKey}`;
  }

  if (authHeader?.startsWith('Bearer ')) {
// For a real app, you would decode the JWT and extract the user ID// This is a simplified example
    return `user:${authHeader.substring(7).slice(0, 10)}`;
  }

// Fall back to IP address
  const ip = request.headers.get('x-forwarded-for') ||
             request.headers.get('x-real-ip') ||
             '127.0.0.1';

  return `ip:${ip}`;
}

4. Secure Form Implementation

Create a secure form component with CSRF protection:



jsx
// components/security/SecureForm.jsx
"use client";
import { useState, useEffect } from 'react';
import { getCsrfToken } from 'next-auth/react';

export default function SecureForm({ action, method = 'POST', children, onSuccess, onError }) {
  const [csrfToken, setCsrfToken] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState(null);

// Fetch CSRF token on component mount
  useEffect(() => {
    async function fetchCsrfToken() {
      const token = await getCsrfToken();
      setCsrfToken(token);
    }
    fetchCsrfToken();
  }, []);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);

    try {
// Get form data
      const formData = new FormData(e.target);

// Add CSRF token
      formData.append('csrfToken', csrfToken);

// Convert to JSON for API requests
      const body = method === 'GET'
        ? undefined
        : JSON.stringify(Object.fromEntries(formData));

// Send request
      const response = await fetch(action, {
        method,
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken
        },
        body
      });

// Handle response
      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Something went wrong');
      }

// Call success callback
      if (onSuccess) {
        onSuccess(data);
      }
    } catch (err) {
      setError(err.message);

// Call error callback
      if (onError) {
        onError(err);
      }
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="secure-form">
      {/* Hidden CSRF input */}
      <input type="hidden" name="csrfToken" value={csrfToken} />

      {children}

      {error && (
        <div className="bg-red-100 text-red-700 p-3 rounded mt-3" role="alert">
          {error}
        </div>
      )}

      {/* Disable submit button while loading or if CSRF token is not ready */}
      <button
        type="submit"
        disabled={isSubmitting || !csrfToken}
        className="mt-4 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded disabled:opacity-50"
      >
        {isSubmitting ? 'Processing...' : 'Submit'}
      </button>
    </form>
  );
}

5. Security Audit Logging

Implement a secure audit logging system:



javascript
// lib/security/audit-log.js
import { db } from '@/lib/db';

// Constants for audit event types
export const AUDIT_EVENTS = {
  AUTH: {
    LOGIN_SUCCESS: 'auth.login.success',
    LOGIN_FAILURE: 'auth.login.failure',
    LOGOUT: 'auth.logout',
    PASSWORD_CHANGE: 'auth.password.change',
    PASSWORD_RESET_REQUEST: 'auth.password.reset_request',
    MFA_ENABLED: 'auth.mfa.enabled',
    MFA_DISABLED: 'auth.mfa.disabled',
  },
  USER: {
    CREATED: 'user.created',
    UPDATED: 'user.updated',
    DELETED: 'user.deleted',
    ROLE_CHANGED: 'user.role.changed',
  },
  DATA: {
    CREATED: 'data.created',
    UPDATED: 'data.updated',
    DELETED: 'data.deleted',
    EXPORTED: 'data.exported',
  },
  ADMIN: {
    SETTINGS_CHANGED: 'admin.settings.changed',
    USER_IMPERSONATION: 'admin.user.impersonation',
  },
  API: {
    KEY_CREATED: 'api.key.created',
    KEY_REVOKED: 'api.key.revoked',
  }
};

/**
 * Log a security audit event
 *@param{Object}params - Audit parameters
 *@param{string}params.event - Event type from AUDIT_EVENTS
 *@param{string}params.actor - Who performed the action (user ID or system)
 *@param{string}[params.target] - The entity being acted upon (user ID, resource ID)
 *@param{string}[params.targetType] - Type of the target (user, resource, etc.)
 *@param{Object}[params.metadata] - Additional information about the event
 *@param{string}[params.ip] - IP address of the actor
 *@param{string}[params.userAgent] - User agent of the actor
 */
export async function logAuditEvent({
  event,
  actor,
  target,
  targetType,
  metadata = {},
  ip,
  userAgent
}) {
  try {
// Remove sensitive data from metadata
    const sanitizedMetadata = sanitizeMetadata(metadata);

// Create the audit log entry
    await db.auditLog.create({
      data: {
        event,
        actor,
        target,
        targetType,
        metadata: sanitizedMetadata,
        ip,
        userAgent,
        timestamp: new Date()
      }
    });

// For critical security events, consider additional alerting
    if (isCriticalEvent(event)) {
      await sendSecurityAlert(event, actor, target, ip);
    }
  } catch (error) {
// Ensure audit logging failures don't break the application
    console.error('Audit logging failed:', error);

// For production, you might want to send this to a fallback logging system// or retry the logging operation
  }
}

// Helper functions
function sanitizeMetadata(metadata) {
// Create a deep copy
  const sanitized = JSON.parse(JSON.stringify(metadata));

// List of fields to redact
  const sensitiveFields = [
    'password', 'token', 'secret', 'key', 'credential', 'ssn', 'credit_card',
    'creditCard', 'cardNumber', 'cvv', 'pin'
  ];

// Recursively redact sensitive data
  function redact(obj) {
    if (!obj || typeof obj !== 'object') return;

    Object.keys(obj).forEach(key => {
      const lowerKey = key.toLowerCase();

// Check if this is a sensitive field
      if (sensitiveFields.some(field => lowerKey.includes(field))) {
        obj[key] = '[REDACTED]';
      } else if (typeof obj[key] === 'object') {
        redact(obj[key]);
      }
    });
  }

  redact(sanitized);
  return sanitized;
}

function isCriticalEvent(event) {
// Define which events are considered critical security events
  const criticalEvents = [
    AUDIT_EVENTS.AUTH.LOGIN_FAILURE,
    AUDIT_EVENTS.AUTH.PASSWORD_CHANGE,
    AUDIT_EVENTS.USER.ROLE_CHANGED,
    AUDIT_EVENTS.ADMIN.USER_IMPERSONATION,
    AUDIT_EVENTS.API.KEY_CREATED,
    AUDIT_EVENTS.API.KEY_REVOKED
  ];

  return criticalEvents.includes(event);
}

async function sendSecurityAlert(event, actor, target, ip) {
// Implementation would depend on your notification system// This could send an email, Slack message, or trigger a monitoring alert

  console.log(`SECURITY ALERT: ${event} by ${actor} affecting ${target} from ${ip}`);

// For production, implement your preferred alerting mechanism
}

Best Practices for SaaS Security 🛡️

To ensure your SaaS application remains secure over time, follow these essential best practices:

Continuous Security Practices

  1. Regular Security Audits
    • Conduct comprehensive security reviews quarterly
    • Use automated vulnerability scanning tools
    • Perform penetration testing annually
  1. Dependency Management
    • Use tools like npm audit and Dependabot to check for vulnerabilities
    • Keep all dependencies updated
    • Remove unused dependencies
  1. Security Training
    • Ensure all developers understand basic security principles
    • Stay informed about the latest security threats
    • Create and maintain security documentation

Key Security Principles

  1. Defense in Depth
    • Implement multiple layers of security controls
    • Never rely on a single security measure
    • Assume external layers will eventually be bypassed
  1. Principle of Least Privilege
    • Give users and systems only the access they need
    • Regularly review and revoke unnecessary permissions
    • Use role-based access control
  1. Secure by Default
    • Start with the most secure configuration
    • Require explicit opt-in for less secure options
    • Make security the path of least resistance

Responding to Security Incidents

  1. Incident Response Plan
    • Prepare a documented incident response procedure
    • Define roles and responsibilities clearly
    • Practice incident response scenarios
  1. Disclosure Policy
    • Create a responsible disclosure policy
    • Consider a bug bounty program
  1. Post-Incident Analysis
    • Conduct thorough post-incident reviews
    • Document lessons learned
    • Implement improvements to prevent similar incidents

Practice Exercises 🏋️

  1. Security Headers Audit
    • Implement the recommended security headers
    • Compare the before and after scores
  1. Environment Variables Challenge
    • Audit your codebase for hardcoded secrets
    • Move all sensitive values to environment variables
    • Ensure no secrets are leaked to the client
  1. API Security Implementation
    • Take an existing API endpoint in your application
    • Add proper authentication and authorization checks
    • Implement input validation with Zod
    • Add rate limiting
  1. Security Middleware Configuration
    • Set up a basic security middleware for your Next.js app
    • Implement Content Security Policy headers
    • Add path-based access controls

Pro Tip: Security is not a one-time task but an ongoing process. Schedule regular time for security reviews and improvements, and build security considerations into your development workflow from the start.

What You've Learned ✅

In this chapter, you've learned the foundations of web application security for your SaaS MVP:

  • How to leverage React Server Components to keep sensitive operations secure
  • Proper management of environment variables to protect secrets
  • Implementing comprehensive API endpoint controls
  • Creating effective security middleware and firewalls

Security might seem daunting at first, but by implementing these foundational practices, you'll create a secure foundation for your SaaS that will protect both your business and your users.

Coming up next: In Chapter 24, we'll explore advanced concepts like serverless functions, WebSockets, and other technologies that can take your SaaS to the next level.

Blog CTA Snippet

Don't Have Time to Build Your SaaS?

Learning to code is powerful — but your time is better spent growing your business. Let our expert team handle your MVP while you focus on what matters.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top