Skip to main content
Back to Blog
Tutorials
4 min read
December 13, 2024

How to Secure a Next.js App: Authentication, CSRF, Headers, and More

Harden your Next.js app with security headers, CSRF protection, input sanitization, rate limiting, and secure authentication patterns.

Ryel Banfield

Founder & Lead Developer

Security is not something you add later. Here is a practical guide to hardening your Next.js application.

Security Headers

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    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: "Referrer-Policy",
    value: "strict-origin-when-cross-origin",
  },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(self)",
  },
  {
    key: "X-XSS-Protection",
    value: "1; mode=block",
  },
];

const config: NextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: securityHeaders,
      },
    ];
  },
};

export default config;

Content Security Policy

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

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const response = NextResponse.next();

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data: https:`,
    `font-src 'self'`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'self'`,
    `form-action 'self'`,
    `base-uri 'self'`,
    `upgrade-insecure-requests`,
  ].join("; ");

  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("x-nonce", nonce);

  return response;
}

CSRF Protection

// lib/csrf.ts
import { cookies } from "next/headers";
import { randomBytes, createHmac } from "crypto";

const SECRET = process.env.CSRF_SECRET!;

export async function generateCSRFToken(): Promise<string> {
  const token = randomBytes(32).toString("hex");
  const signature = createHmac("sha256", SECRET).update(token).digest("hex");
  const signedToken = `${token}.${signature}`;

  const cookieStore = await cookies();
  cookieStore.set("csrf-token", signedToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    path: "/",
    maxAge: 3600,
  });

  return signedToken;
}

export async function verifyCSRFToken(token: string): Promise<boolean> {
  const cookieStore = await cookies();
  const storedToken = cookieStore.get("csrf-token")?.value;

  if (!storedToken || storedToken !== token) return false;

  const [rawToken, signature] = token.split(".");
  const expectedSignature = createHmac("sha256", SECRET)
    .update(rawToken)
    .digest("hex");

  return signature === expectedSignature;
}
// Use in Server Components
import { generateCSRFToken } from "@/lib/csrf";

export default async function ContactPage() {
  const csrfToken = await generateCSRFToken();

  return (
    <form action="/api/contact" method="POST">
      <input type="hidden" name="_csrf" value={csrfToken} />
      {/* Form fields */}
    </form>
  );
}
// Verify in API route
import { verifyCSRFToken } from "@/lib/csrf";

export async function POST(request: Request) {
  const formData = await request.formData();
  const csrfToken = formData.get("_csrf") as string;

  const valid = await verifyCSRFToken(csrfToken);
  if (!valid) {
    return new Response("Invalid CSRF token", { status: 403 });
  }

  // Process form...
}

Input Validation and Sanitization

// lib/validation.ts
import { z } from "zod";
import DOMPurify from "isomorphic-dompurify";

// Strict schemas at API boundaries
export const ContactFormSchema = z.object({
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .transform((val) => DOMPurify.sanitize(val)),
  email: z
    .string()
    .email("Invalid email address")
    .max(255, "Email too long"),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message too long")
    .transform((val) => DOMPurify.sanitize(val)),
  phone: z
    .string()
    .regex(/^\+?[\d\s\-()]{7,20}$/, "Invalid phone number")
    .optional()
    .or(z.literal("")),
});

export type ContactFormData = z.infer<typeof ContactFormSchema>;

Authentication Middleware

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

const PROTECTED_ROUTES = ["/dashboard", "/admin", "/settings", "/api/admin"];
const AUTH_ROUTES = ["/login", "/register"];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const sessionToken = request.cookies.get("session-token")?.value;

  // Redirect authenticated users away from auth pages
  if (AUTH_ROUTES.some((route) => pathname.startsWith(route)) && sessionToken) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  // Protect dashboard routes
  if (PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) {
    if (!sessionToken) {
      const loginUrl = new URL("/login", request.url);
      loginUrl.searchParams.set("redirect", pathname);
      return NextResponse.redirect(loginUrl);
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*", "/settings/:path*", "/login", "/register", "/api/admin/:path*"],
};

Rate Limiting

// lib/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

interface RateLimitOptions {
  windowMs?: number;
  maxRequests?: number;
}

export function rateLimit(
  identifier: string,
  options: RateLimitOptions = {}
): { success: boolean; remaining: number; resetIn: number } {
  const { windowMs = 60_000, maxRequests = 10 } = options;
  const now = Date.now();
  const entry = rateLimitMap.get(identifier);

  if (!entry || now > entry.resetTime) {
    rateLimitMap.set(identifier, { count: 1, resetTime: now + windowMs });
    return { success: true, remaining: maxRequests - 1, resetIn: windowMs };
  }

  if (entry.count >= maxRequests) {
    return {
      success: false,
      remaining: 0,
      resetIn: entry.resetTime - now,
    };
  }

  entry.count++;
  return {
    success: true,
    remaining: maxRequests - entry.count,
    resetIn: entry.resetTime - now,
  };
}
// Usage in API route
import { rateLimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  const { success, remaining, resetIn } = rateLimit(ip, {
    windowMs: 60_000,
    maxRequests: 5,
  });

  if (!success) {
    return NextResponse.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil(resetIn / 1000)),
          "X-RateLimit-Remaining": "0",
        },
      }
    );
  }

  // Handle request...
  const response = NextResponse.json({ ok: true });
  response.headers.set("X-RateLimit-Remaining", String(remaining));
  return response;
}

Secure Cookie Configuration

// lib/session.ts
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function createSession(userId: string) {
  const token = await new SignJWT({ userId })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("7d")
    .setIssuedAt()
    .sign(secret);

  const cookieStore = await cookies();
  cookieStore.set("session-token", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });
}

export async function getSession(): Promise<{ userId: string } | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get("session-token")?.value;
  if (!token) return null;

  try {
    const { payload } = await jwtVerify(token, secret);
    return { userId: payload.userId as string };
  } catch {
    return null;
  }
}

Security Checklist

  1. Set all security headers including CSP
  2. Use CSRF tokens on all state-changing forms
  3. Validate and sanitize all input at API boundaries
  4. Use parameterized queries (never string concatenation for SQL)
  5. Set HttpOnly, Secure, SameSite on all cookies
  6. Implement rate limiting on authentication and form endpoints
  7. Use HTTPS everywhere in production
  8. Keep dependencies updated and audit regularly
  9. Never expose stack traces or internal errors to users
  10. Log security events for monitoring

Need a Security Audit?

We review and harden web applications against common vulnerabilities. Contact us for a security consultation.

securityCSRFheadersauthenticationNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles