Securing API Routes in Next.js: Auth, Authorization & Mistakes

Diagram showing authentication and authorization checks along a Next.js API request flow

Diagram showing authentication and authorization checks along a Next.js API request flow

Securing API Routes in Next.js: Authentication, Authorization and Common Security Mistakes

Authenticating a user is only half the job. The other half — making sure every API route, Route Handler, and Server Action actually checks who's asking and what they're allowed to do — is where most real-world security bugs live. This article covers how to secure the server-side surface of a Next.js app once your authentication layer is in place.

API Authentication: Confirming Who's Calling

Every Route Handler and Server Action that touches non-public data should independently verify the caller's identity — not assume that because a request reached the handler, it must be authenticated.

// app/api/orders/route.ts
import { auth } from '@/auth' // or your provider's server-side session reader

export async function GET(request: Request) {
  const session = await auth()

  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const orders = await getOrdersForUser(session.user.id)
  return Response.json(orders)
}

This check has to run inside the handler, using a server-verified session — not a header or cookie value trusted at face value.

Authorization: Confirming What They're Allowed to Do

Authentication tells you the caller is user abc123. It says nothing about whether abc123 is allowed to see the specific order they're requesting. That's a separate check, every time:

// app/api/orders/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const session = await auth()

  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const order = await getOrderById(id)

  if (!order || order.userId !== session.user.id) {
    // Same response for "not found" and "not yours" — avoid leaking existence
    return Response.json({ error: 'Not found' }, { status: 404 })
  }

  return Response.json(order)
}

Note the pattern of returning an identical 404 whether the order doesn't exist or simply belongs to someone else — returning a 403 instead would confirm to an attacker that the resource exists, which is its own small information leak.

Route Handler Params: Current Async Syntax

Current dynamic Route Handlers receive params as a Promise, which must be awaited before use — the pattern shown above ({ params }: { params: Promise<{ id: string }> }, then await params) is the current convention and should be used consistently rather than an older synchronous destructuring pattern. VERIFY BEFORE PUBLISHING: confirm this remains accurate for the exact Next.js version in use at publish time, since dynamic API signatures are one of the areas most likely to shift between minor releases.

Sessions, JWTs, and Cookies in API Routes

The same primitives from the authentication article apply here, with one addition specific to APIs: if your API is called by something other than your own frontend (a mobile app, a third-party integration), cookie-based sessions may not be practical, and a bearer-token (JWT) pattern in the Authorization header is usually a better fit. Whichever you choose, verify the token's signature and expiry server-side on every request — never trust an unverified token's claims.

Next.js Proxy Is Not Authorization

proxy.ts (the Next.js 16 replacement for middleware.ts) can redirect obviously unauthenticated requests before they reach a route, which is useful for UX. But it is not a substitute for the checks shown above. Proxy-layer checks typically only confirm a session cookie is present — they don't (and often can't, efficiently) confirm the session is valid and check fine-grained, resource-level permissions on every request. Authorization must also be enforced close to the protected resource — inside the Route Handler, the Server Action, or the data-access layer itself — so that even a request that somehow bypasses proxy-level checks still can't reach data it isn't authorized for.

// proxy.ts — appropriate use: cheap redirect for UX
import { NextRequest, NextResponse } from 'next/server'

export default function proxy(request: NextRequest) {
  const hasSession = request.cookies.has('session')
  if (!hasSession && request.nextUrl.pathname.startsWith('/api/orders')) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
  return NextResponse.next()
}

Even with this in place, the Route Handler above still re-checks the session and re-checks ownership — that duplication is intentional, not redundant.

Server Actions

Server Actions are callable directly from the client, which means they need the same authentication and authorization checks as any Route Handler — a Server Action is not automatically protected just because it's defined server-side.

// app/actions/delete-order.ts
'use server'

import { auth } from '@/auth'

export async function deleteOrder(orderId: string) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')

  const order = await getOrderById(orderId)
  if (!order || order.userId !== session.user.id) {
    throw new Error('Not found')
  }

  await removeOrder(orderId)
}

