7 min readJohnny UnarJohnny Unar

AI Is Rewriting Your IAM Layer and Nobody Reviews the Diff

A hands-on audit playbook for the overly broad permissions that LLM-generated backend code quietly bakes into your codebase on every PR.

the diff nobody reads

We pulled a stat from a client audit last quarter that stuck with me, and it lines up with the broader numbers floating around: roughly 41% of AI-generated backend code ships with permissions that are too broad, and about 60% of developers never tighten the scopes before the code goes out. When you sit with those two numbers together they describe a specific failure mode, which is that the access control model of a codebase is being reshaped, function by function, by whatever the model happened to autocomplete at 4pm on a Thursday, and none of it is getting a real second look.

The reason is boring and human. A human writing an IAM policy from scratch feels friction, they have to think about what the service actually needs, they go look up the exact action name, they get annoyed, and that annoyance is a natural check against overreach. An LLM feels zero friction. Ask it to wire up an S3 upload and it will hand you a policy with s3:* on Resource: "*" because that always works, it never throws a permissions error in the demo, and the developer skimming the PR sees a policy block that looks structurally correct and approves it.

The correctness is the trap. The JSON is valid, the Terraform plans cleanly, the tests pass because the tests were also generated by the same model that granted itself admin. Nothing is broken in the way your CI knows how to detect broken. The blast radius just got quietly larger, and the diff that did it looked like every other diff.

We've been cleaning this up on real projects for the last year or so at steezr, and the pattern is consistent enough that it deserves an actual playbook instead of vibes.

what to grep for first

Start with the cheap wins, the ones you can find with ripgrep in about ninety seconds before you build anything fancy. Wildcard IAM policies are the obvious one. Run rg '"Action":\s*"[a-z0-9]+:\*"' across your Terraform and any inline policy JSON, and separately grep for "Resource": "*", because the combination of a service action wildcard and a resource wildcard is where the real damage lives. You'll find things like a Lambda that only ever calls dynamodb:GetItem sitting on a policy granting dynamodb:* on every table in the account.

In Django the equivalent smell is missing row-level scoping. The model has a user foreign key, the view fetches objects, and the queryset is Model.objects.all() with no filter tying results back to request.user. An LLM writing a DRF viewset will happily give you queryset = Invoice.objects.all() and a permission class of IsAuthenticated, which means any authenticated user can pull any invoice by incrementing an ID. Grep for .objects.all() inside views and viewsets and treat every hit as guilty until proven scoped, and check whether get_queryset actually narrows by the requesting user.

If you're on Postgres and relying on RLS, grep for tables that have ENABLE ROW LEVEL SECURITY without a matching CREATE POLICY, because enabling RLS without a policy on a table that a non-owner role touches is a footgun the model loves to half-implement. It writes the enable line, forgets the policy, and now either everything is denied or, if FORCE isn't set and your app connects as the table owner, RLS is silently bypassed entirely.

For Go backends the thing to hunt is the overprivileged service account. Look at how the service authenticates to your cloud, and check whether it's using one fat credential for every operation. We keep finding a single GCP service account with roles/editor doing the work of what should be four narrowly scoped accounts, because the AI-generated setup script requested editor since that's the role that makes the quickstart work on the first try.

why your SAST tools miss all of this

The uncomfortable truth is that Semgrep, Snyk Code, CodeQL, and the rest of the static analysis stack you already pay for were built to find a different category of bug. They're tuned for injection, for hardcoded secrets, for known-vulnerable dependencies, for the taint-flow problems where untrusted input reaches a dangerous sink. Those are the things the training data and the rule libraries were built around, and they catch them well.

An overly broad IAM policy is not a bug in that sense. It's a valid, well-formed, intentional-looking configuration that happens to grant more than the application needs. There's no tainted input, no unsafe sink, no CVE. The s3:* policy is indistinguishable, to a taint-flow engine, from a policy that grants exactly the right three actions, because both are just strings in a config file. The tool has no model of what this particular service is supposed to be allowed to do, so it has nothing to compare against.

The missing Django row-level filter is even worse for a scanner, because the code is correct. Invoice.objects.all() is a perfectly legal queryset. The vulnerability only exists in the gap between what the code permits and what your authorization model intended, and that intent lives in your head, or a threat model doc, not in the AST. This is authorization logic, which has always been the class of flaw that automated tooling is worst at, and it was already the number one item on the OWASP list before every developer got an autocomplete that generates it by default.

So you can't buy your way out of this with the tools you have. You need rules you write yourself, encoding your own least-privilege expectations, and you need them running on every PR.

a permissions lint step for CI

The good news is that most of what I described above is grep, and grep goes into CI cleanly. We build a dedicated permissions job that runs separately from the main lint stage so it's visually distinct in the PR checks and can't get buried under a hundred prettier warnings.

Semgrep is the right home for the structured rules even though its default rulesets won't catch this, because writing custom rules is where it earns its keep. A rule that flags any AWS policy statement combining an action wildcard with a resource wildcard is about eight lines of YAML, and a rule that flags DRF viewsets whose queryset is a bare .all() without an overridden get_queryset is maybe fifteen. For the Postgres RLS gap we run a small script against the schema that lists every table with RLS enabled and diffs it against tables that have at least one policy, failing the build on any mismatch.

For the cloud IAM side, tools like checkov and tfsec (now under Trivy) do have some built-in policy-breadth checks, so turn those on, but treat their output as a floor and layer your own OPA/conftest policies on top encoding your actual services. Something like deny if a service account references roles/editor or roles/owner unless it's on an explicit allowlist with a comment explaining why.

Make the job fail hard, not warn. A warning in CI is a thing everyone learns to scroll past within a week. If a wildcard policy shows up it should block the merge and force a human to either fix the scope or add an inline exception with a justification, and that exception should itself show up in the diff so it gets reviewed. The whole point is to reintroduce the friction that the LLM removed, at exactly the moment the code is trying to enter your main branch.

the review habit that actually sticks

Tooling gets you most of the way, but the durable fix is changing what reviewers look at when the diff touches anything auth-shaped. We've started treating any PR that adds an IAM policy, a permission class, a middleware, or a new service credential as a review that requires a second engineer to answer one question out loud in the PR thread: what is the smallest set of permissions this needs, and does the code match that.

That sounds heavy and it isn't, because for most PRs the answer is thirty seconds of typing. The value is that it forces the reviewer to actually read the policy instead of pattern-matching on its shape, which is the exact failure mode that let the AI-generated overreach through in the first place. When you make someone state the intended scope, the mismatch between intent and code jumps out immediately, and you catch the dynamodb:* that should have been three read actions.

We also keep a running note of the specific overreach patterns the models on a given project tend to emit, because they cluster. On one Go document-processing pipeline we built, the assistant kept reaching for a single storage-admin credential for both reading source uploads and writing processed output, when those are two different buckets with two different lifecycles and should never have shared a role. Once you name a pattern you start seeing it everywhere, and it becomes a one-line comment on the PR instead of an incident three months later when someone finds they can overwrite the source files.

None of this is exotic. It's least privilege, the same discipline that predates all of this, applied to a code-generation process that has a strong structural bias toward the opposite. The autocomplete isn't going away and honestly we don't want it to, it's genuinely fast, but fast means the review layer has to get sharper about the one thing the model is reliably wrong about, which is deciding how much access is enough.

Johnny Unar

Written by

Johnny Unar

Want to work with us?

A hands-on audit playbook for the overly broad permissions that LLM-generated backend code quietly bakes into your codebase on every PR.