Why NextAuth (Auth.js) Doesn't Guarantee API Security

A login page is easy with AI, but securing API endpoints is where vibe-coded apps fail. How to fix missing authorization.
You asked v0 or Cursor to build a dashboard. It integrated NextAuth seamlessly. The UI looks great, and unauthenticated users are correctly redirected to the /login page. Your app is secure, right?
Wrong. Authentication is knowing who the user is. Authorization is knowing what they can do. NextAuth handles the first. The second is entirely your responsibility — and this is where vibe-coded apps consistently fail.
Authentication vs. Authorization: The Core Difference
These two concepts are often conflated, and the confusion causes real vulnerabilities:
Authentication answers: "Who is this person?" It's the login process — verifying a username and password, OAuth token, or magic link. NextAuth (Auth.js) is excellent at this. It manages sessions, handles OAuth providers, stores JWTs, and provides a session object with the user's identity.
Authorization answers: "Is this person allowed to do this specific thing?" It's the access control layer — checking if user Alice can read document #42, whether user Bob has the admin role before showing the admin panel, or whether user Carol is making a request for her own data rather than someone else's.
NextAuth gives you authentication. It gives you nothing for authorization. You must implement authorization yourself, on every route, every Server Action, and every data query.
The confusion arises because setting up NextAuth successfully — watching the redirect to /login work — feels like the security problem is solved. It isn't. It's just the first layer.
The Middleware Trap
The most common misconception in vibe-coded Next.js apps is that middleware.ts secures API routes. It doesn't — and this misunderstanding leads directly to critical vulnerabilities.
Here's what happens:
// middleware.ts — this protects PAGES, not API routes
import { auth } from "@/auth";
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) {
return Response.redirect(new URL("/login", req.url));
}
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Notice the matcher pattern: (?!api|...). This explicitly excludes /api/ routes from middleware processing. Even if you removed that exclusion, middleware runs before your route handler but doesn't have access to your database or business logic — it can check if a session cookie exists, but it can't check if the user has permission to access a specific resource.
The result: a developer adds /dashboard to the middleware matcher, sees that unauthenticated users get redirected, and assumes the data behind /dashboard is protected. But the API routes that serve that data — /api/users, /api/documents/42, /api/admin/stats — are completely unprotected.
// This dashboard page is "protected" by middleware
// app/dashboard/page.tsx
export default async function Dashboard() {
const data = await fetch("/api/users"); // calls this endpoint
return <UserList data={data} />;
}
// But this API route has no auth check
// app/api/users/route.ts
export async function GET(req: Request) {
// ❌ No auth check — middleware didn't run here
const users = await db.user.findMany();
return Response.json(users);
}An attacker doesn't browse to /dashboard. They call GET /api/users directly.
Three Layers of API Security
Every API endpoint that handles sensitive data needs all three layers:
Layer 1: Authentication — Is there a valid session?
const session = await auth();
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}Layer 2: Authorization — Does this user have permission for this action?
// Role check for admin actions
if (session.user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}Layer 3: IDOR Prevention — Does this user own this specific resource?
// Ownership check for user-specific resources
const document = await db.document.findUnique({
where: {
id: params.id,
ownerId: session.user.id // ensures user owns this document
}
});
if (!document) {
return new Response("Not found", { status: 404 });
}Note that Layer 3 returns 404, not 403. Returning 403 tells the attacker "this resource exists but you can't access it." Returning 404 reveals nothing about whether the resource exists for another user.
The Typical AI-Generated Trap
Here's the vulnerable pattern Cursor and v0 generate most often:
// app/api/users/[id]/route.ts
import { db } from "@/lib/db";
export async function GET(req: Request, { params }: { params: { id: string } }) {
// ❌ No session check — any anonymous user can GET this route
const user = await db.user.findUnique({
where: { id: params.id }
});
return Response.json(user);
}Even though /dashboard requires login, GET /api/users/1 is publicly accessible on the internet.
The Fixed Version
// app/api/users/[id]/route.ts
import { db } from "@/lib/db";
import { auth } from "@/auth";
export async function GET(req: Request, { params }: { params: { id: string } }) {
// Layer 1: Authentication
const session = await auth();
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}
// Layer 3: IDOR prevention — user can only fetch their own profile
// (unless they're an admin, who can fetch any profile)
if (session.user.id !== params.id && session.user.role !== "ADMIN") {
return new Response("Not found", { status: 404 });
}
const user = await db.user.findUnique({
where: { id: params.id },
select: {
id: true,
name: true,
email: true,
createdAt: true,
// Explicitly omit: passwordHash, internalNotes, stripeCustomerId
}
});
if (!user) {
return new Response("Not found", { status: 404 });
}
return Response.json(user);
}Role-Based Access Control in NextAuth
To implement role-based authorization, you need to include the role in the session. NextAuth doesn't do this by default:
// auth.ts
import NextAuth from "next-auth";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [...],
callbacks: {
async session({ session, token }) {
// Add custom fields from the database to the session
if (token.sub) {
const dbUser = await db.user.findUnique({
where: { id: token.sub },
select: { role: true, organizationId: true }
});
session.user.id = token.sub;
session.user.role = dbUser?.role ?? "user";
session.user.organizationId = dbUser?.organizationId ?? null;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.role = user.role;
}
return token;
}
}
});// types/next-auth.d.ts — extend the session type
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "user" | "admin" | "moderator";
organizationId: string | null;
} & DefaultSession["user"];
}
}With this setup, every route can check session.user.role to gate admin functionality:
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
// Only admins can delete other users
if (session.user.role !== "admin" && session.user.id !== params.id) {
return new Response("Forbidden", { status: 403 });
}
await db.user.delete({ where: { id: params.id } });
return new Response(null, { status: 204 });
}Protecting Server Actions vs. API Routes
Server Actions and API routes require slightly different treatment:
API Routes (app/api/*/route.ts) return Response objects:
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
// ...
}Server Actions ("use server" functions) should throw errors rather than returning responses:
"use server"
import { auth } from "@/auth";
export async function updateProfile(data: ProfileFormData) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
// Validate that the data being updated belongs to the session user
await db.user.update({
where: { id: session.user.id }, // always use session ID, not user-provided ID
data: { name: data.name, bio: data.bio }
});
}Note the important difference: in Server Actions, use session.user.id directly from the session — never trust a user ID that comes from form data or function arguments.
How to Audit Your Existing Routes
# Find API routes that might be missing auth checks
find ./app/api -name "route.ts" -exec grep -L "auth()\|getServerSession\|getSession" {} \;
# Find Server Actions missing auth
grep -rn '"use server"' ./app --include="*.ts" --include="*.tsx" -l | \
xargs grep -rL "auth()\|getSession\|currentUser"
# Find routes that expose user data without ownership checks
grep -rn "findUnique\|findMany\|findFirst" ./app/api --include="*.ts" -l | \
xargs grep -L "user_id\|userId\|ownerId\|session.user.id"These aren't perfect — some routes are intentionally public — but they give you a starting point for manual review.
Using Zod for Input Validation After Auth
Once you've confirmed who the user is and what they're allowed to do, validate their input before touching the database:
import { z } from "zod";
const UpdatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(50000),
published: z.boolean().optional(),
});
export async function PUT(req: Request, { params }: { params: { id: string } }) {
// 1. Auth
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
// 2. Authorization + IDOR
const post = await db.post.findUnique({
where: { id: params.id, authorId: session.user.id }
});
if (!post) return new Response("Not found", { status: 404 });
// 3. Input validation
const body = await req.json();
const parsed = UpdatePostSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ errors: parsed.error.flatten() }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// 4. Database operation
const updated = await db.post.update({
where: { id: params.id },
data: parsed.data
});
return Response.json(updated);
}The order matters: auth first, then authorization, then input validation. Don't do expensive validation before confirming the user is allowed to make the request at all.
Testing Your Authorization Logic
Write tests that explicitly verify unauthorized access is rejected:
// __tests__/api/posts.test.ts
import { testApiHandler } from "next-test-api-route-handler";
import * as appHandler from "@/app/api/posts/[id]/route";
describe("POST /api/posts/[id]", () => {
it("returns 401 for unauthenticated requests", async () => {
await testApiHandler({
appHandler,
params: { id: "post-123" },
async test({ fetch }) {
const response = await fetch({ method: "DELETE" });
expect(response.status).toBe(401);
},
});
});
it("returns 404 when user tries to delete another user's post", async () => {
// Mock auth to return a different user than the post owner
jest.mocked(auth).mockResolvedValueOnce({
user: { id: "user-456", role: "user" } // not the post owner
});
await testApiHandler({
appHandler,
params: { id: "post-owned-by-user-789" },
async test({ fetch }) {
const response = await fetch({ method: "DELETE" });
expect(response.status).toBe(404);
},
});
});
});These tests should be run in CI to ensure no regressions. A route that was protected can become unprotected if someone refactors it and removes the auth check.
For a broader view of the most common vulnerabilities in AI-generated apps, see Top 5 Vulnerabilities in AI-Generated Apps. For Next.js-specific security issues from Cursor, see Cursor AI Security Flaws.
FAQ
Can I use a higher-order function to avoid repeating the auth check in every route?
Yes, this is a good pattern:
import { auth } from "@/auth";
import type { Session } from "next-auth";
type AuthedHandler = (
req: Request,
context: { params: Record<string, string> },
session: Session
) => Promise<Response>;
export function withAuth(handler: AuthedHandler) {
return async (req: Request, context: { params: Record<string, string> }) => {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
return handler(req, context, session);
};
}
// Usage
export const GET = withAuth(async (req, { params }, session) => {
// session is guaranteed to exist here
const data = await db.data.findUnique({
where: { id: params.id, userId: session.user.id }
});
return Response.json(data);
});What if I accidentally remove an auth check in a future refactor?
This is why testing authorization logic matters — see the testing section above. Additionally, some teams use ESLint rules to enforce that auth checks appear in route files. Consider adding a custom ESLint rule that warns when a route file doesn't contain a call to auth().
Does NextAuth v5 (Auth.js) change any of this?
Auth.js v5 simplifies some configuration but doesn't change the fundamental model: it provides authentication, you implement authorization. The auth() function is the same conceptually. The new authorized callback in middleware can handle some basic RBAC for page routes, but API routes and Server Actions still require explicit checks.
I use tRPC — does this still apply?
Yes. tRPC middleware can enforce authentication for all procedures globally, which helps. But authorization (checking if the user has permission for a specific resource) must still be implemented per-procedure. The middleware in tRPC checks that a session exists; the resolver must check ownership and roles.
VibeShield acts as a rogue unauthenticated user attempting to access every endpoint in your application. It immediately highlights routes that leak private user information without session validation.
Free security scan
Test your app for these vulnerabilities
VibeShield automatically scans for everything covered in this article and more — 18 security checks in under 3 minutes.
Scan your app free