50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 23, 2026·6 min read

Protecting API Routes and Server Actions in Next.js: A Practical Security Guide

Auth checks in the wrong place will bite you. Here's exactly how to lock down API routes and server actions before you ship.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Protecting API Routes and Server Actions in Next.js: A Practical Security Guide

We've seen this pattern more times than we'd like to admit: a developer builds a beautiful Next.js app, adds authentication, protects the pages with middleware — and then forgets that every API route and server action is sitting wide open. The UI is locked. The data is not. Someone with curl and five minutes of free time can wreck your whole day.

This post is about the practical, unsexy work of actually protecting your data layer. Not just slapping an auth check at the top of a page, but making sure nothing sensitive is reachable without the right credentials — whether it's a REST-ish route handler or a server action called from a form.

Why Middleware Alone Isn't Enough

Next.js middleware runs at the edge and is great for redirecting unauthenticated users away from protected pages. It's fast, it's clean, and it runs before anything renders. But middleware has a fundamental limitation: it doesn't run inside your route handlers or server actions. It runs before the request is routed.

This means if your matcher doesn't cover a specific path, or you later add a new API route and forget to think about it, that route is public. And even if your matcher does cover it, middleware typically does a lightweight session check — not a full authorization check. It can verify 'is this user logged in?' but not 'does this user have permission to delete this specific resource?'

Middleware is your first line of defense, not your only one. Always add auth checks directly inside the handler or action — don't outsource your security to a single layer.

Protecting Route Handlers

In the App Router, API routes are now called route handlers and live in route.ts files. Protecting them is straightforward once you build the habit. The pattern is always the same: get the session, check it, return early if it's missing or invalid.

// app/api/invoices/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth'; // your auth helper
import { db } from '@/lib/db';

