50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 22, 2026·7 min read

Security Checklist for Production Next.js Apps (The One We Actually Use)

A no-nonsense security checklist we built after getting too close to real vulnerabilities. Headers, auth, secrets, SQL injection, and more.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Security Checklist for Production Next.js Apps (The One We Actually Use)

We almost shipped a template with a hardcoded secret in a Server Action. It was a test value, it was obviously temporary, and we forgot it was there for three weeks. Nothing happened — but it was a reminder that security issues don't announce themselves. They sit quietly until they don't.

This is the checklist we run through before every production launch. Not theory — stuff we've actually needed. Some of it we learned the hard way, some of it we learned by reading other people's post-mortems at 1am so we wouldn't have to write our own.

1. HTTP Security Headers

Headers are the lowest-effort, highest-impact security improvement you can make. Next.js lets you set them in next.config.ts and they apply globally. Most apps skip this entirely and leave a pile of easy wins on the table.

// next.config.ts
const securityHeaders = [
  {
    key: 'X-DNS-Prefetch-Control',
    value: 'on',
  },
  {
    key: 'X-Frame-Options',
    value: 'SAMEORIGIN', // prevents clickjacking
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=()',
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'", // tighten this in production
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' blob: data: https:",
      "font-src 'self'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      'block-all-mixed-content',
      'upgrade-insecure-requests',
    ].join('; '),
  },
];

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

export default nextConfig;

The CSP is the tricky one. 'unsafe-inline' and 'unsafe-eval' are necessary for a lot of third-party scripts and Next.js itself. The goal isn't perfection — it's making attacks harder. Start with this and tighten as you go. Run your app through securityheaders.com to see where you stand.

2. Environment Variables and Secrets

The most common mistake we see isn't accidentally committing secrets — it's accidentally exposing them to the browser. Anything you prefix with NEXT_PUBLIC_ is bundled into the client-side JavaScript. Anyone can read it. That's fine for a public API URL, catastrophic for a database password.

  • Server-only secrets: DATABASE_URL, STRIPE_SECRET_KEY, email API keys — never NEXT_PUBLIC_
  • Client-safe values: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, NEXT_PUBLIC_APP_URL — fine to expose
  • Validate all env vars at startup using zod — crash loudly on missing values instead of silently failing
  • Never commit .env.local — check your .gitignore right now
  • Rotate secrets after any accidental exposure, even in private repos
// lib/env.ts — validate at startup, not at runtime
import { z } from 'zod';

const serverEnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  RESEND_API_KEY: z.string().startsWith('re_'),
});

const clientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
});

// This runs on the server only
export const serverEnv = serverEnvSchema.parse({
  DATABASE_URL: process.env.DATABASE_URL,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  RESEND_API_KEY: process.env.RESEND_API_KEY,
});

export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
});

If you import serverEnv in a Client Component, Next.js will throw a build error. That's the behavior you want — explicit failure, not a silent secret leak.

3. Authentication and Authorization

Auth bugs are quiet. A missing middleware check, a forgotten authorization step in a Server Action — nothing breaks, users just access things they shouldn't. We've seen multi-tenant apps where you could swap the org ID in a request and get another company's data. Simple to exploit, embarrassing to explain.

  • Always verify the session server-side, never trust client-provided user IDs
  • Check authorization (can this user do this?), not just authentication (is this user logged in?)
  • Use middleware for route-level protection, but also verify in Server Actions and Route Handlers
  • Session tokens should be httpOnly cookies — not localStorage, not a JS-accessible cookie
  • Set a reasonable session expiry (7-30 days) and implement refresh token rotation
  • Lock account after N failed login attempts — brute force is still a thing
// Example: always verify ownership in Server Actions
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function deleteDocument(documentId: string) {
  const session = await auth();
  
  if (!session?.user?.id) {
    throw new Error('Unauthorized');
  }

  // DON'T just delete by ID — verify ownership first
  const document = await db.query.documents.findFirst({
    where: (docs, { and, eq }) => and(
      eq(docs.id, documentId),
      eq(docs.userId, session.user.id) // <-- this is the important part
    ),
  });

  if (!document) {
    // Either doesn't exist OR belongs to someone else
    // Return the same error either way — don't leak existence info
    throw new Error('Document not found');
  }

  await db.delete(documents).where(eq(documents.id, documentId));
}

4. Input Validation and SQL Injection

If you're using Drizzle or Prisma, parameterized queries are the default and SQL injection is basically a non-issue. Where you can still get hurt is in raw SQL, dynamic query building, and — increasingly — passing user input to AI tools that construct queries. Validate everything that comes from outside your application.

// lib/validations/user.ts
import { z } from 'zod';

export const updateProfileSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  bio: z.string().max(500).trim().optional(),
  website: z.string().url().optional().or(z.literal('')),
});

