9 min readJohnny UnarJohnny Unar

Server Actions Are Public Endpoints, Treat Them Like It

Every exported Server Action is a callable HTTP endpoint with no auth, no rate limiting, no validation. Here's the hardening checklist we run on every Next.js project.

the mental model is wrong

The thing that trips up almost every team we've inherited a Next.js codebase from is that Server Actions look like normal function calls. You write an async function, slap 'use server' at the top, import it into a component, call it from an onSubmit handler, and everything works. It feels like you're calling local code. You are not. When you mark a function with 'use server', Next.js compiles it into a POST endpoint with a stable, deterministic ID, and it wires up the client so that calling that function actually fires an HTTP request to /your-page with a special header and a serialized payload. That endpoint is reachable by anyone with curl and thirty seconds of reading your bundle. Nothing about the function being imported into a specific component scopes who can hit it. The component boundary is a UI concept. The action is a network boundary, and network boundaries get probed. We've seen production apps where the entire admin mutation surface was a set of Server Actions guarded by nothing more than the fact that the admin link didn't render for non-admins. The link not rendering does exactly nothing to stop a request. Once you internalize that every 'use server' function is as exposed as a route handler in app/api, the rest of this post is just the checklist you'd already apply to any API endpoint, because that's what these are.

auth at the action boundary, every time

The first line of every mutating action should establish who is calling it and whether they're allowed to. Not in the page, not in a layout, not in middleware alone. In the action. Middleware in Next.js runs on the edge and is genuinely useful for coarse redirects, but people lean on it for authorization and then get burned because matcher config is easy to get subtly wrong and a Server Action POST doesn't always route through the mental model you assumed. We treat the action itself as the source of truth.

Concretely that means something like this at the top of the function, before you touch anything:

ts
1'use server'
2
3export async function deleteInvoice(formData: FormData) {
4 const session = await auth()
5 if (!session?.user) throw new Error('unauthorized')
6
7 const invoiceId = formData.get('id')
8 // ownership check, not just "is logged in"
9 const invoice = await db.invoice.findUnique({ where: { id: invoiceId } })
10 if (invoice?.orgId !== session.user.orgId) throw new Error('forbidden')
11 // ... proceed
12}

The part everyone skips is the ownership check. Verifying a user is logged in is table stakes. The real bugs are IDOR bugs, where user A passes user B's invoice ID and your action happily deletes it because the only check was session?.user being truthy. Every action that accepts an ID from the client needs to prove that the current session actually has rights to that specific resource. We wrap this in a small helper so it's one call instead of five lines of ceremony, and we lint for actions that hit the database without going through it, because the moment it's optional someone forgets.

validate before you touch the database

Server Actions receive FormData or serialized arguments, and both are fully attacker-controlled. The client can send any shape it wants. Someone can call your action with a quantity of -9999, a role of superadmin, an email that's actually a 40kb string designed to blow up your logging, or an object with extra keys you never expected. If you pass that straight into Prisma or a raw query, you're trusting the network.

We run every action's input through a Zod schema before a single database call. Zod 4 parses fast enough that the cost is noise compared to your DB round trip, and the .safeParse result gives you structured errors you can hand back to the form without leaking internals.

ts
1const schema = z.object({
2 invoiceId: z.string().uuid(),
3 amount: z.number().int().positive().max(1_000_000),
4 note: z.string().max(500).optional(),
5})
6
7export async function updateInvoice(input: unknown) {
8 const parsed = schema.safeParse(input)
9 if (!parsed.success) {
10 return { error: 'invalid input' }
11 }
12 const { invoiceId, amount, note } = parsed.data
13 // now it's safe to proceed
14}

Notice the action takes input: unknown. That's deliberate. If you type the parameter as your happy-path interface, TypeScript will convince you the data is already the right shape, and TypeScript types evaporate at runtime, so you get a false sense of safety. Type it as unknown, force the parse, and the compiler makes you handle the failure case. This one habit catches an enormous class of bugs, from mass-assignment to type confusion, and it costs you about four lines per action.

rate limiting nobody gave you for free

