Skip to content
Guilherme Nogueira
Go back

Least Privilege Is a UX Problem

7 min read

Everybody agrees with least privilege. It is one of those principles nobody argues against, right up there with “write tests” and “back up your data.” And like those two, the agreement is loud and the practice is patchy, because the secure option is almost always the annoying one.

I have sat in the meeting where someone proposes granting access to config/*, all of it, to every service. Not because they are careless. Because scoping it properly means knowing every path, writing the policy, getting it reviewed, and redoing all of that the next time something moves. The broad grant is one line and you are done. Least privilege is homework.

So this is the argument I keep making: least privilege does not fail because people disagree with it. It fails because it has bad UX. And you fix security problems with better UX, not with more lectures.

A developer standing at a fork between two paths. The left path leads into a single open vault where one key unlocks every locked box, flanked by warning and skull icons: the broad grant that opens everything. The right path leads to a shielded door where each box carries its own lock: per-service identity, one key per door. The insecure path is the wide easy one, which is the whole problem.

Table of contents

Open Table of contents

The short version

Why everyone reaches for the broad grant

Picture a cluster where services need to read their configuration and secrets from a parameter store. The quick way to make that work is to let the nodes themselves hold the permission. Every pod on the node inherits it, everything can read, nothing is blocked, and the ticket closes.

The permission that gets attached looks harmless in the moment:

{
  "Effect": "Allow",
  "Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
  "Resource": "arn:aws:ssm:*:*:parameter/config/*"
}

That config/* is the whole story. It reads as “let services read their config.” What it actually says is “let any workload on this node read every service’s config, including the ones it has no business touching.”

The blast radius of convenient

Here is what that one wildcard buys you. Every pod shares the same identity, the node role, and that identity can read everything under config/. So the moment any single pod is compromised, through a dependency, an SSRF, a leaked token, anything, the attacker does not get that one service’s secrets. They get all of them. Payment config, database passwords, third-party keys, the lot, because from the store’s point of view it is all the same caller asking politely.

The isolation you thought you had between services does not exist. You drew boundaries in your architecture diagrams and then handed every pod the same key.

Identity per service

The fix is to stop letting the node be the identity and give each service its own. On EKS, Pod Identity associates a specific service account with a specific IAM role, so a pod authenticates as itself, not as the node it happens to land on. Then you scope that role to exactly one path:

{
  "Effect": "Allow",
  "Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
  "Resource": "arn:aws:ssm:*:*:parameter/config/svc-a/*"
}

Side by side comparison. On the left, a shared node IAM role with a broad config/* policy: every pod on the worker node reads every path in the parameter store, so any compromised pod reaches all services' configuration and secrets. On the right, per-service Pod Identity: each pod gets a policy scoped to config/svc-x/*, so it reads only its own path and the blast radius is one service's config.

Now svc-a reads config/svc-a/ and nothing else. Compromise it and the blast radius is one service’s config, not the entire cluster’s. Same mechanism, a fraction of the damage. This is the difference the diagram above is drawing: one shared key that opens every door, versus one key per door.

Make the secure path the easy path

If that were the end of it, we would be back where we started, because writing a scoped policy per service by hand is exactly the friction that pushed people to config/* in the first place. Every new service means a new policy, a new role, a new association, a new review. Multiply by dozens of services and the broad grant wins on effort alone.

So the scoping cannot live in a human’s to-do list. It has to be generated. The platform owns a small module, and a team declares intent, not IAM:

module "svc_a_access" {
  source       = "./modules/service-config-access"
  service_name = "svc-a"
  # generates the scoped role, the policy for config/svc-a/*,
  # and the Pod Identity association. No hand-written IAM.
}

The developer never writes a policy. They say “this is svc-a and it needs its config,” and the scoped, least-privilege setup falls out the other side. The secure path is now shorter than the broad one, because the broad one would require someone to go out of their way to be sloppy.

A three-step flow. Step one, a developer declares intent in a small Terraform module with just the service name. Step two, the platform module generates the IAM role, a policy scoped to config/svc-a/*, the Kubernetes ServiceAccount association, and the tagging, auditing and guardrails. Step three, the result is least privilege by default: a scoped role, scoped policy, service account, and parameter-store access limited to that one path, with the blast radius contained to one service.

That is platform engineering doing its real job: not asking every team to become an IAM expert, but making the correct IAM shape the default output of the platform. That inversion is the entire game.

Least privilege is a UX problem

Security controls that depend on people choosing the harder option have a failure rate, and that rate is just the friction. Raise the friction and more people route around the control. Lower it below the friction of the insecure path and the control enforces itself, because now doing the right thing is also doing the lazy thing.

Shared broad grantScoped identity via module
Blast radiusEvery service’s configOne service’s config
Developer effortOne wildcard, doneDeclare the service name
Who writes IAMA human, under deadline pressureThe platform, correctly, always
Drift over timeGrows quietly toward *Stays scoped by construction
Review burdenArgue about scope every timeReview the module once

Guardrails that slow the team down get disabled. Guardrails that are faster than the unsafe path get adopted without anyone noticing they are guardrails. That is the whole design goal.

What this actually bought

The visible win is the contained blast radius, and that is the one you put in the security review. The quieter win is that nobody argues about config/* anymore, because scoping stopped being a cost. New services come out least-privilege by default, drift toward wildcards stops, and the audit gets boring, which is the highest compliment you can pay an access model.

Final takeaway

Least privilege is not a discipline problem and it is not a knowledge problem. Almost everyone knows they should scope access, and almost everyone grants too much anyway, because in the moment the broad option is simply easier and the deadline is real.

So stop trying to win the argument and change the incline instead. Make the scoped, per-service, contained path the one that takes the least effort, and people will walk down it without being told. Security as code is at its best when the code makes the secure choice the default and the insecure one the thing you would have to work at.

Build the easy path so it is also the safe one, and least privilege stops being a principle you preach and becomes the thing that just happens.


Share this post:

Previous Post
The Build Did Not Fail. It Hung, Which Is Worse.
Next Post
Data Does Not Page You. It Just Goes Quietly Wrong.