userId From Request Body Overwrites Unrelated User Profile
This article may contain affiliate links. Its content is not affected by advertising.
In short
When a scoring API trusts the userId in its request body, a grader can update an unrelated profile's level, regardless of who the submissionId actually belongs to.
When a scoring API trusts the userId in its request body, a grader can update an unrelated profile’s level, regardless of who the submissionId actually belongs to.
Conclusion
A learning system’s scoring API (/api/admin/score) never looked up the target user from the database — it took the userId field straight from the request body and trusted it. The scoring screen itself did send the correct userId, but the server never checked whether the userId in a given request actually matched the submitter behind that submissionId. As a result, a grader who edited only the request body could update an arbitrary other person’s profile (their learning level) and even have the pass/fail result email sent to that unrelated person.
Symptom
The scoring API’s request schema took level and userId straight from the client, alongside submissionId.
const ScoreSchema = z.object({
submissionId: z.string().uuid(),
verdict: z.enum(['passed', 'failed']),
feedback: z.string().max(5000).optional(),
level: z.number().int().min(1).max(47),
userId: z.string().uuid(),
})
When a verdict was finalized as passed, this userId and level were used directly for the database update.
if (verdict === 'passed') {
await admin
.from('profiles')
.update({ current_level: level })
.eq('id', userId)
.lt('current_level', level)
}
The destination of the pass/fail result email was also derived from that same userId.
const { data: userData } = await admin.auth.admin.getUserById(userId)
const email = userData?.user?.email
if (email) {
await sendResultEmail({ email, level, verdict, feedback })
}
The submission record itself (the status update on the level_submissions table) was correctly scoped by submissionId, so which submission got graded was recorded accurately. But the two things that mattered most — whose profiles.current_level to raise, and who to email — were both driven only by the body’s userId, not by the actual submitter tied to that submissionId.
Cause
The scoring screen (ScoreForm) correctly received the submitter’s own userId and level as props.
interface Props {
submissionId: string
level: number
userId: string
criteria: string[]
}
export default function ScoreForm({ submissionId, level, userId, criteria }: Props) {
The problem is that these values were treated not as server-provided display data, but as part of the request the client would go on to send. The submit handler serialized the received level and userId straight into JSON.
const res = await fetch('/api/admin/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ submissionId, verdict, feedback, level, userId }),
})
These props originated on the server component that renders the scoring screen, inside a loop listing submissions awaiting review.
<AiScoreButton
submissionId={sub.id}
level={sub.level}
userId={sub.user_id}
content={sub.content}
/>
...
<ScoreForm
submissionId={sub.id}
level={sub.level}
userId={sub.user_id}
criteria={levelDef?.criteria ?? []}
/>
sub.level and sub.user_id here were correct values fetched from the database. But the moment they were handed to a client component, they stopped being “a fact the server already guaranteed” and became “data the browser will send next.” A grader who edited the outgoing request body in dev tools or an API client could send a userId with no relation to the submitter shown on screen. Because the server never verified that the userId actually belonged to that submissionId’s submitter, an unrelated person’s profile got updated and an unrelated person received the email.
The fix
The fix removes userId and level from the set of fields the request body supplies at all, and instead looks up the owner by reverse-querying the database from submissionId.
// Derive the user and level from submissionId (never trust client-sent values)
const { data: submission, error: fetchErr } = await admin
.from('level_submissions')
.select('user_id, level, status')
.eq('id', submissionId)
.single()
if (fetchErr || !submission) {
return NextResponse.json({ error: 'Submission not found' }, { status: 404 })
}
if (submission.status !== 'pending') {
return NextResponse.json({ error: 'This submission has already been scored' }, { status: 409 })
}
const { user_id: userId, level } = submission
From this point on, userId and level are values fetched from the database, and every subsequent profile update and email send uses these values. The client-side components also dropped the now-unused level and userId from their props, so they are no longer included in the request body.
interface Props {
submissionId: string
criteria: string[]
}
<AiScoreButton
submissionId={sub.id}
level={sub.level}
content={sub.content}
/>
...
<ScoreForm
submissionId={sub.id}
criteria={levelDef?.criteria ?? []}
/>
level remains on the AiScoreButton side for a different purpose (displaying the AI’s score), but userId is no longer passed from either component. The server now trusts only “whose submission does submissionId point to,” not “what the client happened to send.”
Preventing a repeat
Nothing in the source code mentions a test or type-level check that would catch this class of design flaw — treating a value handed to the client as something that can also be trusted when it comes back in a request from that same client. The same fix commit also adds a 409 rejection for re-scoring an already-scored submission, but that addresses a different problem (double-scoring), not the userId trust boundary itself.
Structurally, this kind of gap persists because the fact that a server component used the correct value for rendering, and the judgment that a client component may reuse that same value in its next request, are never distinguished in the code. Passing sub.user_id as a prop is safe on its own if it’s only for display. But the instant that same value is also included in a form’s submitted data, it stops being “a fact the server already verified” and becomes “unverified input the client is now asserting.” At every point that boundary gets crossed, the server needs to re-check the received ID against its own source of truth before trusting it.
Frequently asked questions
Q1Is this the same vulnerability as a typical IDOR?
A typical IDOR means guessing or supplying someone else's resource ID directly. Here the grader kept the correct submissionId and only tampered with the separate userId field. The server never checked that the body's userId matched the submissionId's owner, so an unrelated pair still went through.
Q2Why was it still editable if the screen showed the correct userId?
The ScoreForm component correctly received the submitter's userId and level as props, but it serialized them straight into the fetch body on submit. Anyone editing the outgoing request in dev tools or an API client could send a userId with no relation to what was displayed on screen.
Q3Was the profile overwrite the only impact?
No. On a pass verdict, the code updated profiles.current_level using the body's userId and sent the pass/fail email to that same userId's address. Tampering with the body's userId redirected both the level update and the result email to someone else.
Environment verified
- Next.js 16.2.10 / @supabase/supabase-js ^2.110.1 / zod ^4.4.3
- Fixed on 2026-07-23
What this article is based on
- TypeScript file lines 6-12commit 9fba662
- TypeScript file lines 55-61commit 9fba662
- TypeScript file lines 63-67commit 9fba662
- TypeScript file lines 6-11commit 9fba662
- TypeScript file lines 23-28commit 9fba662
- TypeScript file lines 128-144commit 9fba662
- TypeScript file lines 33-47commit 65edda7
- TypeScript file lines 6-9commit 65edda7
- TypeScript file lines 128-141commit 65edda7
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.