A user signs in, opens their projects page, and renames a project. Everything appears protected: the page requires a session and the form calls a Next.js Server Action. But if the action updates a row using only the project ID supplied by the browser, that same user may be able to rename someone else's project.
The fix is not another login check. It is an object-level authorization check at the data operation: identify the caller from a verified server-side session, validate the submitted ID as untrusted input, and constrain the write to records the caller is allowed to change.
This article uses a hypothetical two-user project app. The SQL authorization invariant was verified in a local regression test, and an isolated Next.js/Auth.js/Prisma test app passed four browser scenarios before being removed. The code below is an implementation pattern, not a production-ready drop-in application.
The failure: signed in, but editing the wrong project
Assume a Project table contains:
| ID | Owner | Name |
|---|---|---|
project-a | Alice | Alice's draft |
project-b | Bob | Bob's private plan |
The short IDs above and in the SQLite test are readable placeholders. The application example below uses UUID-formatted project IDs; a real application must keep its ID format, validation schema, and database column consistent. The SQL ownership predicate was tested separately; the illustrative action still needs adaptation and validation in the reader's own application.
Alice signs in. Her legitimate form submits the ID of project-a. Now consider what happens if she changes that submitted value to project-b. A hidden input, a disabled button, and even a project ID rendered only on a restricted page are not access controls; the browser can send a different value.
This Server Action checks authentication but never checks ownership:
// app/projects/actions.ts — INSECURE EXAMPLE. Do not deploy.
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function renameProject(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Sign in required')
}
const id = String(formData.get('id'))
const name = String(formData.get('name'))
await prisma.project.update({
where: { id }, // Any existing project ID may match.
data: { name },
})
}
If Alice submits Bob's ID, the query does not use Alice's identity at all. This is an insecure direct object reference (IDOR): a reference to an object is accepted without checking permission to access that particular object. OWASP documents this failure mode and recommends restricting queries to objects the current user can access: IDOR Prevention Cheat Sheet.
A random UUID makes an ID harder to guess, but it does not grant permission. IDs can appear in URLs, logs, shared links, browser state, or other legitimate responses.
Fix the write, not just the page
For a simple single-owner project, the authorization rule is:
Update project
Ponly when the verified actor's user ID equalsP.ownerId.
Put the actor ID and object ID in the same update predicate. A separate findUnique() followed by an unconditional update() is easier to misuse and can introduce a check-then-update race if ownership changes between calls.
The following example is illustrative and not an executable starter project. It assumes a Next.js App Router application, an Auth.js auth() helper that exposes a trusted session user ID, Prisma ORM 7 (the prisma.project.updateMany() API), and Zod. These integrations are project-specific and are not installed in this portfolio repository. Check your installed Prisma version before copying the example: Prisma ORM 8 uses a different client API.
// prisma/schema.prisma (Prisma ORM 7) — relevant fields, illustrative
model Project {
id String @id @default(uuid())
ownerId String
name String
@@index([ownerId])
}
// app/projects/actions.ts — authorization pattern to adapt
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const renameInput = z.object({
id: z.string().uuid(),
name: z.string().trim().min(1).max(80),
})
export async function renameProject(formData: FormData) {
// Resolve the actor from the verified server session,
// never from formData.get('ownerId').
const session = await auth()
const actorId = session?.user?.id
if (!actorId) {
return { ok: false, reason: 'SIGN_IN_REQUIRED' } as const
}
const input = renameInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
})
if (!input.success) {
return { ok: false, reason: 'INVALID_INPUT' } as const
}
const result = await prisma.project.updateMany({
where: {
id: input.data.id,
ownerId: actorId, // Authorization happens in the write.
},
data: { name: input.data.name },
})
// Do not reveal whether an inaccessible ID exists.
if (result.count !== 1) {
return { ok: false, reason: 'NOT_FOUND' } as const
}
revalidatePath('/projects')
return { ok: true } as const
}
updateMany() is used here for its scoped where predicate and returned update count; it does not imply that multiple projects should match, because id is unique. Unlike the insecure update() example, no row is modified when the owner predicate does not match. Prisma ORM 7 documents the where filter and { count } return value in its versioned client reference. Prisma ORM 8 replaces this particular client call with a filtered updateAndCount() operation; consult Prisma's v7-to-v8 migration mapping rather than mixing code from different major versions.
Notice the separate responsibilities:
- Authentication:
auth()resolves who is calling. Its session must be validated server-side, and the ID field must be configured correctly for the chosen authentication provider. - Validation: Zod rejects malformed IDs and empty or oversized names. A valid UUID is still not proof of ownership.
- Authorization:
id + ownerIdlimits the mutation to the caller's project. - Response: an absent project and a project owned by someone else receive the same application-level
NOT_FOUNDresult. - Cache handling:
revalidatePath()refreshes affected cached UI after a successful write; it is not a security control.
For an application using organization memberships or delegated editors, ownerId === actorId is not the correct policy by itself. Authorize against active membership, tenant boundaries, role and the specific requested operation, ideally in the query or an appropriate transaction. Do not substitute a client-supplied tenantId for server-verified membership.
Verify the boundary with two identities
A successful update test with Alice alone is not enough. The regression test must try to update Bob's record while authenticated as Alice and confirm the database has not changed.
In a local SQLite regression test, we created two records, reproduced an unrestricted update, and verified that adding the owner ID to the update predicate rejects the cross-owner write. The essential difference is:
-- The unsafe predicate:
UPDATE projects SET name = ? WHERE id = ?;
-- The owner-scoped predicate:
UPDATE projects SET name = ?
WHERE id = ? AND owner_id = ?;
The local regression covered cross-owner attempts, allowed owner updates, anonymous attempts, nonexistent records, and invalid input. This is database-level evidence for the predicate, not, by itself, evidence of Next.js action transport or deployment behavior. A separate disposable Next.js/Auth.js/Prisma test app also passed four browser integration scenarios during editorial verification, but that temporary app is not available as a public reproduction.
When integrating this pattern into a real app, add an HTTP/browser-level regression using two seeded accounts. First, confirm Alice can rename her own record. Next, submit Bob's ID while using Alice's session, assert a denied result, and re-read Bob's row from the database. Repeat for reads, deletes, exports and other actions that accept object references. You should also test expired sessions, revoked memberships and tenant switching where applicable.
Why common safeguards don't settle authorization
“The form only appears on a protected page.” Next.js explicitly warns that Server Functions can be reached by direct POST requests, independently of the UI that calls them. Treat a mutation as an exposed server entry point, not as a private component callback: Mutating Data.
“Middleware already redirects unauthenticated users.” Route-level redirection helps navigation, but does not replace permission checks close to the database. The Next.js authentication guide recommends a Data Access Layer (DAL) and authorization at sensitive operations. In Next.js 15 this routing mechanism is called Middleware; newer versions use the Proxy terminology.
“Server Action IDs are encrypted or obscure.” Next.js has built-in action identification and same-origin/POST protections, but its Data Security guide explicitly says not to rely on them as authorization. Those protections address different threats. CSRF defenses do not answer whether Alice is entitled to modify Bob's data.
“I already checked session.user.id.” Knowing the caller is necessary but insufficient. The authorization predicate must connect this caller, this object and this operation. A valid session followed by a global update is still vulnerable.
For background on the action mechanism, see A Guide to React Server Actions. For the distinction between redirects and permission checks, see the Next.js middleware authentication guide. If choosing a mutation interface, Server Actions vs. API Routes covers a different architectural question; both approaches need object-level authorization.
A review checklist for your next mutation
Before merging a Server Action that reads or changes private data, ask:
- Can the caller invoke this action without visiting the page? Assume yes, and review accordingly.
- Is the caller derived from a verified server-side session rather than a form field?
- Does the query enforce ownership or membership for the exact object and operation?
- Are input shape and business constraints checked separately from permission?
- Can an unauthorized response disclose whether a private record exists?
- Is there a two-user negative test that also checks the database after the attempted write?
- Are related reads, exports, cache responses and background jobs protected by equivalent rules?
The central rule is small, but easy to miss: authentication identifies the actor; authorization constrains what that actor may do. A secure Server Action must do both—even when the caller is already logged in.
Sources and scope
- Next.js: Authentication — authentication versus authorization; DAL; authorize Server Actions (checked October 25, 2026).
- Next.js: Data Security — public action endpoints, untrusted input, action IDs and CSRF distinctions (checked October 25, 2026).
- Next.js: Mutating Data — direct POST invocation and mutation semantics (checked October 25, 2026).
- OWASP: IDOR Prevention Cheat Sheet — object-level permission checks (checked October 25, 2026).
- Prisma ORM 7: Prisma Client API —
updateMany()filters and{ count }return value (checked October 25, 2026). - Prisma ORM 8: Coming from Prisma ORM 7 — API mapping for
updateMany()to the new write methods (checked October 25, 2026).
