Skip to content
C.W.K.
Stream
Lesson 08 of 08 · published

Security & Best Practices

~22 min · security, CSRF, auth, closures

Level 0Curious
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

What you get for free

  • CSRF protection — Origin header validation + per-build encrypted action IDs.
  • Dead-code elimination — unused Server Actions don't end up in the build.
  • POST-only — no GET-by-URL exposure.
  • Encrypted closures — values captured by an inline action are encrypted before the action reference reaches the client.

What you must add yourself

RiskMitigation
Untrusted inputValidate every field server-side (Zod or equivalent)
Wrong user mutating someone else's dataRead session, check resource ownership before writing
Rate abuseRate-limit per IP/user (Upstash, Redis, edge middleware)
Sensitive secrets in closuresAvoid capturing secrets directly; pass identifiers and re-fetch on the server

Threat-model every action as an internet-reachable POST. Test anonymous calls, another user's resource ID, oversized payloads, bursts, and stale action references. Log actor, resource, decision, and request ID while excluding secrets and raw personal input. Invoke the same action through the normal UI, a crafted POST, and sessions with different permissions. Hiding a button and having the server reject a mutation are different security proofs. Origin checks and encrypted IDs protect the framework channel; they do not know your organization roles, ownership rules, or abuse budget. A valid signed-in browser can still make an unauthorized or excessive request. Replay each hostile request without the normal UI and verify the deepest action rejects it. Repeat the suite after framework or authentication upgrades. Security that passed only once in a manual browser session is not a maintained boundary.

Code

Always check auth, always validate·ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

const Schema = z.object({ postId: z.string().uuid() });

export async function deletePost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const parsed = Schema.safeParse({ postId: formData.get('postId') });
  if (!parsed.success) throw new Error('Bad input');

  const post = await db.post.findUnique({ where: { id: parsed.data.postId } });
  if (post?.authorId !== session.user.id) throw new Error('Forbidden');

  await db.post.delete({ where: { id: parsed.data.postId } });
  revalidatePath('/posts');
}
Closures are encrypted, but lean on identifiers·tsx
// Inline action capturing a secret — encrypted, but cleaner to re-fetch
export default async function Page() {
  const apiKey = await getApiKey(); // server-only

  async function callExternal(formData: FormData) {
    'use server';
    const value = formData.get('value') as string;
    await fetch('https://external/api', {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}` }, // captured + encrypted
      body: JSON.stringify({ value }),
    });
  }
  return <form action={callExternal}>{/* … */}</form>;
}

External links

Exercise

Audit one of your Server Actions for the four risks above. Add Zod validation, an auth check, ownership verification, and a rate-limit hook. Document the changes.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.