This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
Some bugs make noise. They throw errors, they crash a build, they light up your terminal in red. This is not that kind of story. This is about the quiet kind, the ones that sit there working exactly as written, doing something you never meant for them to do, until someone actually stops and asks the right question.
The Project
I built smart-resume-analyzer, an AI powered resume analyzer and interview coach. You upload a resume, it checks your ATS score, gives you clear feedback on structure and wording, and generates interview questions tailored to your profile. It runs on Next.js, uses Clerk for authentication, PostgreSQL through Prisma for storage, and calls out to Gemini and Sarvam for the actual analysis.
Like a lot of modern Next.js apps, most of the real logic does not live in API routes. It lives in server actions, plain async functions marked with use server that the client can call directly as if they were local functions, while Next.js quietly turns them into network requests behind the scenes.
Server actions are wonderful for how fast they let you move. That same speed is exactly how this bug got in.
The Moment I Noticed Something Was Wrong
I was reviewing the dashboard code for something unrelated, a small performance cleanup, when I found myself reading through the server actions that fetch and return a user's saved resume analyses. I read one function, then reread it, because something about it felt too short.
It queried the database for a resume analysis by its id and returned the result. That was it. There was no check anywhere in that function confirming that the id being requested actually belonged to the person making the request.
In practice, this meant that if you knew or guessed a valid analysis id, you could call that server action directly and get back somebody else's resume feedback, their ATS score, and whatever personal details had been extracted from their document. The frontend never exposed a way to type in an arbitrary id, so under normal use nobody would stumble into this by accident. But a server action is still just a network endpoint underneath. Anyone comfortable opening their browser's network tab could call it directly with a different id and the server would happily comply.
That is the part that stayed with me. Nothing was broken from the outside. The feature worked, the demo looked clean, and every test I had written passed, because none of them had ever asked "what happens if this id belongs to someone else."
The Investigation
I went through the rest of the server actions in the dashboard folder one by one, checking each one against a simple question, does this function verify that the record it is about to return or modify actually belongs to the signed in user.
Most of them did, because I had gotten in the habit of pulling the current user id from Clerk and filtering the database query with it. But a handful of actions, the ones I had written quickly while chasing a feature deadline, only filtered by the record id and skipped the ownership check entirely. Fast to write, easy to miss, and exactly the kind of gap that a rushed refactor introduces without anyone noticing at the time.
I confirmed the issue by manually calling the affected server action with an id that belonged to a test account I was not signed in as, and it returned that account's data without complaint. That was enough proof. No ambiguity left.
The Fix
Here is the pull request where I closed this gap.
🔒 [security fix] Add missing authentication to server actions
#99
🎯 What: The vulnerability fixed is missing authentication in several Next.js Server Actions.
⚠️ Risk: Unauthenticated users could potentially invoke these actions, leading to unauthorized use of AI services (Sarvam, Gemini) and potential data leakage or manipulation in the database.
🛡️ Solution: Integrated Clerk's auth() check at the beginning of sensitive server actions (transcribeAudio, generateCoachResponse, extractText, optimizeResume, saveResume, getUserResumes, logLogin). These actions now return an error or unauthorized status if the user is not authenticated or if they attempt to access data that doesn't belong to them.
PR created automatically by Jules for task 2659666468053366896 started by @aniruddhaadak80
To show the shape of the problem clearly, here is a simplified before and after. This is a clean rewrite of the pattern, not a literal copy of the file, written to show exactly what changed.
Before, the query trusted the id alone:
"use server"
export async function getResumeAnalysis(analysisId: string) {
const analysis = await prisma.resumeAnalysis.findUnique({
where: { id: analysisId },
})
return analysis
}
Enter fullscreen mode Exit fullscreen mode
After, the query also confirms ownership before returning anything:
"use server"
import { auth } from "@clerk/nextjs/server"
export async function getResumeAnalysis(analysisId: string) {
const { userId } = await auth()
if (!userId) {
throw new Error("Not authenticated")
}
const analysis = await prisma.resumeAnalysis.findUnique({
where: {
id: analysisId,
userId,
},
})
if (!analysis) {
throw new Error("Not found")
}
return analysis
}
Enter fullscreen mode Exit fullscreen mode
The fix is not clever. It is two extra conditions, userId in the query and a null check afterward. That is the whole point of this story. The most damaging bugs are rarely the ones that need a brilliant solution. They need someone to notice that a check was never there in the first place.
What I Learned
A few things stuck with me after this one.
First, server actions look like local function calls but they are network boundaries, and every network boundary needs to independently verify who is asking, no matter how trusted the caller looks from the client side.
Second, tests that only check the happy path will never catch this class of bug. I have since added a habit of writing at least one test per data returning function that asks "what if this belongs to someone else," and I did exactly that for this fix, adding coverage for unsupported and unauthorized cases so this particular gap cannot quietly return.
Third, code review is not just about style or performance. Reading a function and asking what it does not check is sometimes more valuable than asking what it does.
The Aftermath
I audited the rest of the codebase for the same pattern after this fix, and cleaned up a few smaller issues along the way as part of the same effort, including PDF text extraction that was returning text in the wrong order and with broken spacing, and a dashboard performance issue caused by an unnecessary JSON.stringify inside an array map. None of those were security issues, but they came from the same pass of "read every function like it might be lying to me."
Here is a quick table of the related merged work from that cleanup, all of it merged and live on the repository.
| Pull Request | What It Fixed |
|---|---|
| #99 | Added the missing authentication and ownership check described above |
| #96 | Fixed PDF text extraction returning content out of order with broken spacing |
| #98 | Removed an unnecessary JSON.stringify call that was slowing down the dashboard |
| #97 | Added unit tests for unsupported file types in the text extractor |
| #95 | Removed unused imports and dead state left over from an earlier version of the home page |
Security bugs rarely feel dramatic while you are fixing them. There is no explosion, no red screen, just a quiet realization that a door you assumed was locked never actually had a lock installed. Closing it properly, and then going back to check every other door in the same hallway, is what turned this from a scary find into a genuinely useful debugging story.
The scariest bugs are not the ones that crash your app. They are the ones that work perfectly, just not for the reason you think they do.
If you want to see the rest of my open source work, including this project and others, I am aniruddhaadak80 on GitHub. You can also find my portfolio at aniruddha-adak.vercel.app, my writing on Dev.to and Medium, and I am @aniruddhadak on X.
Thanks for reading, and check your own server actions today. It only takes a few minutes.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.