Back to all posts
December 15, 2025Charlie BrownSecurity

Next.js Security Best Practices: Protecting Your Application

Learn essential security practices for Next.js applications including authentication, CSRF protection, XSS prevention, and secure API routes.

Next.js Security Best Practices: Protecting Your Application

Next.js Security Best Practices: Protecting Your Application

Security is paramount in modern web applications. Next.js provides powerful features for building secure applications, but understanding how to properly implement security measures is crucial. In this article, we'll explore essential security practices for Next.js applications, covering authentication, CSRF protection, XSS prevention, and secure API routes.

Understanding Next.js Security Model

Next.js applications run in multiple environments:

  • Server-side: API routes, Server Components, Server Actions
  • Client-side: Client Components, browser JavaScript
  • Build-time: Static generation, optimization

Each environment requires different security considerations.

Authentication and Authorization

1. Secure Session Management

Use httpOnly cookies for session tokens to prevent XSS attacks:

typescript
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function POST(request: NextRequest) {
  const { email, password } = await request.json();
  
  // Validate credentials
  const user = await authenticateUser(email, password);
  
  if (!user) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }

  // Generate secure token
  const token = await generateToken(user);
  
  // Set httpOnly cookie
  cookies().set('session_token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  });

  return NextResponse.json({ success: true });
}

2. Middleware for Route Protection

Use Next.js middleware to protect routes:

typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session_token')?.value;

  // Protect dashboard routes
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    // Verify token validity
    try {
      verifyToken(token);
    } catch (error) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

3. Server Actions Authentication

Protect Server Actions with authentication checks:

typescript
// app/actions/user-actions.ts
'use server';

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

async function getAuthenticatedUser() {
  const token = cookies().get('session_token')?.value;
  
  if (!token) {
    redirect('/login');
  }

  try {
    const user = await verifyToken(token);
    return user;
  } catch (error) {
    redirect('/login');
  }
}

export async function updateProfile(formData: FormData) {
  const user = await getAuthenticatedUser();
  
  // Only allow users to update their own profile
  const userId = formData.get('userId');
  if (userId !== user.id) {
    throw new Error('Unauthorized');
  }

  // Update profile logic
  await updateUserProfile(userId, formData);
}

CSRF Protection

1. CSRF Tokens for Forms

Implement CSRF protection for form submissions:

typescript
// app/api/csrf/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import crypto from 'crypto';

export async function GET() {
  const token = crypto.randomBytes(32).toString('hex');
  
  cookies().set('csrf_token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 60 * 60, // 1 hour
  });

  return NextResponse.json({ csrfToken: token });
}

2. Validate CSRF Tokens

Validate CSRF tokens in API routes:

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function POST(request: NextRequest) {
  const body = await request.json();
  const csrfToken = request.headers.get('X-CSRF-Token');
  const cookieToken = cookies().get('csrf_token')?.value;

  if (!csrfToken || csrfToken !== cookieToken) {
    return NextResponse.json(
      { error: 'Invalid CSRF token' },
      { status: 403 }
    );
  }

  // Process request
  return NextResponse.json({ success: true });
}

XSS Prevention

1. Sanitize User Input

Always sanitize user input before rendering:

typescript
// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify';

export function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  });
}

2. Use React's Built-in Escaping

React automatically escapes content, but be careful with dangerouslySetInnerHTML:

typescript
// app/components/UserContent.tsx
'use client';

import { sanitizeHtml } from '@/lib/sanitize';

export function UserContent({ content }: { content: string }) {
  // ✅ Good: Sanitize before rendering
  const sanitized = sanitizeHtml(content);
  
  return (
    <div dangerouslySetInnerHTML={{ __html: sanitized }} />
  );
}

3. Content Security Policy

Implement CSP headers:

typescript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self'",
              "connect-src 'self' https://api.example.com",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

Secure API Routes

1. Input Validation

