IAM users vs roles, why instance roles replace hardcoded access keys, policy structure (effect/action/resource/condition), the least-privilege principle, and AssumeRole for cross-account access.
Published September 23, 2026
IAM User: long-lived credentials (an access key + secret) tied to a specific identity
— a real risk if that key ever leaks, since it doesn't expire on its own
IAM Role: TEMPORARY credentials, ASSUMED by something (a person, a service, an EC2
instance) for a bounded session — expires automatically, nothing long-lived to leak
This is the single most important IAM distinction to internalize: a user's access key is a long-lived secret — if it leaks (committed to a public repo, exposed in a log — see Centralized Logging's never-log-sensitive-data principle), it remains valid indefinitely until someone notices and manually revokes it. A role is assumed temporarily, issuing short-lived credentials that expire automatically — even if somehow exposed, the exposure window is bounded by the session's own expiration, not open-ended.
// WRONG — a hardcoded, long-lived secret baked into config or code
AWSCredentials creds = new BasicAWSCredentials("AKIA...", "secretkey...");
// RIGHT — the SDK automatically retrieves temporary credentials from the
// instance's attached IAM role, refreshed automatically, never stored anywhere
AmazonS3 s3 = AmazonS3ClientBuilder.defaultClient(); // picks up the instance role transparently
An EC2 instance (or a Kubernetes pod, via IRSA — IAM Roles for Service Accounts) can have an IAM ROLE attached directly, and the AWS SDK automatically retrieves and refreshes temporary credentials from it — no access key ever needs to be stored in code, config, or an environment variable at all. This is a direct, concrete application of Payment — Security's "the fewer places a sensitive credential exists, the smaller the leak surface" principle: instance roles eliminate an entire class of "a hardcoded AWS key got committed to git" incident by removing the long-lived key from the picture entirely.
{
Every IAM policy statement follows this shape: Effect (Allow or Deny), Action (which specific API calls, e.g. s3:GetObject), Resource (which specific ARN(s) this applies to — note the scoping to uploads/* specifically, not the whole bucket), and an optional Condition (further narrowing WHEN the rule applies — here, only for principals tagged as the payments team). This structure is what makes fine-grained, auditable access control expressible — access can be scoped down to a specific action, on a specific resource path, under a specific condition, rather than an all-or-nothing grant.
Grant ONLY the specific actions and resources actually needed for a given role's job — not broad wildcard access (s3:* on *) out of convenience. This is the same principle underlying Payment — Security's scope-reduction discussion, applied to AWS access generally: a compromised credential with narrowly-scoped permissions can do meaningfully less damage than one with broad access, making least-privilege a genuine, practical security control, not just a compliance checkbox.
Account A's role → calls sts:AssumeRole → temporarily assumes a role IN Account B
→ receives TEMPORARY credentials scoped to whatever Account B's role allows
→ those credentials expire automatically after the session
AssumeRole is the mechanism behind cross-account access (a common pattern in organizations running multiple AWS accounts for isolation — e.g. separate staging and production accounts) and cross-service access (a Lambda function assuming a role to access a resource in a different service). It extends the same "temporary, auto-expiring credentials" principle from instance roles to ANY scenario needing to grant scoped, time-limited access across a trust boundary.
Q: If IAM roles are strictly better than long-lived users, why do IAM users still exist at all? A: Genuinely long-lived human access still has legitimate use cases (a human logging in via the console with MFA, where a 'role session' model is less natural) — the practical guidance is roles for anything PROGRAMMATIC or automatable (services, instances, CI pipelines), reserving users mainly for actual human console access, ideally with MFA required.
Q: How does a Condition differ from just scoping the Resource more narrowly? A: Resource scoping restricts WHICH resource a policy applies to; a Condition restricts WHEN/under-what-circumstances it applies (a specific source IP range, a specific time window, a specific tag on the requesting principal) — the two compose together, letting a policy be narrow on both WHAT it grants access to and the CIRCUMSTANCES under which that access is valid.
Q: Does least-privilege mean starting with zero permissions and adding as needed, or starting broad and narrowing? A: Starting narrow (deny by default, grant specific needed actions) is the correct direction — starting broad and attempting to narrow later almost never actually happens in practice (nothing forces the narrowing step, and removing access that's already working risks breaking something), which is why disciplined least-privilege needs to be the DEFAULT posture from the start, not a cleanup task deferred to later.
Q: Is AssumeRole's temporary-credential model vulnerable to the same GC-pause/stale-token issue as the fencing-token problem in Distributed Lock Service? A: Conceptually related but not identical — an AssumeRole session's credentials simply EXPIRE after their configured duration (typically up to an hour), and any call made with expired credentials is rejected by AWS directly; there's no equivalent 'stale client believes it still has valid access' risk the way a distributed lock's fencing-token problem describes, since AWS itself is the authority validating credential freshness on every single API call.