Authentication vs. Authorization: The Bug AI Won't Catch
I asked an AI assistant for a simple endpoint: GET /api/tasks/:id, the detail view for a task in a to-do app. It came back working. It checked for a valid session, looked up the task by that id, and returned it. I tested it logged in as myself and saw my own task. I logged out and got rejected, as expected. Every test I ran passed.
The test I didn't run was the one that mattered: while still logged in as myself, what happens if I change the id in the URL to a task that isn't mine?
I ran it a day later, almost by accident, while debugging something unrelated. The endpoint returned the other task's data without complaint. My session was valid. The task existed. Nothing in the code asked whether the two belonged together.
Two questions that get treated as one
There's a reason this slips through so easily: authentication and authorization sound like they answer the same question, and in casual conversation people use them interchangeably. They don't, and the gap between them is exactly where this bug lives.
Authentication answers "who are you?" Is there a valid session, a correct token, a password that matches? It's a yes/no check against an identity.
Authorization answers a different question, one you can only ask after the first is settled: "given who you are, what are you allowed to do?" Specifically: does this particular session have permission over this particular resource?
A system can nail the first question completely and never once ask the second. That's what happened with my endpoint. It authenticated the user correctly, every time. It never authorized the request against the specific task being requested. Those are two separate checks, and only one of them was in the code.
Why the AI didn't add the second check
I want to be precise about what happened here, because it's tempting to treat this as an AI mistake, and I don't think that's accurate. I asked for an endpoint that checks whether a session exists and returns the task by id. That's what I got. The model didn't skip a step I asked for. It skipped a step I never described.
Think about what "check if the resource belongs to this session" actually requires as an instruction. It means: after confirming there's a valid session, take the user id from that session, take the task's owner id from the database row you just fetched, and compare them before returning anything. That's a specific, secondary query and a specific comparison. Nothing about "get the task by id" implies it. An AI assistant, like a junior engineer moving fast, builds the literal request. It doesn't invent a security boundary you didn't mention, no matter how obvious that boundary looks in hindsight.
This has a name in security literature, if you want to look it up later: broken object level authorization, sometimes shortened to BOLA, closely related to what used to be called IDOR (insecure direct object reference). All three names point at the same shape of bug: an endpoint that authenticates the requester but never checks whether the requester is entitled to the specific object being requested. I'm not bringing this up to send you down a compliance checklist. I'm bringing it up because knowing the name helps you search for the right thing when you're debugging this at 11pm and something feels off but you can't articulate what.
What the actual risk looks like
In a to-do app, the exploit path requires nothing dramatic. A regular user, with their own valid session, changes the number at the end of the URL from /api/tasks/482 to /api/tasks/483. If the backend only confirmed a session exists and never confirmed the task belongs to that session, that user can now see, and sometimes edit, someone else's task. No stolen session, no cracked password. Just a sequential id and a little curiosity.
Task ids are the easy version of this because they're often small integers and the guessing is trivial. The same bug shows up in less obvious places: a document id in a query string, an invoice id in an export link, a user id in a "download my data" endpoint. The url doesn't need to be predictable to be a problem. It only needs to exist somewhere, once, for someone to try incrementing it.
I've caught variations of this in my own projects more than once, always the same way: not through a security audit, but by staring at an endpoint I'd already shipped and asking, out loud, "wait, what actually stops someone from doing this with a different id." That question doesn't come from a tool. It comes from having been burned by it, or from having read about someone else who was.
What I tried first, and why it wasn't enough
My first instinct, the first few times I ran into this, was to add authorization as an afterthought: write the endpoint, ship it, and go back later to "harden" it once something felt risky. That doesn't work, for a boring reason. Once an endpoint works and passes your manual tests, the incentive to go back and add a check for a bug you haven't seen yet is close to zero. I never went back. The fix only ever happened when I found the gap by accident, or when I changed how I asked for the endpoint in the first place.
Adding a generic auth middleware didn't solve it either, and this is the part that surprised me the first time. Middleware that checks "is there a valid session" runs at the wrong layer. It answers the authentication question, correctly, on every request. It has no idea what a "task" is, or who owns which one, because ownership is a property of the specific resource being fetched, not of the request as a whole. You can have airtight session middleware and still ship this bug on every single resource-scoped endpoint in the app, because the middleware was never built to catch it.
What actually worked
The fix that stuck was moving the ownership check into the prompt itself, as an explicit, separate instruction from "check if there's a session." Instead of asking for an endpoint that returns a task by id, I ask for an endpoint that returns a task by id only if the task's owner id matches the current session's user id, and returns a 403 or a 404 otherwise. That's a small change in wording with a real consequence: it forces the second question into its own line of code, usually something close to:
const task = await db.tasks.findUnique({ where: { id } });
if (!task || task.ownerId !== session.userId) {
return res.status(404).end();
}
return res.json(task);
Two lines, one comparison. The entire fix for this class of bug, once you know to ask for it, is that small. The hard part was never writing the check. It was remembering it's a separate question from "is this person logged in."
I now treat this as a standing instruction whenever I ask for any endpoint that touches a specific record owned by a user: return or modify this resource only if it belongs to the requesting session. I say it every time, even when it feels repetitive, because the cost of saying it and not needing it is one sentence in a prompt, and the cost of needing it and not saying it is someone else's data.
The one thing to take from this
If you're building with AI assistance and you don't have a formal security background, the practical takeaway isn't "learn every security category by name." It's narrower than that, and more useful: every time you ask for an endpoint that reads or writes something tied to a specific user, ask yourself whether you've told the model to check ownership, explicitly, as a separate step from checking the session. If you haven't said it, it's very likely not in the code, no matter how obvious it seems once you know to look for it.
Knowing who someone is and knowing what they're allowed to do are two different questions. Most authentication libraries, and most AI-generated endpoints, answer the first one well by default. The second one only gets answered if you ask for it by name.