DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Next.js Server Actions Are Public Endpoints: How I Lock Them Down

Headline: Every Next.js Server Action is a public HTTP endpoint. "use server" marks a function to be called over the network—not protected from the network.


I shipped a feature last quarter that let users export their data. The export logic lived in a Server Action. Three weeks later I realized anyone with a browser console could call that action directly—no session, no auth check—because I forgot to verify the session inside the action itself.

This post is what I learned hardening Server Actions at scale.

Why Server Actions Are Different

Traditional APIs have a clear boundary: your route handlers live in app/api/ and you consciously design them as public endpoints. Server Actions blur that line. You write them inline, they feel like private functions, but Next.js exposes each one as a POST endpoint at a URL derived from a build-time hash.

# What actually happens under the hood
POST /_next/action/abc123def456
Content-Type: application/x-www-form-urlencoded

[encrypted action arguments]
Enter fullscreen mode Exit fullscreen mode

The arguments are encrypted, but the endpoint exists. If your action doesn't check auth, an attacker who finds the action ID can call it.

The Mistakes I See Most

1. Assuming "use server" Means Private

// ❌ This is a public HTTP endpoint
"use server"

export async function exportUserData(userId: string) {
  // No auth check — anyone can call this
  return db.query('SELECT * FROM users WHERE id = $1', [userId]);
}
Enter fullscreen mode Exit fullscreen mode

The action is exposed. The arguments aren't, but the endpoint is.

2. Missing Session Checks Inside Actions

// ✅ Check session inside the action itself
"use server"

import { auth } from '@/lib/auth';

export async function exportUserData(userId: string) {
  const session = await auth();
  if (!session?.user?.id) throw new Error('Unauthorized');
  if (session.user.id !== userId) throw new Error('Forbidden');

  return db.query('SELECT * FROM users WHERE id = $1', [userId]);
}
Enter fullscreen mode Exit fullscreen mode

3. Trusting Client-Provided Values Without Validation

Server Actions receive arguments from the client. Even with encryption, you control what your client sends. Validate everything:

// ✅ Validate inputs with Zod
import { z } from 'zod';

const ExportSchema = z.object({
  userId: z.string().uuid(),
  format: z.enum(['csv', 'json']),
});

export async function exportUserData(input: unknown) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const { userId, format } = ExportSchema.parse(input);
  if (userId !== session.user.id) throw new Error('Forbidden');

  return generateExport(userId, format);
}
Enter fullscreen mode Exit fullscreen mode

The Pattern I Now Use Everywhere

I've settled on a wrapper that enforces auth before any action runs:

// lib/safe-action.ts
import { auth } from '@/lib/auth';
import type { Session } from 'next-auth';

type AuthenticatedAction<T, R> = (session: Session, input: T) => Promise<R>;

export function requireAuth<T, R>(action: AuthenticatedAction<T, R>) {
  return async (input: T): Promise<R> => {
    const session = await auth();
    if (!session?.user) throw new Error('Authentication required');
    return action(session, input);
  };
}

// Usage
export const exportUserData = requireAuth(async (session, input: unknown) => {
  const { userId, format } = ExportSchema.parse(input);
  if (userId !== session.user.id) throw new Error('Forbidden');
  return generateExport(userId, format);
});
Enter fullscreen mode Exit fullscreen mode

This makes the auth requirement impossible to forget—you can't write the action without thinking about the session.

Rate Limiting and Abuse Prevention

Because actions are HTTP endpoints, they're vulnerable to abuse. Add rate limiting at the middleware level:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10s'),
});

export async function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith('/_next/action/')) {
    const ip = req.ip ?? '127.0.0.1';
    const { success } = await ratelimit.limit(ip);
    if (!success) return new NextResponse('Too many requests', { status: 429 });
  }
  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

What I Recommend Auditing Now

  1. Search your codebase for "use server" files. List every exported function.
  2. Check each one for: session verification, input validation, authorization (not just authentication).
  3. Add the requireAuth wrapper (or equivalent) to every action that touches user data.
  4. Add rate limiting at middleware for action endpoints.
  5. Log action calls with user ID and input shape—not full values—so you have an audit trail.

Server Actions are a great DX improvement. They're also a new attack surface if you treat them as private functions when they're actually public endpoints.


Originally published on devya.dev. Also at eng-ahmed.com.

Top comments (0)