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 auditand 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:
-
httpOnlyso JavaScript can't read them -
secureso they only travel over HTTPS -
sameSite(usuallylaxorstrict) 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-onlypackage to stop server code from being imported into client components by accident. - Never commit
.envfiles 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
dangerouslySetInnerHTMLunless 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
hrefattributes.
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. š
Top comments (0)