A Next.js Server Action is invoked as updateProfile.bind(null, userId) and updates the row whose id equals that first argument. The action does verify that a session exists. What is still wrong, and how would you fix it?
answer
- logged in is not allowed to
- the id came from the caller
- bound args round-trip through the browser
- encrypted is not authorized
- put ownership in the where clause
basics
~20 sThe row being updated is chosen by an argument that travelled through the client, so any logged-in user can substitute another user's id. Checking that a session exists is authentication, not authorization: derive the subject from the session, or scope the write by the session's id.
solid answer
~60 sVerifying a session proves *someone* is logged in; it says nothing about whether that someone owns the record named in the payload. Bound arguments and hidden form fields reach the server through the client, so the id is caller-controlled and any authenticated user can swap in a stranger's — the classic broken-object-level-authorization bug, and one of the easiest to ship in an action because the argument looks like it came from your own render. The fix has two shapes. If the subject is always the caller, do not pass the id at all: read the session inside the action and use `session.userId`. If the id genuinely varies — an admin editing someone else, a record inside a workspace — keep the argument but make the write itself carry the ownership predicate, `where id = ? and tenant_id = session.tenantId`, so the check cannot be skipped by a later refactor. Note that Next encrypts values a Server Action closes over before they reach the browser, which is confidentiality, not authorization: an encrypted argument is still an argument.
code
typescript · 19 lines'use server'
type Session = { userId: string }
async function requireSession(): Promise<Session> {
return { userId: 'u1' } // replace with your session lookup
}
// Vulnerable: the row is chosen by a caller-supplied argument.
export async function updateProfileUnsafe(userId: string, formData: FormData) {
await requireSession()
console.log('updating', userId, formData.get('name'))
}
// Fixed: the subject comes from the session, so there is nothing to tamper with.
export async function updateProfile(formData: FormData) {
const session = await requireSession()
console.log('updating', session.userId, formData.get('name'))
}go deeper
Know the difference between proving a user is logged in and proving this user may touch this record, and that any id arriving with the request came from the caller.
Explain that bound arguments and hidden fields round-trip through the browser, and show the rewrite that takes the subject from the session instead of from a parameter.
Argue why folding the ownership predicate into the write beats a separate read-then-compare, and cover the enumeration angle of distinguishing forbidden from not-found.
Set the policy: where object-level authorization is expressed once for the whole system, how tenancy is carried through every query, and how you prove coverage rather than assuming it.
## Where the two checks diverge ```ts 'use server' export async function updateProfile(userId: string, formData: FormData) { const session = await getSession() if (!session) throw new Error('Unauthenticated') // authentication await db.user.update(userId, { name: formData.get('name') }) // no authorization } ``` The guard answers "is anyone logged in?" The write answers "which row?" — and the answer comes from the request. Those two facts are never connected, so every authenticated user can update every profile by changing one value in the POST body. The vulnerability class is broken object-level authorization (IDOR): the identifier of the object is attacker-controlled and no check ties it to the caller. ## Why the bound argument feels trustworthy and isn't `action.bind(null, id)` is the documented way to pass extra arguments to a Server Action, and it reads like a server-side capture because it is written in server code. It is not. The bound value is serialized into the payload the client holds and comes back on invocation. Next encrypts values that an action closes over before sending them to the browser, so the user cannot *read* them — useful when the closure incidentally captures something sensitive — but confidentiality is not integrity of intent. The server still ends up acting on a value that made a round trip through an environment you do not control, and the same is true of hidden `<input>` fields, URL segments, and anything read from `searchParams`. The rule to state: **an identifier that arrives with the request is an assertion by the caller, not a fact.** ## Fix one — do not accept the identifier When the object is always "the caller's own," the id has no business being a parameter: ```ts 'use server' export async function updateProfile(formData: FormData) { const session = await requireSession() await db.user.update(session.userId, { name: parse(formData).name }) } ``` This is the strongest form because the dangerous parameter no longer exists. A future refactor cannot forget a check that has no place to live. Prefer it whenever it applies. ## Fix two — scope the query, don't precede it When the id legitimately varies, the tempting shape is a read-then-decide: ```ts const row = await db.doc.find(docId) if (row.ownerId !== session.userId) throw new Error('Forbidden') await db.doc.update(docId, patch) // two statements, two chances to drift ``` That works, but the check and the mutation are separable — someone adds a second write path, or a batch endpoint, and only one of them carries the guard. Folding the predicate into the statement is more durable: ```ts const updated = await db.doc.updateWhere( { id: docId, ownerId: session.userId }, patch, ) if (updated === 0) throw new Error('Forbidden') ``` Now "not yours" and "does not exist" are the same outcome, which also avoids leaking existence through differing error messages or timings. ## Roles are the same trap one level up The identical defect appears with role or tenant values: a hidden field carrying `tenantId`, a `role` key in the form body, an `isAdmin` flag bound into the action. Every one of them is caller-supplied. The permission-bearing attributes must be re-read from the server-side session or from the database on each call — never accepted from the payload, and never carried over from what the page rendered. ## Detecting it in review A useful heuristic when reading a `'use server'` module: for each parameter, ask "if the client sent a different value here, would the action still do something?" If yes for any identifier, there must be a predicate somewhere in that body tying it to the session. Actions with zero identifier parameters are the ones you can review at a glance, which is a good reason to keep the argument list minimal even when passing the id would be convenient. ## What to say when asked "Session-exists is authentication. The row is selected by a value the client sent, so this is object-level authorization missing. Either drop the parameter and take the id from the session, or push ownership into the where-clause of the write so it cannot be bypassed. And bound arguments are not server-side state just because they were written in server code — Next encrypts closed-over values, which keeps them private, but they still arrive from the client."
- If Next encrypts values that an action closes over, why is that not enough here?Encryption gives confidentiality — the browser cannot read a value the action captured, which stops incidental leaks. It does not make the value a server-side fact: it still travels to the client and back, and the payload as a whole is caller-controlled. Authorization has to be re-derived from the session on the server, not inherited from anything that made the round trip.
- Should a forbidden update return 'forbidden' or 'not found'?Prefer indistinguishable outcomes. If the write is scoped by owner, a zero-row result naturally means "not yours or not there", and reporting it uniformly avoids confirming that a record with that id exists. Distinct messages turn an authorization bug into an enumeration oracle even when the write itself is correctly blocked.
- Where does the ownership check belong when several actions touch the same resource?In a shared data-access function that both the actions and any read paths call, so the predicate is written once and every caller inherits it. Repeating the check in each action body is how one of them eventually ships without it — and a single function is also the place to add auditing and consistent error shaping.
saying these in an interview costs you the question
- The session check already covers authorization
- Bound arguments are server-side, the client never sees them
- Encrypted closure values cannot be tampered with
- Hidden inputs are safe because the user cannot see them
- The UI only ever sends the current user's id