
Matheus Alisauska
Software Engineer
@EnlivEnergia
Next.js is awesome. It’s fast, flexible, and beginner friendly which is exactly why so many developers are picking it up. But here’s the thing: when you’re moving fast, it’s easy to miss some critical security details, especially when your app handles both the frontend and the backend. Let’s talk about why your Next.js project might not be as secure as it looks (and how to fix that).
1. Data fetching with server actions
When working with Next.js, it’s crucial to understand how to properly use Server Actions. Server Actions are primarily designed for mutations, not for data fetching. However, many developers may not realize that when you create a Server Action, it automatically generates an endpoint — very similar to an API Routes
Here’s the catch: this endpoint isn’t private. It can be accessed by any user interacting with your system, unless proper precautions are taken. If you’re using Server Actions without strict access control, you might unintentionally expose sensitive operations or data.
This is an example of how simple decisions during development can affect security:
const handleGetPosts = async (userId: string) => { try { setLoading(true); const posts = await getPosts(userId); //Server Action setPosts(posts); } catch (error) { console.error(error) } finally { setLoading(false) } }
We have a function that takes a user ID as a parameter. This userId
comes from another function that returns the current user’s ID along with some other irrelevant data.
The getPosts
function is a Server Action, and we’re using it inside a client component.
Because of that, the request can be easily intercepted, and the payload in this case, the current user’s ID — can be tampered with.
This is the result of that function — we take what it returns and display it in the UI.
Now let’s intercept and modify the payload to retrieve information from other users.
Using Burp Suite, it’s incredibly easy to intercept and modify the payload. In this example, I simply changed the userId from 1 to 2 and just like that, I was able to fetch another user’s data. What makes this especially dangerous is the fact that the IDs are incremental. If you were using randomized IDs or UUIDs, this would be much harder to exploit, but even then, relying on obscurity is not real security.
And now, what we see are the posts of the user with ID 2.
How to Prevent This Issue
-
Never Trust the Client Always validate data on the server side. The client can easily manipulate requests, so never rely on values like userId passed from the client. Always validate them on the server before proceeding.
-
Use UUIDs for User IDs Switch from incremental IDs to UUIDs to make it harder for attackers to guess or manipulate user data.
-
Understand How Server Actions Behave Use server actions appropriately, prefer them for mutations (e.g., creating, updating, or deleting data) rather than for fetching data. This helps maintain a clear separation of concerns and avoids unnecessary data exposure.
2. Insecure Route Handlers
One of the things that makes Next.js a true full-stack framework is Route Handlers. With them, we can create routes that can be accessed by any external application and it’s pretty common to use them for things like auth callbacks, payment gateway integrations, and more.
But if we don’t add a proper layer of security and validation, they can quickly become a major vulnerability in our system.
Here’s an example of a poorly protected API route:
// app/api/checkout/route.ts export async function POST(req: Request) { const body = await req.json(); const { userId, productId, quantity } = body; const session = await createCheckoutSession(userId, productId, quantity); return NextResponse.json({ url: session.url }); }
In this checkout implementation, we can already identify a few issues:
1. Missing payload validation When the route doesn’t validate the incoming data, the user can create a checkout with an invalid product quantity, or even send a property with an unexpected type. If this isn’t handled properly, it could easily cause errors in the system, or worse, be exploited to break functionality or abuse the business logic.
2. No userId verification Since we’re fully trusting the incoming payload, we’re not checking whether the provided userId actually belongs to the user making the request. Before proceeding with the checkout, we should implement logic to verify the user’s session and ensure the IDs match.
3. Trusting client-provided data unnecessarily Including the userId in the request body is not only redundant — it’s a security risk. This information is already available on the server through the authenticated session. Accepting it from the client opens the door for impersonation attempts or logic abuse. In general, never trust the client for things the server already knows.
Here’s a safer version of the same route handler that addresses all three issues:
// app/api/checkout/route.ts import { NextResponse } from 'next/server'; import { z } from 'zod'; import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; const checkoutSchema = z.object({ productId: z.string(), quantity: z.number().int().positive().max(10), }); export async function POST(req: Request) { const { getUser } = getKindeServerSession(); const user = await getUser(); if (!user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await req.json(); const result = checkoutSchema.safeParse(body); if (!result.success) { return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); } const { productId, quantity } = result.data; const sessionCheckout = await createCheckoutSession(user.id, productId, quantity); return NextResponse.json({ url: sessionCheckout.url }); }
We’ve removed the responsibility of sending the userId
from the client, and instead rely on the authenticated session to get it safely on the server. This ensures that only the account owner can create a checkout for themselves. On top of that, we run our payload through a schema to validate its structure and types before proceeding.
3. Leaking Server Data
Imagine we’re working on a project with multiple developers, and we have a function that deals with highly sensitive data that shouldn’t be exposed to the client.
Here’s an example of such a function
export async function getSecret() { const secret = "SECRET_KEY"; //Do something with the secret }
import { getSecret } from "@/data/get-secret" export default function Page() { const secret = getSecret(); return ( <p>...</p> ) }
To prevent this kind of issue, we can implement two simple but effective measures:
- Move the
SECRET_KEY
to an environment variable - Use
import 'server-only'
to ensure the function stays on the server
Here’s what the safer version of that function could look like:
import 'server-only'; export async function getSecret() { const secret = process.env.SECRET_KEY; // Do something with the secret return secret; }
With just these two changes, we accomplish a lot:
- The sensitive key is no longer hardcoded in the source code
- The function becomes restricted to server components only — Next.js will prevent it from being imported in client components during build time
This is a great way to enforce safe usage patterns, especially in team environments where accidental imports can happen. By applying these small guardrails, we significantly reduce the chances of a critical mistake making its way to production
And that’s it! Just a couple of small changes can make a big difference when it comes to keeping your app secure.
Hope this gave you some useful insights — feel free to share with someone who’s working with Next.js too. See you in the next one!