The default identity in a cloud account is the one that ends up owning everything. It is the path of least resistance: one role, broad permissions, attached to whatever needs to move. It works on day 1 and it is a slow-burning liability by day 100, because you can no longer say what any single component is actually allowed to do. Every workload can do everything, so a bug or a compromise in any one of them is a bug or compromise in all of them.
The principle that avoids this is old and simple: least privilege, applied from the first resource, not retrofitted after the fact. Each thing that runs gets exactly the permissions it needs to do its job and nothing more. And no machine holds a secret to prove who it is; it proves its identity by context instead. Both halves matter, and both are cheap on day 0 and expensive to add later.
Give every workload its own minimal identity
Concretely, this means resisting the shared role. In a small scanning API I run, there are two functions, and they do very different things. One accepts uploads and records metadata. The other pulls a file, scans it, and reports the result. They could share a role. They do not.
The upload function's identity can write to exactly one prefix of one bucket, and read and write exactly the three tables it touches:
{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = ["${aws_s3_bucket.uploads.arn}/uploads/*"]
}
Not s3:*. Not the whole bucket. PutObject, into uploads/, and that is all. The scanner's identity is the mirror image: it can GetObject and DeleteObject on that same prefix, read the one table it needs, and emit an event. It cannot write uploads. It cannot touch the tables the upload function owns.
The value shows up the day something goes wrong. If the scanner has a flaw, the blast radius is the scanner's permissions, which is a short and specific list. It cannot exfiltrate the accounts table, because it was never allowed to read it. You did not have to predict the specific attack. You only had to refuse to hand out permissions nobody could justify. Least privilege is less a defense against a known threat than a cap on the cost of an unknown one.
The service here is IAM, and the shape is one role per workload, each with an inline policy scoped to specific resource ARNs and specific actions. It is more lines of Terraform than a shared role. That is the entire price, and it is worth it.
No machine should carry a key
The second half is about how automation authenticates. The classic pattern for a deploy pipeline is an access key pair, generated once, pasted into the CI system as a secret, and then living there forever. It is a long-lived credential sitting in a place that gets breached often. If it leaks, it is valid until someone notices and rotates it, and rotation is a chore nobody schedules.
The better answer is federation: the CI system proves its identity for the length of one job, gets short-lived credentials, and holds nothing between runs. On AWS this is OIDC. You register GitHub's token issuer as an identity provider, then create a role that GitHub can assume, but only under tightly scoped conditions:
resource "aws_iam_role" "github_actions" {
assume_role_policy = jsonencode({
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
}
}]
})
}
The two conditions are the whole security story. The aud check confirms the token was minted for AWS. The sub check pins it to one specific repository. A workflow in any other repo, even inside the same organization, cannot assume this role, because its token's subject will not match. There is no key to leak, because there is no key. The credential exists for the duration of the job and expires on its own.
If you want to be stricter, and often you should, you narrow the sub further, from repo:my-org/my-repo:* to a specific branch or environment, so only a deploy from main can assume the production role. The point stands either way: the machine is trusted because of where it demonstrably is, not because of a secret it carries.
The honest tension: tight runtime, powerful applier
Here is where I have to be honest, because the neat story has a seam in it.
The runtime roles above are genuinely least-privilege. The applier role, the one CI assumes to run Terraform, is not, and it largely cannot be. To manage infrastructure, that role holds permissions that look nothing like least privilege:
{
Effect = "Allow"
Action = ["lambda:*", "apigateway:*", "dynamodb:*", "s3:*",
"iam:*", "logs:*", "ecr:*", "sqs:*", "events:*"]
Resource = "*"
}
That is close to administrator, and pretending otherwise would be dishonest. The reason is structural: a tool that can create, modify, and destroy arbitrary infrastructure needs broad permissions to do so, and especially iam:*, because it manages the very roles this article is about. You cannot have automation that provisions least-privilege roles for everything else while itself being tightly scoped. Someone has to hold the keys to the workshop.
So the honest position is not "everything is least privilege." It is: runtimes are least-privilege, the applier is powerful, and you compensate for the applier's power in other ways. You constrain who can invoke it, which is exactly what the OIDC sub condition does, pinning that powerful role to one repository so it can only be exercised by a reviewed change landing in a known place. You keep it keyless, so its power is never sitting in a secret store waiting to be stolen. And you make sure it is loud: everything it does is logged, which is the subject of a later part of this series.
That is the real day-0 posture. Least privilege is the default and the goal, applied ruthlessly to the things that run your product. The one identity that must be powerful is fenced in by federation and scope instead, and you write down why. Naming the tension is part of doing it right. An applier role that quietly grew to admin because nobody wanted to look at it is a very different thing from an applier role that is admin on purpose, keyless, pinned to one repo, and audited.
Get both halves in on day 0. Minimal identities for the workloads, keyless federation for the pipeline, and an honest label on the one place power has to concentrate. Adding these later means going back through every resource you shipped without them, which is the kind of work that never quite gets prioritized until an incident prioritizes it for you.
Previous: Secure the State Before You Secure Anything Else
Next in this series: Deny by Default: Nothing Is Reachable Unless You Say So
The same decision on GCP: Least Privilege Starts at Resource One