// In your Server Action:
export async function updateProfile(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error('Unauthorized');

  const raw = {
    name: formData.get('name'),
    bio: formData.get('bio'),
    website: formData.get('website'),
  };

  // Parse throws if validation fails — caller gets a clear error
  const validated = updateProfileSchema.parse(raw);

  await db
    .update(users)
    .set(validated)
    .where(eq(users.id, session.user.id));
}

Also: don't trust file uploads. Validate file type on the server (not just the extension — check the MIME type), limit file sizes at the infrastructure level, and never serve uploaded files from the same origin as your app if you can avoid it.

5. CSRF, CORS, and Server Actions

Next.js Server Actions have built-in CSRF protection — they check the Origin header and require the request to come from the same origin. This is mostly handled for you. What's NOT handled for you is CORS on Route Handlers.

The default for Route Handlers is restrictive, but if you've added CORS headers to support a mobile app or a third-party integration, double check that you're not accidentally opening up sensitive endpoints. Wildcard CORS ('*') on an authenticated endpoint is a real vulnerability.

// app/api/public-data/route.ts — CORS for a truly public endpoint
export async function GET(request: Request) {
  const data = await getPublicData();
  
  return Response.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // fine for public, read-only data
      'Access-Control-Allow-Methods': 'GET',
    },
  });
}

// app/api/user-data/route.ts — authenticated endpoint, no wildcard
export async function GET(request: Request) {
  const session = await auth();
  if (!session) return new Response('Unauthorized', { status: 401 });

  // No CORS headers here — same-origin only
  const data = await getUserData(session.user.id);
  return Response.json(data);
}

6. Dependency Vulnerabilities and Supply Chain

npm packages are a legitimate attack vector. The 'event-stream' incident from 2018 seems ancient but the pattern keeps repeating. You don't need to be paranoid, but you do need a process.

  • Run `npm audit` (or `pnpm audit`) regularly — add it to your CI pipeline
  • Use Dependabot or Renovate to get automated PRs for security updates
  • Be suspicious of packages with very few downloads that do a lot — check the source
  • Lock your dependency versions in package.json (exact versions for critical security packages)
  • Check what a package actually does before installing it — `npx` especially
The attack surface of a modern web app is mostly your dependencies, not your code. You probably have 300+ packages in node_modules. You wrote maybe 10% of what's actually running.

7. Data Exposure in API Responses

This one bites people constantly. You have a User table with a password hash, a stripeCustomerId, internal flags, admin booleans. You SELECT * and return the whole object. Now your frontend JavaScript has access to fields it should never see — and so does anyone inspecting the network tab.

// Bad: returning the entire DB row
export async function getUser(userId: string) {
  return db.query.users.findFirst({
    where: eq(users.id, userId),
    // No 'columns' filter — returns passwordHash, stripeCustomerId, isAdmin, etc.
  });
}

// Good: explicit field selection
export async function getUser(userId: string) {
  return db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: {
      id: true,
      name: true,
      email: true,
      avatarUrl: true,
      createdAt: true,
      // passwordHash: false (implicit — don't include it)
      // stripeCustomerId: false
      // isAdmin: false
    },
  });
}

// Or use a Zod schema to strip sensitive fields before returning
const publicUserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  avatarUrl: z.string().nullable(),
});

export type PublicUser = z.infer<typeof publicUserSchema>;

TypeScript gives you a false sense of security here. The type might not include passwordHash, but if you're doing a raw query and not explicitly selecting columns, the data is still there in the response — just not visible to your editor.

The Quick-Reference Checklist

Before you push to production, run through this:

  • [ ] Security headers configured in next.config.ts (X-Frame-Options, CSP, HSTS minimum)
  • [ ] No secrets prefixed with NEXT_PUBLIC_
  • [ ] .env.local in .gitignore, no secrets in git history
  • [ ] Environment variables validated with zod at startup
  • [ ] All auth checks happen server-side (not just client-side redirects)
  • [ ] Server Actions verify ownership before modifying data
  • [ ] All user input validated and sanitized before DB writes
  • [ ] File uploads: type validation, size limits, separate origin or CDN
  • [ ] CORS configured conservatively — no wildcard on authenticated endpoints
  • [ ] npm audit passing with no high/critical vulnerabilities
  • [ ] API responses explicitly select columns — no SELECT *
  • [ ] Error messages don't leak internal details to users
  • [ ] Rate limiting on auth endpoints (login, signup, password reset)
  • [ ] HTTPS only in production, Strict-Transport-Security header set
  • [ ] Dependency versions locked, automated security updates enabled

The templates we ship on peal.dev have most of this baked in by default — security headers in the config, validated env vars, auth patterns that enforce ownership checks. Starting from something secure is a lot easier than retrofitting it onto an existing app.

One last thing: security isn't a one-time checklist. It's a habit. The most impactful thing you can do long-term is add npm audit to your CI, set up Dependabot, and actually read the security advisories when they land. Most production incidents we know about weren't sophisticated attacks — they were known vulnerabilities that nobody got around to patching.

You don't need to make your app impossible to attack. You need to make it harder to attack than the next app. Most attackers are running automated scripts looking for low-hanging fruit.
Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders