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
- API Keys and Secrets Stay Protected: Server Component code never reaches the client, keeping your credentials secure.
- Reduced Attack Surface: Less JavaScript shipped to the browser means fewer potential vulnerabilities.
- Sensitive Data Protection: Process and filter sensitive data on the server before sending results to the client.
- 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
- Exposed Validation Logic: Attackers can bypass client-side validation.
- No Secrets Allowed: Never include API keys or sensitive values in client components.
- State Manipulation: Browser state can be manipulated through dev tools.
- 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:
- Server-Only Variables: Only accessible on the server
- 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
- Never Commit Secrets to Git Always include
.env*.localfiles in your.gitignore:# .gitignore .env .env.local .env*.local
- 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)
- 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="..."
- 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();
- 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
- 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
- Regular Security Audits
- Conduct comprehensive security reviews quarterly
- Use automated vulnerability scanning tools
- Perform penetration testing annually
- Dependency Management
- Use tools like
npm auditand Dependabot to check for vulnerabilities
- Keep all dependencies updated
- Remove unused dependencies
- Use tools like
- Security Training
- Ensure all developers understand basic security principles
- Stay informed about the latest security threats
- Create and maintain security documentation
Key Security Principles
- Defense in Depth
- Implement multiple layers of security controls
- Never rely on a single security measure
- Assume external layers will eventually be bypassed
- Principle of Least Privilege
- Give users and systems only the access they need
- Regularly review and revoke unnecessary permissions
- Use role-based access control
- 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
- Incident Response Plan
- Prepare a documented incident response procedure
- Define roles and responsibilities clearly
- Practice incident response scenarios
- Disclosure Policy
- Create a responsible disclosure policy
- Set up a security@yourdomain.com email
- Consider a bug bounty program
- Post-Incident Analysis
- Conduct thorough post-incident reviews
- Document lessons learned
- Implement improvements to prevent similar incidents
Practice Exercises 🏋️
- Security Headers Audit
- Use securityheaders.com to test your site
- Implement the recommended security headers
- Compare the before and after scores
- Environment Variables Challenge
- Audit your codebase for hardcoded secrets
- Move all sensitive values to environment variables
- Ensure no secrets are leaked to the client
- 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
- 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.

