How to prevent IDOR vulnerabilities in Next.js API routes

by SkillAiNest

Imagine this scenario: A user successfully logs into your application, but upon loading his dashboard, he sees someone else’s data.

Why does this happen? Authentication worked, session is valid, user authenticated, but authorization failed.

This particular problem is called IDOR (Insecure Direct Object Reference). It is one of the most common security bugs and is classified under Broken Object Level Permissions (BOLA) In OWASP API Security Top 10

In this tutorial, you will learn:

  • Why does IDOR happen?

  • Why verification alone is not enough.

  • How object-level permissions work

  • How to properly fix IDOR in Next.js API routes

  • How to design secure APIs from scratch

Table of Contents

Validation vs. Authorization

Before writing more, let me clarify one critical point.

In IDOR scenarios, authentication works (the user is logged in), while authorization is missing or incomplete. This distinction is the main lesson of this article.

What is IDOR Vulnerability?

An IDOR vulnerability occurs when your API obtains a resource by an identifier (such as a user ID), and then you do not verify that the requester has or is allowed to access the resource.

Example of such a request:

GET /api/users/123

The code above is an HTTP. get Request from /api/users/123 gave way GET The method is used to request data from the server. This indicates that the client is requesting a specific user with ID. 123 And this request returns user data in response (often in JSON format).

If your backend uses a structure similar to the code snippet below to make a request without checking who is making the request, you have an IDOR vulnerability, even if the user is logged in.

db.user.findUnique({ where: { id: "123" } })

What the code does is query the database for a user record. gave db.user refers to the part user Model/Table and findUnique() There is a method that returns only one record based on the unique field. Within the method, the where clause defines the condition of the filter and { id: "123" } Asks the database to find the user whose unique id equal "123". If there is a matching record, it returns a User object. Otherwise, it returns null.

Weak patterns in Next.js

Given this Next.js App Router API route:

// app/api/users/(id)/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

Before going into the implications of this code snippet, let’s understand what the code does. It defines a dynamic API route. /api/users/(id). Exported. GET The function is an async route handler that runs when a GET request is made to this endpoint. It receives a request object and a params Objection, where params.id Contains dynamic (id) In the URL section. gave db.user.findUnique() The method queries the database for a user whose id Matches params.idand select option limits the fields returned. id, emailand name. Finally, NextResponse.json() Sends the retrieved user data back to the client as a JSON response.

Now, for context, the code is a bad approach because the root accepts the user ID from the URL, fetches that user directly from the database, and returns the result. There is no session authentication, no ownership checking, and no role checking.

If a login user changes. id In the URL, they can access other users’ data. It’s just IDOR.

How to handle IDOR in Next.js

The first element of defense is to verify identity. We will use getServerSession from NextAuth (adjust if using another auth provider). This change ensures that you read the session from cookies, validate it on the server side, and ensure that the user has a valid ID. This prevents unauthorized access.

// lib/auth.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/authOptions";

export async function requireSession() {
  const session = await getServerSession(authOptions);

  if (!session?.user?.id) {
    return null;
  }

  return session;
}

The above code defines an authentication helper function called requireSession. gave getServerSession(authOptions) The function retrieves the current user session on the server using the provided authentication configuration. optional chaining (session?.user?.id) in if block which then securely checks whether the logged-in user and their id The function returns if no valid session or user ID is found. nullThe request is invalid. Otherwise, it returns full session object so that it can be used in secure paths or server logic.

You have successfully verified that the user and session exist; Now, update the path:

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

The solution is still incomplete, but in the code above, you have blocked anonymous access. gave GET The handler calls requireSession() which was previously created to confirm that the request has been validated. If no valid session is returned, it immediately responds with a JSON error message. 401 Unauthorized HTTP status. If the user is authenticated, it proceeds to make the call. db.user.findUnique() To bring the user of which id Matches params.idjust choosing id, emailand name fields Finally, it returns the retrieved user data as a JSON response. NextResponse.json().

Something is still missing. Can you guess? Any authenticated user can still request any resource by customizing the URL path. How? This leads us to object-level permissions.

Object-level permissions

Object-level permissions ensure that a user can only access their own data (unless explicitly authorized).

An improvement to the code would be to add an ownership check. Adjustments ensure that the API request checks whether the requester is authenticated and owns the requested object. If either fails, access is denied.

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (session.user.id !== params.id) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

Let’s take a look at what happened in the code. GET The handler first validates the request by using requireSession()Return a 401 Response if no valid session exists. It then checks for permission by comparing. session.user.id with the params.id. If they don’t match, it returns . 403 Forbidden The answer is to prevent users from accessing other users’ data. If both checks pass, it executes the query using the database. db.user.findUnique() To retrieve specific user and limit the result to selected fields. Finally, it sends back the user data as a JSON response. With that, you’ve implemented a Object-level permissions.

How to design secure endpoints (/api/me)

The safest way to design your endpoint is to eliminate risk entirely. Instead of allowing users to specify an ID (/api/users/:id), use /api/meBecause the server already knows the user ID from the session.

// app/api/me/route.ts
export async function GET() {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

This approach ensures that your API only returns data for the currently authenticated user. It calls first. requireSession() To ensure that the request has been verified, return a 401 The response if no session exists. Instead of using a URL parameter, it reads the user ID directly. session.user.idEnsuring that the user can only access their own data. Then calls. db.user.findUnique() To retrieve that user from the database, selecting only the specified fields, and returning the result as a JSON response.

You can be confident with this approach because the client cannot manipulate the user’s identity. The server receives the user’s identity from a trusted source, and the attack surface is reduced. It is called secure-by-design API model.

Now, you must clearly understand that endorsement does not mean permission. therefore,

  • IDOR occurs when object ownership is not verified.

  • Every API route that accepts an ID must authenticate access.

  • A secure API design reduces the risk level.

  • Permissions must always run on the server.

Mental models for API design

When writing any API route, answer these questions:

  1. Who is making this request?

  2. What object are they demanding?

  3. Does the policy allow them to access it?

If you can’t clearly answer all three, your path may be weak.

The result

IDOR vulnerabilities occur when APIs rely on user-supplied identifiers without verifying ownership or authorization.

To prevent these in Next.js, validate each private path, implement object-level permissions, centralize the permission logic, and write tests for denied access.

Security isn’t about adding a login, it’s about enforcing a security policy on everything you access.

You may also like

Leave a Comment

At Skillainest, we believe the future belongs to those who embrace AI, upgrade their skills, and stay ahead of the curve.

Get latest news

Subscribe my Newsletter for new blog posts, tips & new photos. Let's stay updated!

@2025 Skillainest.Designed and Developed by Pro