All postsEngineering

Server Actions are public endpoints: securing them in current Next.js

Ownership checks inside the action, input parsed as unknown, rate limits keyed on something you trust, and audit rows written in the same transaction.

Johnny Unar9 min read

01

every action is a public endpoint

Server Actions look like function calls. You write an async function with 'use server', import it into a component, call it from a form or an event handler, and it works. Under the hood, Next.js gives the function an ID, puts that ID in the client bundle of every page that uses it, and turns each call into a POST request to the page with a Next-Action header and a serialized payload. Anyone who loads the page can read the ID from the JavaScript and send the same request with curl. Where the action is imported, and whether a button for it is rendered, has no bearing on who can call it.

Next.js 15 tightened two things here, and they're worth getting right because older articles, including the first version of this one, describe the pre-15 behaviour. Action IDs are now non-deterministic, encrypted values that are recalculated between builds (the docs say they're cached for at most 14 days), and actions that no client code references are dropped from the bundle at build time, so they don't become endpoints at all. Both reduce accidental exposure. Neither protects an action your UI actually uses, and the Next.js data security guide says so directly: treat Server Actions as reachable by direct POST requests and verify authentication and authorization inside each one.

Some protection is built in. Actions only accept POST, and Next.js compares the Origin header with Host (or X-Forwarded-Host) and aborts the request when they differ, which stops classic cross-site form posts. Sending a valid action request with Origin: https://evil.example to a Next.js 16.4 app gets a 500 and a log line about the mismatched origin. If you run behind a proxy where the public host differs from what the app sees, list the real origins in serverActions.allowedOrigins rather than turning the check off. Variables that an inline action closes over are encrypted with a key generated per build; when you self-host several instances, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY so they agree, and still don't put secrets in closures.

Everything else is your job, and it's the same checklist you'd apply to a route handler: who is calling, are they allowed to touch this record, is the input what you expect, how often can they do it, and what record does it leave.

02

authorization belongs in the action, not in proxy

The tempting shortcut is a single check in middleware, which Next.js 16 renamed to proxy.ts. CVE-2025-29927 is the reason not to rely on it. In March 2025, GHSA-f82v-jwr5-mffw (CVSS 9.1) showed that a request carrying an x-middleware-subrequest header could skip middleware entirely on self-hosted Next.js, so any authorization implemented only in middleware was bypassable. It was fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5, and deployments on Vercel weren't affected, but the lesson outlived the patch. Proxy runs before routing and matches paths with a config you can get subtly wrong. A page-level check doesn't cover the actions defined on that page either, because each action is its own entry point.

Proxy is still the right place for an optimistic redirect that keeps logged-out users away from pages:

ts
1import { NextResponse, type NextRequest } from 'next/server'
2
3export function proxy(request: NextRequest) {
4 if (!request.cookies.has('session')) {
5 return NextResponse.redirect(new URL('/login', request.url))
6 }
7}
8
9export const config = { matcher: ['/dashboard/:path*'] }

The real check happens in the action, and the part people skip is ownership. Being logged in proves nothing about invoice 2222..., and an action that accepts an ID from the client and only checks that a session exists is an IDOR bug: user A sends user B's ID and the action happily deletes it. The cleanest way to avoid the race between "load and compare" and "delete" is to put the ownership condition in the statement itself:

ts
1'use server'
2
3import { z } from 'zod'
4import { auth } from '@/lib/auth'
5import { pool } from '@/lib/db'
6
7export async function deleteInvoice(formData: FormData) {
8 const session = await auth()
9 if (!session) throw new Error('unauthorized')
10
11 const id = z.uuid().parse(formData.get('id'))
12 const { rowCount } = await pool.query(
13 'DELETE FROM invoices WHERE id = $1 AND org_id = $2',
14 [id, session.orgId],
15 )
16 if (rowCount === 0) throw new Error('not found')
17}

auth() stands for whatever your session lookup is (Auth.js, a signed cookie, your own table), and pool is a pg pool. formData.get('id') returns FormDataEntryValue | null, so it goes through z.uuid() before it touches SQL; a missing field fails as "expected string, received null" instead of reaching the database as null. Posting the form with another organization's invoice ID deletes nothing and throws "not found", which is also the right answer to give, since "forbidden" confirms the record exists.

03

parse input as unknown

Action arguments arrive over the network, so their TypeScript types are a claim the client makes, not a guarantee. Type the parameter as unknown and parse it, and the compiler forces you to deal with the failure case:

ts
1const refundSchema = z.object({
2 invoiceId: z.uuid(),
3 amountCents: z.int().positive().max(10_000_000),
4 reason: z.string().trim().min(1).max(500),
5})

Money goes in as integer cents, never as a float, and z.int() rejects 5.5 instead of rounding it into something you didn't intend. Zod 4 also moved string formats to the top level, so z.uuid() and z.email() replace the deprecated z.string().uuid() and z.string().email(). Put upper bounds on everything, including strings, because an unbounded reason field is an easy way to fill your audit table.

The same discipline applies on the way out. Whatever an action returns is serialized to the client, so return { ok: true } or the few fields the UI needs, not the database row with its internal columns.

04

rate limits need an identity you can trust

Nothing rate-limits Server Actions for you. Anything that sends email, calls a paid API or checks a password needs a limit, and for unauthenticated actions the obvious key is the client IP. The old version of this article read it from x-forwarded-for, which is only safe if every request passes through a proxy that overwrites that header. If the app is reachable directly, or the proxy appends to the header instead of replacing it, the client controls the value and rotates it on every request. Vercel overwrites x-forwarded-for on its edge, and a self-hosted setup has to make the same guarantee itself, for example nginx setting X-Real-IP from $remote_addr. Read only that header, and make the choice explicit in configuration:

