Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

How to Build a Next.js App That's Harder to Break in Penetration Testing πŸ”

You ship your Next.js app. It looks great and the Lighthouse score is green. Then a penetration tester sends you a report, and it's longer than your README. It happens to many of us. Security often feels like something

You ship your Next.js app. It looks great and the Lighthouse score is green. Then a penetration tester sends you a report, and it's longer than your README.

It happens to many of us. Security often feels like something to "add later," right after the feature is done. Later usually arrives as a surprise.

So here's a question: what if your next pen test came back with only a few small notes instead of a wall of red? 😊

No app is 100% unhackable, and anyone who promises that is selling something. But you can build a secure Next.js app that is much harder to attack. Let's do it step by step.

What Does "Hard to Penetrate" Actually Mean?

Penetration testing (pen testing) is when a security tester, with permission, tries to break into your app the way a real attacker would. They look for weak spots, then report them so you can fix them.

Think of your app as a house:

  • The doors and windows are your login, forms, and API routes.
  • The locks are your authentication and authorization.
  • The security camera is your logging and monitoring.

A pen tester walks around the house and tries every handle. A hard-to-penetrate app has strong locks, few unnecessary doors, and nothing valuable left on the porch.

Your goal isn't a perfect house. It's a house where the easy tricks don't work, so attackers give up or get caught.

Why This Matters

  • Real users trust you with real data. Emails, passwords, and messages deserve protection.
  • Fixing early is cheaper. A missing check found during development takes minutes. Found after launch, it can take days.
  • Client and employer confidence. If you build for clients or want a strong portfolio, secure habits set you apart.
  • Fewer 2 a.m. emergencies. Boring, solid security means calmer weekends. βœ…

10 Practical Ways to Harden Your Next.js App

1. Keep Next.js and your dependencies updated

Security bugs get found in frameworks, and fixes ship in updates. For example, in 2025 a vulnerability (CVE-2025-29927) showed that apps relying only on middleware for authorization could be bypassed in some versions. Updating fixed it.

Developer moment: "I'll update later" is the most expensive sentence in web development. πŸ˜…

  • Update Next.js and React regularly.
  • Run pnpm audit and read the results.
  • Check the official Next.js security announcements now and then.

2. Check permissions close to the data

This is the big one. Don't rely only on middleware or hidden buttons to protect things.

Server Actions and Route Handlers are reachable over HTTP, so treat each one like a public API endpoint. Anyone can call them directly, not just through your UI.

Check who the user is and what they're allowed to do right where the data is read or changed:

"use server";

import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";

const schema = z.object({ postId: z.string().uuid() });

export async function deletePost(formData: FormData) {
  const user = await getCurrentUser();
  if (!user) throw new Error("Unauthorized");

  const { postId } = schema.parse({ postId: formData.get("postId") });

  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post || post.authorId !== user.id) throw new Error("Forbidden");

  await db.post.delete({ where: { id: postId } });
}

Notice three things: login check, input validation, and ownership check. Skipping the ownership check is a classic pen-test finding, where user A can edit user B's data by changing an ID.

3. Validate every input on the server

Browser validation is for user-friendliness, not security. Anyone can skip your form and send requests directly.

  • Validate request bodies, query params, and form data on the server.
  • A schema library like Zod makes this clean.
  • Reject anything unexpected instead of trying to "fix" it.

4. Add security headers

Headers are a cheap way to close several doors at once. In Next.js you can set them in next.config.ts:

import type { NextConfig } from "next";

const securityHeaders = [
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=()",
  },
];

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

export default nextConfig;

Also consider a Content Security Policy (CSP). It tells the browser which scripts and resources are allowed, which reduces the damage if an XSS bug slips through. The Next.js docs explain how to set one up with nonces. Test it carefully, since a strict CSP can break scripts you forgot about.

5. Protect your cookies and sessions

If you handle sessions yourself, set cookies with:

  • httpOnly so JavaScript can't read them
  • secure so they only travel over HTTPS
  • sameSite (usually lax or strict) to reduce CSRF risk