Resource-Level Authorization

For anything beyond simple ownership checks (teams, roles, shared resources), centralize the logic rather than repeating ad hoc conditionals across every handler:

// lib/authorize.ts
export async function canAccessOrder(userId: string, orderId: string) {
  const order = await getOrderById(orderId)
  if (!order) return false
  if (order.userId === userId) return true
  const membership = await getTeamMembership(order.teamId, userId)
  return membership?.role === 'admin' || membership?.role === 'member'
}

Centralizing this logic makes it auditable in one place and much harder to accidentally skip in a new route.

Rate Limiting

Authentication and sensitive write endpoints (login, password reset, order creation) should be rate-limited to blunt brute-force and abuse attempts. The exact mechanism depends on your infrastructure (an edge KV store, a dedicated rate-limiting service, or your database), so the specific implementation is intentionally left as VERIFY BEFORE PUBLISHING: confirm current rate-limiting approach against your hosting provider's documentation, since this is infrastructure-dependent rather than a fixed Next.js API.

Input Validation

Never trust a request body's shape. Validate it — with a schema library or explicit checks — before it touches your data layer:

import { z } from 'zod'

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive().max(100),
})

export async function POST(request: Request) {
  const session = await auth()
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const body = await request.json()
  const parsed = CreateOrderSchema.safeParse(body)

  if (!parsed.success) {
    return Response.json({ error: 'Invalid input' }, { status: 400 })
  }

  const order = await createOrder(session.user.id, parsed.data)
  return Response.json(order, { status: 201 })
}

CSRF Considerations

For cookie-based sessions, Server Actions and Route Handlers relying on cookies are potentially exposed to cross-site request forgery unless mitigated. Setting sameSite: 'lax' (or 'strict' where compatible with your flows) on session cookies is the primary defense for most Next.js apps, and Server Actions include built-in origin checking in current Next.js versions. If you're building a token-header-based API (rather than relying on cookies) for non-browser clients, CSRF is largely a non-issue for that surface, since CSRF specifically exploits ambient cookie credentials.

Common Security Mistakes

  • Checking authentication in proxy.ts only, and assuming that's sufficient authorization for the route.
  • Trusting a client-supplied user ID or role field in a request body instead of deriving identity from the verified session.
  • Returning different error codes/messages for "not found" versus "not yours," leaking resource existence.
  • Skipping input validation because "the frontend already validates this."
  • Reusing the same error-handling path for authentication and authorization failures in a way that leaks internal details in the response body.
  • Forgetting that Server Actions need the same checks as Route Handlers, since they're just as directly callable.

Practical Security Checklist

  • ☐ Every Route Handler and Server Action independently verifies the session server-side.
  • ☐ Every resource-returning endpoint checks ownership/permission, not just authentication.
  • proxy.ts checks are treated as UX, with real enforcement duplicated at the data-access layer.
  • ☐ Session cookies use httpOnly, secure, and an appropriate sameSite value.
  • ☐ All request bodies are validated against a schema before use.
  • ☐ Sensitive endpoints (login, password reset, account changes) are rate-limited.
  • ☐ Error responses for "not found" and "not authorized" don't leak which case occurred.
  • ☐ Dynamic route params are awaited using the current Promise-based signature.

Want a deeper checklist to run on every AI-assisted pull request?

This is a paid product from CodeBitDaily — not a free resource.

The AI Code Review Checklist Pack adds a dedicated Security checklist (SQL injection, XSS, auth, secrets) alongside Performance and Accessibility checklists, built specifically for reviewing AI-generated code before it merges.

Authentication decides who's knocking. Authorization, enforced consistently at every layer described here, decides what happens once you open the door. Getting both right — and never assuming one covers the other — is what separates a genuinely secure Next.js app from one that merely looks secure until someone tests it.


Related reading:

Comments

Popular posts from this blog

Why Python is Still the King of AI Programming in 2026: A Deep Dive

Top 5 AI Automation Tools Every Developer Must Use in 2026

The AI Revolution in Full Stack Development: 2026 Comprehensive Guide