export async function GET(request: NextRequest) {
  const session = await getSession();

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

  const invoices = await db.query.invoices.findMany({
    where: (invoices, { eq }) =>
      eq(invoices.userId, session.user.id),
  });

  return NextResponse.json(invoices);
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await getSession();

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

  // Critical: always scope queries to the current user
  // Never trust the client-provided ID alone
  const invoice = await db.query.invoices.findFirst({
    where: (invoices, { and, eq }) =>
      and(
        eq(invoices.id, params.id),
        eq(invoices.userId, session.user.id) // ownership check
      ),
  });

  if (!invoice) {
    return NextResponse.json(
      { error: 'Not found' },
      { status: 404 }
    );
  }

  await db.delete(invoices).where(eq(invoices.id, params.id));

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

Notice the ownership check in the DELETE handler. This is where most people slip up. They check that a user is authenticated but forget to verify that the resource they're trying to modify actually belongs to them. An attacker who is logged in with their own account can just swap out the ID in the request and delete someone else's data. This is called an Insecure Direct Object Reference (IDOR) and it's embarrassingly common.

Protecting Server Actions

Server actions feel different because they're called from your own React components, not from external HTTP clients. It's tempting to think 'nobody else can call these.' Wrong. Server actions are exposed as POST endpoints. Anyone who opens DevTools can find the endpoint, copy the request, and replay it with different data. Treat them exactly like API routes.

// app/actions/projects.ts
'use server';

import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
import { projects } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const updateProjectSchema = z.object({
  projectId: z.string().uuid(),
  name: z.string().min(1).max(100),
});

export async function updateProject(formData: FormData) {
  // Step 1: auth check — always first
  const session = await getSession();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  // Step 2: validate and parse input
  const parsed = updateProjectSchema.safeParse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  if (!parsed.success) {
    throw new Error('Invalid input');
  }

  const { projectId, name } = parsed.data;

  // Step 3: ownership check — always scope to current user
  const project = await db.query.projects.findFirst({
    where: and(
      eq(projects.id, projectId),
      eq(projects.userId, session.user.id)
    ),
  });

  if (!project) {
    throw new Error('Not found');
  }

  // Step 4: do the thing
  await db
    .update(projects)
    .set({ name })
    .where(eq(projects.id, projectId));

  revalidatePath('/projects');
}

One thing worth calling out: in server actions, throwing an Error bubbles up as an error response. In route handlers, you return a NextResponse with the appropriate status code. The pattern is slightly different but the principle is identical — check auth, validate input, verify ownership, then proceed.

Building a Reusable Auth Helper

Writing the same session check at the top of every handler gets old fast. A better pattern is an auth helper that either returns the session or throws, so you get a guaranteed typed session object and early exit in one line.

// lib/auth/require-auth.ts
import { getSession } from '@/lib/auth';
import { NextResponse } from 'next/server';

type Session = {
  user: {
    id: string;
    email: string;
    role: 'user' | 'admin';
  };
};

// For server actions — throws on failure
export async function requireAuth(): Promise<Session> {
  const session = await getSession();

  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  return session as Session;
}

// For server actions — throws if not admin
export async function requireAdmin(): Promise<Session> {
  const session = await requireAuth();

  if (session.user.role !== 'admin') {
    throw new Error('Forbidden');
  }

  return session;
}

// For route handlers — returns response or session
export async function requireAuthForRoute(): Promise<
  { session: Session; error: null } | { session: null; error: NextResponse }
> {
  const session = await getSession();

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

  return { session: session as Session, error: null };
}

// Usage in a route handler:
// const { session, error } = await requireAuthForRoute();
// if (error) return error;
// ... use session.user.id safely

Now your handlers read clearly. The auth check is one line and the intent is obvious. We use this exact pattern in our templates — the less boilerplate you have to think about per handler, the more likely you are to actually remember to add it.

Role-Based Access Control in Actions

Not all protected routes are created equal. Some things only admins should be able to do. If you have roles stored on the user, checking them is trivial — but you need to pull them from the session (which comes from your database or JWT), not from the client. Never trust a role passed in from the frontend.

// app/actions/admin.ts
'use server';

import { requireAdmin } from '@/lib/auth/require-auth';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function deleteUser(userId: string) {
  // This will throw 'Forbidden' if the current user isn't an admin
  // The role comes from the server-side session, not the request
  const session = await requireAdmin();

  // Prevent admins from deleting themselves
  if (userId === session.user.id) {
    throw new Error('Cannot delete your own account');
  }

  await db.delete(users).where(eq(users.id, userId));
}

What to Watch Out For

There are a few failure modes we see regularly in Next.js codebases:

  • Checking auth but not ownership — user A can modify user B's resources just by guessing an ID
  • Trusting client-provided user IDs — never use req.body.userId to scope queries, always use session.user.id
  • Missing auth on 'internal' routes — just because you didn't link to it doesn't mean it's hidden
  • Swallowing errors silently — if your auth helper catches and returns null on error, you might accidentally treat an error as 'not logged in' when the session check itself failed
  • Not validating input in server actions — malformed input that hits your database can cause unexpected behavior beyond just auth issues
  • Returning too much data — even authenticated users shouldn't get fields they don't need; always select explicitly
If you wouldn't expose a database column to a random user, don't select it. Scope your queries, not just your routes.

Quick Wins Before You Ship

Before going to production, it's worth doing a quick audit. Search your codebase for every route.ts and actions file. For each one, ask: does it have an auth check at the top? Does every mutation verify ownership? Is input validated with a schema? This takes 20 minutes and catches a lot.

The templates at peal.dev come with these patterns baked in — auth helpers, ownership-scoped queries, and Zod validation on every server action — so you're not starting from scratch each time you build something new.

One last thing: test your own security. Add a second test account, log in as that user, and try to access resources that belong to your first account. Try changing the IDs in requests manually. If you can do it, an attacker can too. It's uncomfortable to find your own holes, but it's much better than finding out someone else found them first.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders