One of the more dangerous mistakes in AI-generated code is also one of the easiest to miss in review. The code has authentication; the user is logged in, and there is an authentication check somewhere in the request path. Everything looks right, but the endpoint still lets one user access another user's data.
The problem is simple:
Authentication tells you who the user is; Authorization tells you whether that user is allowed to access this specific resource.
AI-generated code often gets the first part right and quietly skips the second.
Imagine an endpoint like this:
def show
return head :unauthorized unless current_user
@invoice = Invoice.find(params[:id])
render json: @invoice
end
At first glance, this doesn't look reckless.
But the important question is missing: Does this invoice belong to the current user?
If I am logged in as user 42 and request:
/invoices/123
The code checks only that I am authenticated: it doesn't check whether invoice 123 is mine.
If changing 123
to 124
gives me somebody else's invoice, authentication has done nothing to protect that data.
This is the class of problem commonly described as BOLA (Broken Object Level Authorization) or, in older terminology, IDOR: object-level access control is missing even though authentication exists.
AI coding tools are often very good at reproducing familiar application patterns. A controller action often looks like:
@invoice = Invoice.find(params[:id])
An authentication check often looks like:
return head :unauthorized unless current_user
Both are individually plausible. Put them together and the code looks secure.
That is the problem.
The generated code has matched two common patterns without necessarily reasoning about the relationship between the authenticated user and the object being loaded.
The failure is not:
“There is no security.”
The failure is:
“Security exists, but it is not being applied to this resource.”
That is much harder to catch casually.
The exact implementation depends on the application, but the data access should normally be constrained by the user's permitted scope.
For example:
def show
@invoice = current_user.invoices.find(params[:id])
render json: @invoice
end
Now the lookup itself enforces the ownership boundary.
A request for somebody else's invoice doesn't merely fail an if
statement later. The resource is outside the queryable scope in the first place. In a more complex system, that boundary may come from a policy object, tenant scope, permission service, or domain rule instead.
The implementation is less important than the review question:
Can I trace the current user's permission all the way to the specific object being accessed?
If I can't, I treat it as a security finding that needs verification.
This kind of bug is especially easy to miss when the generated tests were written by the same AI that generated the implementation. You often get tests like:
it "returns an invoice for an authenticated user" do
sign_in(user)
get "/invoices/#{invoice.id}"
expect(response).to have_http_status(:ok)
end
That test passes. It also proves almost nothing about authorization. The test you actually need is closer to:
it "does not allow a user to access another user's invoice" do
sign_in(user)
get "/invoices/#{other_users_invoice.id}"
expect(response).to have_http_status(:not_found)
end
or whatever denial behavior your application uses.
This is one reason I don't treat a green test suite as proof that AI-generated code is safe. A test suite can faithfully confirm the same misunderstanding that produced the code.
Negative testing is a separate topic, and probably worth its own article.
When I review security-sensitive AI-generated code, I don't ask:
“Is there authentication?”
I ask:
“Is authorization enforced for this exact operation on this exact resource?”
That wording matters. It forces the review away from the comforting presence of middleware, current_user
, role checks, and authentication helpers.
You have to follow the access path.
For an object lookup, that means tracing from the request down to the data access:
request
↓
authenticated user
↓
authorization decision
↓
specific resource
↓
data access
If the chain contains a jump like:
authenticated user
↓
Model.find(params[:id])
I want to know exactly what prevents cross-user or cross-tenant access. “Auth is handled elsewhere” isn't an answer until you verify where and how.
One of the prompts I use in the Security Deep-Dive is deliberately explicit about this failure mode:
Review this code for missing or incorrect authorization checks.
For every resource accessed by ID or other user-controlled identifier:
- identify who is allowed to access it
- verify that authorization is checked for the specific resource
- do not treat authentication alone as authorization
- flag any lookup where an authenticated user could substitute another object's ID
For each finding, include:
- severity
- file and line number
- exploit path
- minimal fix
If authorization depends on middleware, policies, framework defaults, or another layer, state that assumption and mark it as needing verification rather than inventing a vulnerability.
The last sentence is important: security review prompts can become useless if you tell the model to be “aggressive” and it responds by inventing vulnerabilities that depend on imaginary configuration. I want it to challenge the code, but I also want findings grounded in what is actually there.
For security-sensitive changes, I often do a second pass from the opposite direction.
Instead of:
“Review this code for security problems.”
ask:
“Assume you are a malicious but valid user of this system.”
That user already has credentials. That removes the easy answers.
The question becomes:
A surprising number of access-control mistakes only become obvious once you stop imagining an anonymous attacker and start imagining a perfectly legitimate user who is curious about what happens when they change a parameter.
I treat this pattern as especially important in changes involving:
Those are the places where “logged in” and “allowed to do this” most obviously diverge.
AI-generated security bugs aren't always dramatic. They don't necessarily contain eval()
or expose a secret in plaintext. Sometimes the code uses the correct framework, the correct authentication system, the correct ORM, and the correct controller structure. It just misses one relationship.
This user. This operation. This resource.
That is enough.
Authentication answers:
Who are you?
Authorization answers:
Are you allowed to do
thistothat?
Don't let an AI-generated authentication check convince you that the second question has been answered.
This is one of the checks in Round 2: Security Deep-Dive of The AI Code Review Protocol.
If this way of reviewing code is useful, my free repo contains the free prompts, and the book explains the full four-round method and why each round exists.
The free prompt library and automated review skill are available in the GitHub repository.
If you want the reasoning behind the full four-round process, the guide is available on Selar and Amazon Kindle.