Route handlers, Server Actions, all of it ships with zero rate limiting out of the box. If you have a signIn action or a sendPasswordReset action or anything that triggers an email or hits an external API, it can be hammered thousands of times a second by a single client, and you'll find out when your Postmark bill spikes or your database connection pool is exhausted.

We do per-action, per-identity rate limiting in Redis. The identity is the user ID when there's a session, and the IP when there isn't, because unauthenticated actions like login are exactly the ones that get abused. A sliding window counter is enough for most cases and it's cheap.

ts
1import { Ratelimit } from '@upstash/ratelimit'
2import { Redis } from '@upstash/redis'
3
4const limiter = new Ratelimit({
5 redis: Redis.fromEnv(),
6 limiter: Ratelimit.slidingWindow(5, '60 s'),
7 prefix: 'action:reset-password',
8})
9
10export async function resetPassword(input: unknown) {
11 const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
12 const { success } = await limiter.limit(ip)
13 if (!success) return { error: 'too many requests, slow down' }
14 // ...
15}

Give each sensitive action its own prefix so a burst on login doesn't eat the budget for, say, comment posting. The tradeoff with Upstash's serverless Redis is a network hop per check, which on Vercel functions in the same region is usually a couple of milliseconds, and if you're already running your own Redis for sessions or caching you can point the limiter at that and skip the extra service entirely. We've done both. For a document-processing pipeline we built recently, the rate limit lived right in front of the action that kicked off OCR jobs, because that action fanned out to a queue and one abusive client could have backed up the whole system.

audit logging without the round trips

Once you've got auth, validation, and rate limiting, the last piece people ask for is knowing who did what, especially in customer portals and internal admin tools where a mutation can move money or delete records. The naive approach is to await an insert into an audit_log table at the end of every action, which adds a synchronous database round trip to every mutation and quietly makes your app slower.

We don't await it. The audit write goes onto a fire-and-forget path, either a lightweight queue or a batched writer that flushes on an interval, so the action returns to the user immediately and the log lands a beat later. If you're on the Node runtime you can use after() from next/server to run work after the response is sent:

ts
1import { after } from 'next/server'
2
3export async function deleteInvoice(input: unknown) {
4 // ... auth, validate, delete ...
5 after(async () => {
6 await logAudit({
7 actor: session.user.id,
8 action: 'invoice.delete',
9 target: parsed.data.invoiceId,
10 at: new Date(),
11 })
12 })
13 return { ok: true }
14}

The log entry should capture the actor, the action name, the target resource, and a timestamp, and it should never contain raw payloads that might hold secrets or PII you don't want sitting in a table forever. Store the identifiers, not the whole request. When something goes wrong at 2am and you need to reconstruct who deleted the customer's records, this table is the difference between an answer in ten seconds and a forensic archaeology project across application logs that may already have rotated.

make the safe path the only path

Doing all of this by hand at the top of every action works right up until the codebase grows past a handful of actions and someone ships one in a hurry that skips a step. The fix is to stop relying on discipline. We wrap the whole pattern in a factory so that defining an action forces you through auth, validation, and rate limiting by construction, and there's simply no way to write an unguarded one that still compiles.

ts
1export const updateInvoice = createAction({
2 schema: updateInvoiceSchema,
3 rateLimit: { max: 10, window: '60 s' },
4 requireAuth: true,
5 handler: async ({ input, session }) => {
6 // input is parsed and typed, session is guaranteed
7 },
8})

Inside createAction you run the session check, the rate limiter keyed on the user or IP, the Zod parse, and the audit hook, and only then call the handler with clean, typed arguments. The handler can't run before the guards pass. When we onboard a new engineer, they don't need to remember the checklist, they just can't express an action without it. That's the whole point. Security that depends on every developer remembering to do four things on a Friday afternoon is security that fails, and the earlier you push it into the shape of your tooling the fewer 2am pages you'll get. If you shipped a pile of Server Actions in a sprint and you're reading this quietly wondering whether you left the door open, spend a morning grepping for 'use server' and check each one against this list. Most of the time you'll find at least one that's wide open.

Johnny Unar

Written by

Johnny Unar

Want to work with us?

Every exported Server Action is a callable HTTP endpoint with no auth, no rate limiting, no validation. Here's the hardening checklist we run on every Next.js project.