Always validate and sanitize input:

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  age: z.number().int().min(18).max(120),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    
    // Validate input
    const validatedData = createUserSchema.parse(body);
    
    // Process request
    const user = await createUser(validatedData);
    
    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Invalid input', details: error.errors },
        { status: 400 }
      );
    }
    
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

2. Rate Limiting

Implement rate limiting to prevent abuse:

typescript
// lib/rate-limit.ts
import { LRUCache } from 'lru-cache';

const rateLimit = new LRUCache<string, number>({
  max: 500,
  ttl: 60 * 1000, // 1 minute
});

export function rateLimitCheck(identifier: string, limit: number = 10): boolean {
  const count = rateLimit.get(identifier) || 0;
  
  if (count >= limit) {
    return false;
  }
  
  rateLimit.set(identifier, count + 1);
  return true;
}
typescript
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimitCheck } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';
  
  if (!rateLimitCheck(`login:${ip}`, 5)) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  // Process login
}

3. SQL Injection Prevention

Use parameterized queries (via ORM):

typescript
// ✅ Good: Using Prisma (parameterized)
const user = await prisma.user.findUnique({
  where: { email: userEmail },
});

// ❌ Bad: Raw SQL without parameters
const user = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${userEmail}
`;

Environment Variables Security

1. Server-Only Variables

Never expose sensitive variables to the client:

typescript
// ✅ Good: Server-only variable
// .env.local
DATABASE_URL=postgresql://...
API_SECRET_KEY=secret123

// app/api/users/route.ts
const apiKey = process.env.API_SECRET_KEY; // ✅ Server-only
typescript
// ❌ Bad: Exposing secrets to client
// next.config.js
module.exports = {
  env: {
    API_SECRET_KEY: process.env.API_SECRET_KEY, // ❌ Exposed to client!
  },
};

2. Client-Safe Variables

Use NEXT_PUBLIC_ prefix only for client-safe variables:

typescript
// ✅ Good: Public API URL
// .env.local
NEXT_PUBLIC_API_URL=https://api.example.com

// Client component
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Secure Headers

1. Security Headers Configuration

Configure security headers in next.config.js:

typescript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-DNS-Prefetch-Control',
            value: 'on',
          },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload',
          },
          {
            key: 'X-Frame-Options',
            value: 'SAMEORIGIN',
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-XSS-Protection',
            value: '1; mode=block',
          },
          {
            key: 'Referrer-Policy',
            value: 'origin-when-cross-origin',
          },
        ],
      },
    ];
  },
};

File Upload Security

1. Validate File Types

Always validate file types and sizes:

typescript
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return NextResponse.json(
      { error: 'No file provided' },
      { status: 400 }
    );
  }

  // Validate file type
  if (!ALLOWED_TYPES.includes(file.type)) {
    return NextResponse.json(
      { error: 'Invalid file type' },
      { status: 400 }
    );
  }

  // Validate file size
  if (file.size > MAX_SIZE) {
    return NextResponse.json(
      { error: 'File too large' },
      { status: 400 }
    );
  }

  // Process file
  const buffer = await file.arrayBuffer();
  // Store securely...
}

Best Practices Summary

  1. Always validate input on both client and server
  2. Use httpOnly cookies for session tokens
  3. Implement CSRF protection for state-changing operations
  4. Sanitize user-generated content before rendering
  5. Use parameterized queries to prevent SQL injection
  6. Implement rate limiting to prevent abuse
  7. Never expose secrets to the client
  8. Use HTTPS in production
  9. Keep dependencies updated to patch security vulnerabilities
  10. Regular security audits of your application

Conclusion

Security in Next.js requires attention to multiple layers: authentication, input validation, XSS prevention, CSRF protection, and secure API design. By following these practices, you can build robust, secure Next.js applications that protect both your users and your data. Remember that security is an ongoing process—regular audits and updates are essential.

References

Want more insights?

Subscribe to our newsletter or follow us for more updates on software development and team scaling.

Contact Us