Better still, use a well-maintained auth library instead of writing session logic from scratch. Rolling your own auth is how many bugs are born.

6. Keep secrets out of the browser

In Next.js, any environment variable starting with NEXT_PUBLIC_ is bundled into client code. Anyone can read it.

  • Never put API secrets, database URLs, or private keys behind NEXT_PUBLIC_.
  • Use the server-only package to stop server code from being imported into client components by accident.
  • Never commit .env files to Git.

7. Be careful with HTML you didn't write

React escapes content by default, which protects you from a lot of XSS. The danger starts when you bypass it:

  • Avoid dangerouslySetInnerHTML unless you really need it.
  • If you do need it, sanitize the HTML with a trusted library first.
  • Never trust user-provided URLs blindly, especially in href attributes.

8. Add rate limiting to sensitive routes

Login, password reset, and signup endpoints attract automated guessing. Rate limiting slows that down.

  • Limit attempts per IP or per account.
  • Add small delays or temporary lockouts after repeated failures.
  • Use the same generic message for "wrong email" and "wrong password" so attackers can't learn which accounts exist.

9. Don't leak details in errors

A stack trace is a gift to an attacker. It can reveal file paths, library versions, and database details.

  • Show users a friendly, generic error.
  • Log the real details on the server.
  • Don't expose internal IDs or database error messages in API responses.

10. Test your own app (with permission)

Before a real pen tester shows up, try basic checks yourself. Only test apps you own or have written permission to test.

  • Run an automated scanner like OWASP ZAP against your staging site.
  • Walk through the OWASP Top 10 list and ask, "Could this happen in my app?"
  • Try to access another user's data by changing an ID in the URL. If it works, you've found a real bug. πŸ‘€

Comparison: Client-Side Checks vs Server-Side Checks

Client-side only Server-side enforced
Hiding an "Admin" button Looks secure Actually secure only if the server also checks
Form validation Good for UX Needed for security
Pen-test result Easy to bypass Much harder to bypass

The UI is the lobby, and the server is the locked vault. Pen testers skip the lobby and go straight to the vault.

Quick Do's and Don'ts

Do βœ…

  • Check authorization on every Server Action and API route
  • Validate all input on the server
  • Keep dependencies updated
  • Use trusted auth libraries
  • Log suspicious activity

Don't ❌

  • Trust the frontend to enforce rules
  • Put secrets in NEXT_PUBLIC_ variables
  • Show raw error messages to users
  • Disable security features just to make an error go away
  • Assume "nobody will find this URL" is a security plan

Common Mistakes People Make

1. Protecting only the page, not the action.
The page is hidden from non-admins, but the API behind it is open. This happens because the UI feels like the whole app. Protect the data layer too.

2. Trusting IDs from the client.
If the request says userId: 42, don't believe it. Take the user from the session instead.

3. Treating security as a final-week task.
Adding it at the end means rewriting things. Build the habits in from day one.

4. Copy-pasting auth code you don't understand.
It works until it doesn't. Read what it does, or use a maintained library.

5. Forgetting old routes and test endpoints.
That /api/test route from last month may still be live. Clean up before deploying.

6. Believing "my framework handles it."
Next.js gives you good defaults, but it can't know your business rules. Who can see what is your job.

Conclusion

Building a Next.js app that's harder to penetrate isn't about being a security genius. It's about a handful of solid habits:

  • Keep everything updated
  • Check permissions on the server, close to the data
  • Validate all input
  • Set security headers
  • Protect cookies and secrets
  • Limit abuse with rate limiting
  • Test your own work before someone else does

Start with one or two of these today. Secure apps are built one small, boring, careful step at a time. πŸš€

If this helped, you can read more developer-friendly posts on hamidrazadev.com. And if you know someone shipping a Next.js app, share this with them. What's the most surprising security bug you've seen in a project? Drop it in the comments. 😊

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.