ts
1import { headers } from 'next/headers'
2
3export async function clientIp(): Promise<string | null> {
4 const name = process.env.TRUSTED_IP_HEADER
5 if (!name) return null
6 const value = (await headers()).get(name)?.trim()
7 return value || null
8}

The counter itself can be a fixed window in Redis, which is enough for abuse protection and costs one round trip:

ts
1import { createClient } from 'redis'
2
3const redis = createClient({ url: process.env.REDIS_URL })
4let ready: Promise<unknown> | undefined
5
6export async function allow(key: string, limit: number, windowSeconds: number) {
7 ready ??= redis.connect()
8 await ready
9 const bucket = `rl:${key}:${Math.floor(Date.now() / 1000 / windowSeconds)}`
10 const [count] = await redis.multi().incr(bucket).expire(bucket, windowSeconds).exec()
11 return Number(count) <= limit
12}

An IP limit alone is weak even when the IP is trustworthy, because attackers have many addresses. Limit on the thing being attacked as well. For a password reset that's the target email address:

ts
1export async function requestPasswordReset(input: unknown) {
2 const parsed = z.object({ email: z.email().max(254) }).safeParse(input)
3 if (!parsed.success) return { error: 'invalid input' }
4
5 const ip = await clientIp()
6 const ok =
7 (await allow(`reset:email:${parsed.data.email.toLowerCase()}`, 3, 3600)) &&
8 (ip === null || (await allow(`reset:ip:${ip}`, 20, 3600)))
9 if (!ok) return { error: 'too many requests' }
10
11 // send the email here
12 return { ok: true }
13}

Sending four reset requests for the same address from four different IPs gets three through and refuses the fourth, which is the behaviour you want against someone flooding a victim's inbox. For authenticated actions, key the limit on the user ID and you don't need the IP at all. If you'd rather not run Redis, @upstash/ratelimit gives you sliding windows over HTTP, and the same rule about the key applies.

05

audit records that can't silently disappear

For actions that move money or delete data, the audit record is part of the change. The first version of this article wrote it with after(), which schedules work to run after the response has been sent. after() is fine for analytics and cache warming, but if the process crashes or the insert fails, the refund has happened and the record of who did it hasn't, which defeats the purpose. Write the audit row in the same transaction as the mutation, so either both exist or neither does. The extra insert runs on the same connection inside the same transaction, which costs far less than losing the record.

ts
1export const refundInvoice = createAction({
2 name: 'invoice.refund',
3 schema: refundSchema,
4 limit: { max: 10, windowSeconds: 60 },
5 handler: async ({ invoiceId, amountCents, reason }, session) => {
6 const db = await pool.connect()
7 try {
8 await db.query('BEGIN')
9 const { rowCount } = await db.query(
10 `UPDATE invoices SET refunded_cents = refunded_cents + $1
11 WHERE id = $2 AND org_id = $3 AND refunded_cents + $1 <= total_cents`,
12 [amountCents, invoiceId, session.orgId],
13 )
14 if (rowCount === 0) {
15 await db.query('ROLLBACK')
16 return { error: 'not found' }
17 }
18 await db.query(
19 `INSERT INTO audit_log (actor_id, org_id, action, target_id, detail)
20 VALUES ($1, $2, 'invoice.refund', $3, $4)`,
21 [session.userId, session.orgId, invoiceId, { amountCents, reason }],
22 )
23 await db.query('COMMIT')
24 return { ok: true }
25 } catch (err) {
26 await db.query('ROLLBACK')
27 throw err
28 } finally {
29 db.release()
30 }
31 },
32})

The UPDATE carries the ownership check and the business rule in one statement, so a refund for another organization's invoice and a refund larger than what's left both affect zero rows and come back as "not found". Store identifiers and the fields a reviewer needs, not the raw request, so the audit table doesn't become a second copy of personal data.

06

make the safe path the only path

Checklists fail on the action someone writes in a hurry. Wrap the checks in a factory so that defining an action without them takes more effort than defining one with them. This is the one refundInvoice above uses:

ts
1import 'server-only'
2import { z } from 'zod'
3import { auth } from '@/lib/auth'
4import { allow } from '@/lib/rate-limit'
5
6type Session = NonNullable<Awaited<ReturnType<typeof auth>>>
7
8export function createAction<S extends z.ZodType, R>(opts: {
9 name: string
10 schema: S
11 limit: { max: number; windowSeconds: number }
12 handler: (input: z.infer<S>, session: Session) => Promise<R>
13}) {
14 return async (input: unknown): Promise<R | { error: string }> => {
15 const session = await auth()
16 if (!session) return { error: 'unauthorized' }
17
18 if (!(await allow(`${opts.name}:${session.userId}`, opts.limit.max, opts.limit.windowSeconds))) {
19 return { error: 'too many requests' }
20 }
21
22 const parsed = opts.schema.safeParse(input)
23 if (!parsed.success) return { error: 'invalid input' }
24
25 return opts.handler(parsed.data, session)
26 }
27}

Exporting the result from a 'use server' file works because it's an async function. All of this was run against Next.js 16.4.5 with Postgres and Redis in Docker, calling the actions directly with curl: no session returns "unauthorized", a malformed ID or 5.5 cents returns "invalid input", another organization's invoice returns "not found", the eleventh call in a minute returns "too many requests", and only the legitimate refund changes a row and writes an audit entry. If you want this pattern without maintaining it, next-safe-action is a well-used library that builds actions from a client with middleware for auth and schema validation.

Then audit what you already have. grep -rln "use server" app lib lists every file that defines actions, and each exported function in those files should answer the same questions: where does it check the session, where does it check ownership, what parses its input, what limits it, and what record does it leave. The Next.js data security guide has an auditing section with the same list, which is useful when you need to convince someone this isn't paranoia.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.