There is a belief that quietly causes real vulnerabilities in Next.js apps: "Server Actions are internal, so the ID I pass into them is safe."
It is not. A Server Action is an HTTP endpoint with a generated URL, and anyone with a browser's network tab can call it with whatever arguments they want.
The Code That Looks Safe
// components/PostCard.tsx
import { deletePost } from '@/actions/posts';
export function PostCard({ post }: { post: Post }) {
return (
<form action={deletePost.bind(null, post.id)}>
<button type="submit">Delete</button>
</form>
);
}
// actions/posts.ts
'use server';
export async function deletePost(id: string) {
await connectDB();
await Post.findByIdAndDelete(id);
revalidatePath('/dashboard/posts');
}
This is the exact pattern most Server Actions tutorials teach, including a version of it in my own earlier post on replacing API routes. It works. The delete button deletes the post. It is also an insecure direct object reference, usually shortened to IDOR.
What an Attacker Actually Sees
When you bind an argument like post.id, Next.js serializes it into the form submission. Open the network tab, click Delete once on your own post, and you can see the request: an action identifier plus the bound ID.
Now replay that request with a different ID.
POST /dashboard/posts
Next-Action: 7f3c...a91
Body: ["64f1a2b3c4d5e6f7a8b9c0d1"]
If deletePost never checks who is calling it, that request deletes whichever post matches the ID, whether it belongs to the caller or to a completely different user. MongoDB ObjectIds are not secret. They are partly time-based and show up in URLs, API responses, and shared links, so assuming they are unguessable is a second mistake stacked on the first.
Why This Gets Missed
Three things hide this bug.
The UI only ever shows your own records. Every manual test uses a logged-in user clicking Delete on their own post. The code path works, so nothing fails.
The authentication check can pass while authorization is missing. Many Server Actions do check that someone is logged in. Being logged in is not the same as owning the record. Authentication asks "who are you." Authorization asks "is this yours."
Binding feels like closure, not input. deletePost.bind(null, post.id) reads like the ID is captured server-side. It is not. It is serialized to the client and comes back as a normal argument.
The Fix: Check Ownership Inside the Action
// actions/posts.ts
'use server';
export async function deletePost(id: string) {
const session = await getServerSession();
if (!session?.userId) {
throw new Error('Unauthorized');
}
await connectDB();
// The ownership check lives in the query itself
const result = await Post.findOneAndDelete({
_id: id,
authorId: session.userId,
});
if (!result) {
throw new Error('Not found');
}
revalidatePath('/dashboard/posts');
}
The important line is authorId: session.userId inside the query filter. The record is only found, and therefore only deleted, if it belongs to the person making the request. There is no separate "fetch then compare" step that someone can later forget or reorder.
Returning "Not found" for both a missing record and someone else's record is deliberate. It stops an attacker from learning which IDs exist.
Validate the Shape of the Argument Too
Arguments are also untrusted in format, not only in ownership. An ID that is not a valid ObjectId can throw confusing errors or, depending on your query, behave unexpectedly.
import { z } from 'zod';
const IdSchema = z.string().regex(/^[a-f\d]{24}$/i);
export async function deletePost(rawId: string) {
const id = IdSchema.parse(rawId);
// ...
}
The Rule
Treat every Server Action like a public API route: authenticate the caller, authorize the specific record, and validate the arguments. The fact that it is a function call in your component tree changes how it feels to write, not how it behaves on the network.
A quick audit works well: search your codebase for every file with 'use server' and look at each exported function. For each one, ask where it checks that the current user is allowed to touch the specific record it received.
I build this ownership-in-the-query pattern into the dashboard code across my Next.js templates, because it is easy to get right at the start and painful to retrofit across dozens of actions later.
Get the templates: https://pixelanas.gumroad.com
Go open one of your own delete or update actions right now and check whether the query filters by the current user. If it does not, you have found your first one. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)