{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreihdnkijmgxzkaapovlsl54prbhvexrj5vl32uymofs75etybss22e",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp2hzsvydhd2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreihjaq3x23fbfcx7pxrahtw3imezcwjhzkwn57evzbaeuocvjzeha4"
    },
    "mimeType": "image/webp",
    "size": 61626
  },
  "path": "/shubhradev/nextjs-16-server-actions-security-the-auth-check-most-developers-miss-1ei1",
  "publishedAt": "2026-06-24T17:24:06.000Z",
  "site": "https://dev.to",
  "tags": [
    "nextjs",
    "security",
    "webdev",
    "javascript",
    "Next.js 16 Authentication: The 3-Layer Security Model That Catches What proxy.ts Misses",
    "Next.js 16 Server Actions: The Bugs That Only Show Up in Production"
  ],
  "textContent": "I keep seeing it on code reviews. Proxy solid. Auth on every Server Component. Header direction correct.\n\nThen I look at the Server Actions.\n\nNo auth check. Not one. The page that renders the button is protected. The action behind the button accepts whatever the caller sends -- any valid session, any resource ID, no ownership verification anywhere.\n\n`'use server'` doesn't add authentication. It exposes an HTTP endpoint. Anyone with a valid session cookie and a cURL command can call it directly, no UI required.\n\nThat's what this post covers.\n\n##  What \"use server\" does, and what it does not do\n\nWhen you add `'use server'` to a file or a function, you're telling Next.js to move that code to the server and expose it as a callable endpoint. That's all it does.\n\nIt does not add authentication. It does not check who is calling. It does not verify that the caller has any session at all. From the official Next.js 16 docs:\n\n> Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation.\n\nPublic-facing API endpoints. That's exactly what they are. Anyone who can construct a POST request to the right URL can call your Server Action. No browser required. No UI required. A cURL command with a valid session cookie is enough.\n\nThe shift that matters: when you write a Server Action, you're not writing an internal helper that only runs when a user clicks a specific button. You're writing a public HTTP endpoint that also happens to be invokable from a button. Completely different from a security standpoint.\n\nNext.js does give you two protections out of the box worth knowing about. Action IDs are encrypted and non-deterministic, recalculated between builds, so old IDs stop working after a deploy. Unused Server Actions are removed from the client bundle entirely through dead code elimination. Both things help reduce your attack surface. Neither one is authentication.\n\n##  The gap that looks fine until it isn't\n\nThis is the pattern I keep finding on code reviews:\n\n\n\n    // app/dashboard/posts/[id]/page.tsx\n    import { verifySession } from '@/app/lib/dal'\n    import { deletePostAction } from './actions'\n\n    export default async function PostPage({ params }) {\n      const session = await verifySession()  // Page is protected\n\n      return (\n        <div>\n          <h1>Edit Post</h1>\n          <form action={deletePostAction}>\n            <input type=\"hidden\" name=\"postId\" value={params.id} />\n            <button type=\"submit\">Delete</button>\n          </form>\n        </div>\n      )\n    }\n\n\n\n    // app/dashboard/posts/[id]/actions.ts\n    'use server'\n\n    import { db } from '@/lib/db'\n\n    export async function deletePostAction(formData: FormData) {\n      const postId = formData.get('postId') as string\n\n      // No auth check. The page already verified the user.\n      await db.post.delete({ where: { id: postId } })\n    }\n\n\nThe page check runs at render time and confirms the user is authenticated before the UI appears. The action check never runs because nobody added one. The action will accept a delete request from anyone with any valid session, for any post ID, with no verification that the caller owns the post.\n\nThe fix is treating the action as its own entry point with its own verification:\n\n\n\n    // app/dashboard/posts/[id]/actions.ts\n    'use server'\n\n    import { verifySession } from '@/app/lib/dal'\n    import { db } from '@/lib/db'\n\n    export async function deletePostAction(formData: FormData) {\n      const session = await verifySession()\n      const postId = formData.get('postId') as string\n\n      const post = await db.post.findUnique({ where: { id: postId } })\n\n      if (!post || post.authorId !== session.userId) {\n        throw new Error('Not found')\n      }\n\n      await db.post.delete({ where: { id: postId } })\n    }\n\n\nTwo changes. `verifySession()` runs first, independent of whether the page ran it. The ownership check runs before the delete.\n\nSame error for \"not found\" and \"not authorized\" is intentional. Different error messages let a caller figure out which post IDs exist in your system. One generic error closes that.\n\n##  The Data Access Layer: why it exists\n\nOnce you start adding auth checks, ownership checks, and business logic inside every action, the actions get long and hard to audit. I used to copy-paste the ownership check into every action that touched posts. Then I changed the logic in one place and forgot the other four. A user found the gap before I did. The Data Access Layer pattern fixed that permanently.\n\nCentralize all auth logic and database access in a separate module marked with `server-only`. Keep your actions thin. Actions receive input, call DAL functions, handle Next.js-specific things like cache revalidation. That's it.\n\n\n\n    // app/lib/dal.ts\n    import 'server-only'\n    import { cookies } from 'next/headers'\n    import { decrypt } from '@/app/lib/session'\n    import { cache } from 'react'\n    import { redirect } from 'next/navigation'\n\n    export const verifySession = cache(async () => {\n      const cookie = (await cookies()).get('session')?.value\n      const session = await decrypt(cookie)\n\n      if (!session?.userId) {\n        redirect('/login')\n      }\n\n      return { isAuth: true, userId: session.userId as string }\n    })\n\n\n`server-only` at the top is not decoration. It causes a build error if this module ever gets imported from a Client Component. The auth logic physically cannot reach the browser because the build won't allow it. That's stronger than any code review convention.\n\nThe `cache()` wrapper from React is worth understanding too. When `verifySession()` gets called multiple times during the same render pass, once from the page, once from a leaf component, once from a data function, React deduplicates those calls automatically. One cookie read, one JWT verification, one result shared across the render. No repeated work.\n\nNow the DAL functions:\n\n\n\n    // app/lib/dal.ts (continued)\n    import { db } from '@/lib/db'\n\n    export const getUserPost = cache(async (postId: string) => {\n      const session = await verifySession()\n\n      const post = await db.post.findUnique({\n        where: { id: postId },\n        select: { id: true, title: true, content: true, authorId: true }\n      })\n\n      if (!post || post.authorId !== session.userId) {\n        return null\n      }\n\n      return post\n    })\n\n    export const deleteUserPost = async (postId: string) => {\n      const session = await verifySession()\n\n      const post = await db.post.findUnique({ where: { id: postId } })\n\n      if (!post || post.authorId !== session.userId) {\n        throw new Error('Not found')\n      }\n\n      await db.post.delete({ where: { id: postId } })\n    }\n\n\nAnd the action becomes thin:\n\n\n\n    // app/dashboard/posts/[id]/actions.ts\n    'use server'\n\n    import { deleteUserPost } from '@/app/lib/dal'\n    import { revalidatePath } from 'next/cache'\n\n    export async function deletePostAction(formData: FormData) {\n      const postId = formData.get('postId') as string\n      await deleteUserPost(postId)\n      revalidatePath('/dashboard/posts')\n    }\n\n\nThe action does two things: call the DAL function and handle cache invalidation. Everything else lives in the DAL where it's reviewable and testable in one place.\n\n##  The Layout trap that catches everyone once\n\nA lot of developers put their auth check in a shared Layout component because it feels efficient. One check, covers everything under that route segment. The official docs have a specific warning about this:\n\n> Due to Partial Rendering, be cautious when doing checks in Layouts as these don't re-render on navigation, meaning the user session won't be checked on every route change.\n\nWhat that means in practice: a user navigates to `/dashboard`, the Layout checks their session. They then navigate to `/dashboard/billing`. The Layout does not re-render. No session check runs for that navigation. If that user's session was revoked between those two navigations, the billing page renders anyway.\n\nThe fix is straightforward. Layouts fetch user data for display purposes, name in the nav, avatar, that kind of thing. The authorization check belongs in the page component or the DAL function it calls, not in the Layout.\n\n\n\n    // app/dashboard/layout.tsx\n    import { getUser } from '@/app/lib/dal'\n\n    export default async function DashboardLayout({ children }) {\n      const user = await getUser()  // For display only, not auth enforcement\n\n      return (\n        <div>\n          <nav>Welcome, {user.name}</nav>\n          {children}\n        </div>\n      )\n    }\n\n    // app/dashboard/billing/page.tsx\n    import { verifySession } from '@/app/lib/dal'\n    import { getUserPermissions } from '@/app/lib/dal'\n    import { redirect } from 'next/navigation'\n\n    export default async function BillingPage() {\n      const session = await verifySession()  // Runs on every navigation to this page\n      const permissions = await getUserPermissions(session.userId)\n\n      if (!permissions.includes('billing:read')) {\n        redirect('/unauthorized')\n      }\n\n      // render billing content\n    }\n\n\nBecause `verifySession()` lives in the DAL and gets called from the page component directly, it runs on every navigation to that page regardless of what the Layout did or did not do.\n\n##  Roles vs permissions inside actions\n\nRoles are coarse and stable. They live in the session payload. Checking a role inside a DAL function costs nothing extra because the session is already decrypted:\n\n\n\n    // app/lib/dal.ts\n    export const verifyAdminSession = cache(async () => {\n      const session = await verifySession()\n\n      if (session.role !== 'admin') {\n        redirect('/unauthorized')\n      }\n\n      return session\n    })\n\n\nPermissions are granular and they change independently of session rotation. If you revoke a user's `billing:export` permission at 2pm, you want that to take effect immediately, not when their session next expires. That means a database call:\n\n\n\n    // app/lib/dal.ts\n    export const getUserPermissions = cache(async () => {\n      const session = await verifySession()\n\n      const permissions = await db.permission.findMany({\n        where: { userId: session.userId },\n        select: { name: true }\n      })\n\n      return permissions.map(p => p.name)\n    })\n\n\nBecause `getUserPermissions` is wrapped in `cache()`, if it gets called from both the page component and a leaf component during the same render, the database query runs once. Covered in Part 2 briefly, but the DAL pattern is where this actually becomes useful because the cache wrapper lives in one place and applies everywhere the function is called.\n\n##  What to return from Server Actions\n\nServer Action return values get serialized and sent to the client. If you return a raw database record, the client gets every column including ones it should never see. This happens by accident more than people realize:\n\n\n\n    // This sends passwordHash, stripeCustomerId, internalNotes to the browser\n    'use server'\n    export async function updateProfile(formData: FormData) {\n      const session = await verifySession()\n\n      return db.user.update({\n        where: { id: session.userId },\n        data: { name: formData.get('name') as string }\n      })\n    }\n\n    // Return only what the UI needs\n    'use server'\n    export async function updateProfile(formData: FormData) {\n      const session = await verifySession()\n\n      await db.user.update({\n        where: { id: session.userId },\n        data: { name: formData.get('name') as string }\n      })\n\n      return { success: true, name: formData.get('name') }\n    }\n\n\nWhen your DAL functions use explicit `select` statements, this is mostly handled automatically. The action returns what the DAL gives it. The DAL selects only the columns it needs. Nothing extra reaches the client.\n\n##  Before you ship\n\n  * Every Server Action calls `verifySession()` before touching data\n  * Every mutation checks ownership before executing, not after\n  * DAL functions use `server-only` and explicit `select` statements\n  * Auth checks live in page components, not Layouts\n  * Return values are DTOs, not raw database records\n  * Test every action directly with cURL or a REST client before deploying, not just through the UI\n\n\n\nThat last one is the test. If hitting an action endpoint directly with a valid session cookie returns data or executes a mutation it should not, the UI protecting it does not matter.\n\n##  Where this series ends\n\nPart 1 was the invoice incident. One missing ownership check in a data function. Technically correct auth at every layer that actually existed, and one layer that did not exist.\n\nPart 2 was the proxy.ts gate. The matcher failures that happen silently, the header direction bug, and `getVerifiedUser()` to close the header trust boundary.\n\nThis post is the layer that catches mutations. `verifySession()` in every Server Action independent of the page that rendered the button. Ownership checks before every mutation. The DAL pattern so the auth logic lives in one place. DTOs so the client only gets what it needs.\n\nThree independent checks. One bug doesn't open the door.\n\nI still see the missing action check on almost every code review I do. Not in the proxy. Not in the page. In the action, quietly accepting whatever the caller sent.\n\nFull implementation with all three layers at Next.js 16 Authentication: The 3-Layer Security Model That Catches What proxy.ts Misses. Loading states, error handling, `useActionState`, and cache invalidation patterns in Server Actions are covered in Next.js 16 Server Actions: The Bugs That Only Show Up in Production.\n\nNote: I use AI for editing and image generation, but the technical substance is from my own work.",
  "title": "Next.js 16 Server Actions Security: The Auth Check Most Developers Miss"
}