Free site scan →

ShipReady Guide · Next.js Security

How to Add Auth to Your Next.js API Routes

Every API route you ship is a public URL. If a route can read data, write data, send email, or spend your OpenAI credits, anyone on the internet can call it — the login screen on your homepage doesn't stop them. Here's how to lock your routes down.

For: Next.js App Router (14 / 15) Time to fix: ~15 minutes Difficulty: Beginner

The 10-second test: is your route open?

Open a terminal — or better, a private/incognito browser window where you're not logged in — and call your own API route directly:

Terminal
curl -i https://your-app.com/api/user/profile

If you get back 200 OK and real data, your route is unprotected. It doesn't matter that the button calling it only appears after login. Attackers, bots, and curious strangers don't use your UI — they call the URL. This is the single most common security hole in vibe-coded apps, because AI coding tools happily generate working routes and rarely add the auth check.

Why this one hurts

An open route that wraps a paid API leaks money (anyone can run up your OpenAI bill through your endpoint). An open route that reads user data leaks your users' information. An open route that writes or deletes data can wreck your database in one script. All three start with the same missing five lines.

The bug: trusting the request instead of the session

Most unprotected routes look like this. Notice where the user ID comes from:

app/api/messages/route.ts — unprotected
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function POST(req: Request) {
  const { userId, message } = await req.json();

  // Anyone can send ANY userId — no login required.
  await db.messages.create({ data: { userId, message } });

  return NextResponse.json({ ok: true });
}

Two problems in one: there's no check that the caller is logged in at all, and the route trusts a userId supplied by the caller. Even if you add a login page in front of this, an attacker can post messages as any user by changing one field. The fix follows one rule:

The one rule

Never take identity from the request — not the body, not the query string, not a header the client sets. Derive it from the session on the server, every time, in every handler.

The three auth patterns (pick yours)

Which pattern you need depends on who calls the route. Human users in a browser → session auth. A machine, cron job, or external service → an API key or secret. Pick your stack below:

Use when: your app already has login with Auth.js (formerly NextAuth). The auth() helper reads the session from the request's cookies — no token handling required.

app/api/messages/route.ts — protected
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";

export async function POST(req: Request) {
  const session = await auth();

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

  const { message } = await req.json();

  // userId comes from the session — never from the request body.
  await db.messages.create({
    data: { userId: session.user.id, message },
  });

  return NextResponse.json({ ok: true });
}

Note the status code: 401 means "not logged in." If the user is logged in but tries to touch someone else's data (their session ID doesn't match the record's owner), return 403 — or 404 if you don't want to confirm the record exists.

Which routes need auth — and which don't

Not every route should demand a login. Webhooks would break, and public content doesn't need a gate. Use this as the rule of thumb:

Reads or writes a user's data
Session auth. User ID from the session only, plus an ownership check on every record touched.
Calls a paid API (OpenAI, etc.)
Session auth — otherwise the internet is spending your credits. Add rate limiting too if you can.
Sends email or messages
Session auth. An open email route becomes a spam cannon with your domain's reputation attached.
Webhook receiver
No session — verify the provider's signature on every request instead.
Cron / scheduled job
Secret header check (CRON_SECRET or your own API key pattern above).
Truly public, read-only data
Can stay open — but be sure it can never return private records, and cache it so it can't be used to run up your database bill.

Middleware: a gate, not a lock

It's tempting to protect everything in one file. Auth.js can wrap your routes in middleware so unauthenticated requests never reach the handler:

middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  if (!req.auth) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }
  return NextResponse.next();
});

export const config = {
  matcher: ["/api/:path*"],
};

Use it — it catches the routes you forgot. But don't rely on it alone:

Middleware has failed before

In March 2025, a critical Next.js vulnerability (CVE-2025-29927) let attackers bypass middleware-based authorization entirely by adding one header to a request. Apps that also checked auth inside the route handler were fine; apps that trusted middleware alone were wide open. Keep Next.js updated — and treat the in-handler check from the patterns above as the real lock, with middleware as a helpful first gate.

Middleware also can't do the ownership check ("is this your message?") — that logic belongs in the handler, next to the data.

Verify the fix

Don't trust that it works — test it the way an attacker would:

  1. Logged out: run the curl test again. You want 401 Unauthorized, not data.
  2. Logged in: use the app normally. The route should work exactly as before.
  3. Wrong user: log in as a second account and try to fetch or modify the first account's record by ID. You want 403 or 404 — never the data.
  4. Every method: repeat for POST, PUT, PATCH, and DELETE. Protecting GET and forgetting DELETE is a classic.
Terminal — expect 401
curl -i -X POST https://your-app.com/api/messages \
  -H "Content-Type: application/json" \
  -d '{"userId": "someone-else", "message": "hi"}'

# HTTP/1.1 401 Unauthorized
# {"error":"Unauthorized"}

Not sure which of your routes are open?

ShipReady scans your live site and flags unprotected API routes — plus leaked API keys, missing OG images, and other launch-day problems — with copy-paste fixes for each one. The scan is free and takes about a minute.

Scan my site free → No code upload. We check the deployed site, the way an attacker sees it.

Mistakes that leave routes open

FAQ

Do I need auth on a route that only reads data?

If the data belongs to a user — yes. "Read-only" personal data is still personal data. Truly public content (a blog post list, pricing) can stay open.

What's the difference between 401 and 403?

401 means "we don't know who you are" — no valid session. 403 means "we know who you are, and you're not allowed." Returning the right one makes debugging and monitoring far easier.

I'm on the Pages Router. Does this still apply?

Yes — same rule, different helper. In Pages Router API routes, call getServerSession(req, res, authOptions) from next-auth at the top of the handler and return 401 when there's no session. The user-id-from-the-session rule is identical.

Should I check auth in Server Actions too?

Yes. Server Actions are endpoints too, and they're callable outside your UI. Treat every action like a route: verify the session first, derive the user ID from it, and validate inputs.

Is rate limiting part of auth?

They're partners. Auth stops strangers; rate limiting stops a logged-in user (or a leaked API key) from calling your route ten thousand times. Add it to any route that costs you money per call.