IAM is the one AWS service you cannot opt out of. Every call to every other service passes through its decision engine first, and most of us only study how that engine works on the day it says no. This is what’s actually happening: the signing, the evaluation, the replication, and the sprawling surface that has grown around a simple idea since 2011.
Where it came from
When AWS launched S3 and EC2 in 2006, an account had exactly one identity: the account itself. One email address, one password, one pair of long-lived access keys with god-mode over everything. Teams shared those root keys the way they shared the office wifi password, pasted into wikis and hard-coded into deploy scripts. There was no way to give a developer access to one bucket without giving them access to every bucket, and no way to revoke one person’s access without rotating the keys for everyone.
IAM appeared in preview in 2010 and went generally available in May 2011, introducing users, groups, and a JSON policy language for saying who could do what to which resource. Roles arrived in 2012 and proved to be the more important idea: an identity that nobody logs in as, which principals assume to receive temporary credentials. Federation via SAML followed in 2013, web identity federation for mobile apps around the same time, AWS Organizations and service control policies in 2017, permission boundaries in 2018, IAM Identity Center (as AWS SSO) in 2017 with its big rename in 2022, IAM Roles Anywhere in 2022, and resource control policies in November 2024. Each addition answered a scaling problem the previous layer created, which is why the whole thing now reads like sedimentary rock. The core evaluation model has barely changed since 2011, though; once you understand it, every layer above makes sense.
The mental model to hold onto: AWS has no inside. There is no trusted network zone where checks are skipped. The console is a web app calling the same public APIs your CLI calls. CloudFormation calls them. Lambda calls them on your behalf. Every one of those calls is an individually signed HTTP request, and every one is evaluated against policy before the service does any work. IAM is the control plane for the entire platform, which is also why it is the highest-value target in any AWS compromise: nobody attacks the hypervisor when they can find a leaked access key.
Every call is a signed HTTP request
Run aws s3api list-buckets and what leaves your machine is an ordinary HTTPS request with an extraordinary header:
Authorization: AWS4-HMAC-SHA256
Credential=AKIAIOSFODNN7EXAMPLE/20270927/ap-southeast-2/s3/aws4_request,
SignedHeaders=host;x-amz-content-sha256;x-amz-date,
Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
There are no sessions and no cookies. Each request is authenticated on its own: the credential scope says which access key signed it and for which date, region, and service; the signature proves the sender holds the matching secret; and the signed-headers list pins down exactly which parts of the request the signature covers. The secret access key itself never crosses the wire. Whether you’re identifiable at all, and as whom, is the authentication half of the problem; what identity even means in systems like this is its own topic. IAM’s distinctive work is the other half: deciding what the authenticated principal may do.
SigV4: proving who sent the request
Signature Version 4 has been the signing scheme since 2012 (v2 lingered on old S3 endpoints for years; it’s gone). Signing is a three-step derivation your SDK performs on every call.
First, the SDK builds a canonical request: the HTTP method, the URI path, the sorted query string, the sorted lowercased headers being signed, the list of those header names, and the SHA-256 hash of the body, all joined with newlines into one unambiguous string. Canonicalisation matters because proxies rewrite whitespace and reorder headers; both sides must reconstruct the exact same bytes or verification fails.
Second, it builds a string to sign: the literal AWS4-HMAC-SHA256, the timestamp, the credential scope (20270927/ap-southeast-2/s3/aws4_request), and the hash of the canonical request.
Third, it derives the signing key through an HMAC chain, starting from the secret access key:
kDate = HMAC-SHA256("AWS4" + secretAccessKey, "20270927")
kRegion = HMAC-SHA256(kDate, "ap-southeast-2")
kService = HMAC-SHA256(kRegion, "s3")
kSigning = HMAC-SHA256(kService, "aws4_request")
signature = hex(HMAC-SHA256(kSigning, stringToSign))
The chain is the clever part. The derived key is only valid for one date, one region, and one service, so AWS can distribute derived keys to regional verification fleets without those fleets ever holding your long-term secret. A compromised verifier leaks keys that expire at midnight and only work against one service in one region. If the mechanics of HMACs and key derivation are unfamiliar, the machinery behind signing and HMACs covers them properly.
Two operational consequences fall straight out of the design. Clock skew breaks signing. The timestamp is inside the signed material and AWS rejects requests whose X-Amz-Date drifts too far from its own clock: about five minutes for most services, fifteen for S3, with RequestTimeTooSkewed or SignatureDoesNotMatch as the symptom. A VM that’s been suspended and resumed, a container host with a broken NTP daemon, a Raspberry Pi without a battery-backed clock: all of them produce authentication failures that look like credential problems and are actually time problems. Signing assumes two machines agree on the hour, which is a stronger assumption than it sounds, and every clock on the network disagreeing is the normal state rather than the broken one. Modern SDKs detect the skew from the error response and offset subsequent requests, which hides the problem until it doesn’t.
Presigned URLs are the same mechanism inverted. A presigned S3 URL is a normal SigV4 signature moved into the query string with an explicit expiry (up to seven days). Whoever holds the URL can make that one request as you. Nothing about the object becomes public; the URL carries the authorisation.
The one place classic SigV4 falls short is requests that could land in more than one region, because the region is baked into the derived key. SigV4A fixes this with asymmetric cryptography: the SDK deterministically derives an ECDSA P-256 keypair from your secret access key, signs with the private half (AWS4-ECDSA-P256-SHA256), and puts a wildcard * in the region slot of the credential scope. Any region can verify the signature because verification needs only the public key, which AWS computes and caches server-side. You’ll meet SigV4A with S3 Multi-Region Access Points and EventBridge global endpoints; the SDK switches automatically.
Temporary credentials add one more moving part: a session token, sent as X-Amz-Security-Token. The token is an encrypted blob that carries the session’s context (which role, what session tags, what session policy) so the verifying service can reconstruct who is calling without a database lookup on every request.
The request context and the decision
A verified signature establishes who. The service then assembles a request context: the principal (down to the exact session ARN), the action (s3:GetObject), the resource ARN, and dozens of condition keys. Some are global (aws:SourceIp, aws:CurrentTime, aws:PrincipalOrgID, aws:SecureTransport, aws:PrincipalTag/team, aws:RequestedRegion), some service-specific (s3:prefix, ec2:InstanceType, sts:ExternalId). Condition keys that don’t apply to a request are simply absent from the context, which is why the Null operator exists: to test for a key’s presence rather than its value.
The enforcement engine then gathers every policy that could apply (organisation policies, resource policies, the principal’s identity policies, any boundary, any session policy) and evaluates the context against the lot. The default is deny; something must explicitly allow the action; any explicit deny anywhere is final. The full precedence dance is below, but that’s the skeleton, and it runs on every single API call: there’s no caching of decisions across requests, because the context (source IP, time, tags, the resource itself) changes per request. AWS has stated the authorisation system handles over a billion requests per second. It is plausibly the busiest policy engine on Earth.
How credentials find your code
Almost nobody signs requests with keys they typed in. The SDKs walk a credential provider chain, and knowing its order explains most “why is this running as the wrong identity” mysteries: explicit configuration in code, then the environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN), then web identity token files, then the shared ~/.aws/credentials and ~/.aws/config profiles (including SSO sessions), then the container credentials endpoint (ECS/EKS), then finally the EC2 instance metadata service. First hit wins.
On EC2, the last link in that chain is worth understanding precisely, because it has been the pivot point of real breaches. An instance profile is a container that attaches exactly one role to an instance; the instance metadata service (IMDS) at 169.254.169.254 serves that role’s temporary credentials to anything on the box that asks. The 2019 Capital One breach was a server-side request forgery that asked: a misconfigured WAF was tricked into fetching the credentials URL and handing back live keys for a role with S3 access to a hundred million credit applications.
IMDSv2 is the response. Getting metadata now takes two steps: a PUT to fetch a session token, then a GET presenting the token in a header.
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
Most SSRF vulnerabilities can issue GETs but can’t issue PUTs or set custom headers, so step one fails. The token response also ships with an IP TTL (the “hop limit”) of 1 by default, so if the instance is acting as a NAT box or proxy, the response is dropped before it can leave the machine. New instance types default to IMDSv2-only; you should enforce it everywhere (HttpTokens: required) and treat IMDSv1 as a finding.
The credentials IMDS vends rotate automatically, roughly every hour with early refresh, and that points at the larger trend: long-lived access keys are dying, deliberately. Every mainstream workload now has a keyless path: EC2 and ECS get roles via metadata endpoints; Lambda gets its execution role injected; GitHub Actions and other CI systems federate via OIDC and call AssumeRoleWithWebIdentity with a short-lived job token; on-premises servers use IAM Roles Anywhere with X.509 certificates; humans come through Identity Center. AWS has been squeezing from the policy side too: condition keys to deny access-key creation, credential reports and last-used data to find stale keys, and, as of May 2026, condition keys (iam:ServiceSpecificCredentialAgeDays among them) to constrain even the niche service-specific credentials. An AKIA key (long-term) in a config file is a 2015 architecture; an ASIA key (temporary) that expires within hours is what everything modern uses. If you still have IAM users with keys, the roadmap is: inventory them with the credential report, find what the keys actually touch via Access Analyzer, move each workload to a role, delete the user.
One global namespace, one control plane
IAM looks global: a role ARN has no region in it, and a role created once is usable everywhere. The implementation is a control plane in us-east-1 with read replicas in every region. Writes (create a role, attach a policy, delete a key) go to Northern Virginia; the data plane that answers “is this request allowed” runs locally in each region against a replicated copy of your policy configuration, so authorisation decisions never depend on a cross-region hop and keep working during a us-east-1 control-plane event. You can see the seam directly: IAM’s API endpoint is iam.amazonaws.com, IAM changes land in CloudTrail as us-east-1 events (an EventBridge rule watching for CreateUser or AttachRolePolicy fires only in us-east-1, and single-region trails elsewhere miss IAM entirely unless they’re multi-region), and IAM quota increases must be requested through the Service Quotas console in us-east-1.
Replication is asynchronous, so IAM is eventually consistent, and the API acknowledges your write before every region has seen it. The propagation window is usually a few seconds, occasionally longer, and it produces a family of gotchas every infrastructure engineer eventually meets. Create a role and immediately create a Lambda function that uses it: The role defined for the function cannot be assumed by Lambda. Re-run and it works. Terraform and CloudFormation carry dedicated retry logic for exactly this. The window cuts the other way too: a deleted access key or a detached policy can remain live for seconds after the delete call returns, and security researchers have shown persistence tricks that deliberately race the propagation. The operational rules: never write IAM changes and consume them in the same breath without retries, and treat revocation as complete only once CloudTrail shows the credential failing.
The cast of principals
The root user is the account’s original identity: email, password, and powers no policy can take away, because identity policies don’t apply to it (only organisation-level SCPs can constrain a member account’s root). A handful of tasks genuinely require it (closing the account, some billing and support changes); everything else shouldn’t go near it. The posture has hardened sharply. MFA on root went from advice to enforcement in waves through 2024 and, since June 2025, is required across all account types, with FIDO2 passkeys (including syncable ones unlocked by Touch ID or Windows Hello) as a first-class, phishing-resistant option alongside TOTP apps and hardware keys. And since November 2024, centralised root access management lets an organisation delete root credentials from member accounts entirely: no root password, no root MFA device to manage per account, with short-lived, task-scoped privileged root sessions issuable centrally for the rare cases that need one. For any org of size, this is the correct end state: most accounts should have no usable root credentials at all.
IAM users are named identities with a password and/or up to two access keys. In 2027 they’re a legacy pattern for humans (Identity Center replaces them) and a fallback for machines (roles replace them); the legitimate remnant is third-party tooling that can’t federate.
Roles are the workhorse. A role is a principal with two policy attachment points: permissions policies saying what it can do, and a trust policy saying who may assume it. The trust policy is worth internalising as what it literally is: a resource-based policy where the role itself is the resource and the action is sts:AssumeRole. That framing collapses a lot of apparent special cases; cross-account role assumption follows exactly the same two-sided rules as cross-account bucket access.
Federated identities are external identities (a SAML assertion from your IdP, an OIDC token from Google or GitHub) exchanged for role sessions via STS. Service principals (lambda.amazonaws.com, ec2.amazonaws.com) are how AWS services themselves appear in trust policies, so a service can assume a role in your account to act on your behalf. Service-linked roles are the managed version of that: roles owned by a service, with an immutable trust policy and an AWS-managed permissions policy, created automatically when you first use the feature that needs them. You can see them (AWSServiceRoleForAutoScaling) but can’t edit their permissions: the service knows what it needs, and drift is impossible.
The policy language
Every policy is a JSON document of statements, and every statement answers four questions: Effect (Allow or Deny), Action, Resource, and optionally Condition.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DeliveryPhotosOnly",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::greenbox-delivery-photos/${aws:PrincipalTag/depot}/*",
"Condition": {
"StringEquals": { "aws:PrincipalTag/team": "operations" },
"Bool": { "aws:SecureTransport": "true" }
}
}]
}
Policies split along two axes. Identity-based vs resource-based: identity policies attach to a principal and don’t name one (the attachment supplies it); resource policies attach to a resource (bucket policy, KMS key policy, SQS queue policy, role trust policy) and must name their principals, which is what makes them the vehicle for cross-account access. Inline vs managed: inline policies are embedded in a single principal and die with it; managed policies are standalone objects attachable to many principals, with versioning and a rollback trail. AWS ships several hundred AWS-managed policies; treat the job-function ones (ReadOnlyAccess, PowerUserAccess) as starting points and scaffolding, and customer-managed policies as the production tool.
The Version field, permanently 2012-10-17, gates policy variables like ${aws:PrincipalTag/depot} above, which let one policy serve many principals by folding request context into the resource ARN. That’s the mechanism underneath ABAC, attribute-based access control: tag your principals (directly, or via session tags passed through sts:TagSession at federation time), tag your resources, and write a handful of policies comparing aws:PrincipalTag to aws:ResourceTag instead of a thousand policies naming ARNs. ABAC scales well and audits terribly; you can no longer read a policy and know what it grants without also knowing the tag state of the world, and a tag becomes a security boundary, so tag-writing permissions become privileged.
Condition operators come in families: string (StringEquals, StringLike with wildcards), ARN (ArnLike), numeric and date comparisons, IpAddress for CIDR matching, Bool, and Null for testing whether a key exists at all. Every operator has an ...IfExists variant that passes when the key is absent, and multivalued keys need the ForAllValues:/ForAnyValue: prefixes, a corner where subtle always-true conditions breed (an empty request set satisfies ForAllValues, which has surprised more than one security team).
Then there are NotAction and NotResource, which invert the match, and which are safe with Deny and dangerous with Allow. Deny + NotAction is the standard idiom for “deny everything except this short list”, and it’s how region-restriction and break-glass policies get written. Allow + NotAction means “allow everything except this list”, which grants every action in every service AWS ships next year, sight unseen. Linters flag it for good reason.
The evaluation order: who holds a veto
Now the full decision. For a request within one account, the engine works through an ordered series of gates, and the two rules that matter most are: an explicit Deny from any policy, anywhere, ends the evaluation immediately, and after that, each applicable gate must contain an Allow.
- Resource control policies (RCPs, from AWS Organizations) apply to the resource’s account: a ceiling on what anyone, including external principals, can do to resources in the account. Introduced November 2024 for S3, STS, KMS, SQS and Secrets Manager, extended since (ECR and OpenSearch Serverless in 2025; Cognito, CloudWatch Logs and DynamoDB by early 2026). This is where a data perimeter lives: “no S3 bucket in this organisation serves a principal outside the organisation”, written once, as a Deny with an
aws:PrincipalOrgIDcondition, immune to any bucket policy anyone writes below it. - Service control policies (SCPs) apply to the principal’s account: a ceiling on what identities in the account can do, regardless of their identity policies. SCPs grant nothing; they bound. (Neither SCPs nor RCPs touch service-linked roles, or the management account’s own principals, two exemptions that surprise people during incident response.)
- The resource-based policy, if the resource has one. The asymmetry worth memorising: within one account, an Allow in the resource policy can authorise the request all by itself, even if the principal’s identity policies say nothing. Same-account access is roughly the union of the two.
- The permissions boundary, if the principal has one: a managed policy acting as a filter, so effective identity permissions are the intersection of boundary and identity policies. Boundaries exist to make permission-granting delegable: platform teams let application teams create their own roles, on the condition that every created role carries the boundary, enforced with an
iam:PermissionsBoundarycondition oniam:CreateRole. The boundary caps the child roles no matter what policies get attached to them. - The session policy, if one was passed to
AssumeRole: another intersection filter, scoping down a single session below what the role allows. - The identity-based policies: the union of everything attached to the principal and its groups. For most requests, this is where the Allow actually comes from.
Cross-account requests drop the union shortcut: the request must be allowed independently on both sides, by the resource policy in the resource’s account (naming the foreign principal) and by identity policies in the caller’s account, with each account’s organisational policies applying to its own half. Both owners hold a veto, which is exactly what you want at a trust boundary. KMS adds its own famous wrinkle: a key policy is the sole root of trust for its key, and even same-account identity policies grant nothing unless the key policy delegates to IAM (the standard "Principal": {"AWS": "arn:aws:iam::111122223333:root"} statement does precisely that delegation, and deleting it locks the account out of its own key).
One more subtlety comes up in debugging: when a resource policy names a role ARN as principal, IAM resolves it to the role’s hidden unique ID at save time. Delete and recreate the role with the same name and the policy shows the same text but points at a corpse; the console displays the raw unique ID (AROA...) as a tell.
STS: the temporary-credential machine
The Security Token Service mints every temporary credential in AWS. Its flagship call, AssumeRole, takes a role ARN and a session name and returns three things: an ASIA... access key, a secret key, and a session token, valid for a duration between 15 minutes and the role’s MaxSessionDuration (configurable up to 12 hours; default one hour). Nothing revokes an issued session before expiry; the practical revocation tools are a Deny with an aws:TokenIssueTime condition (the console’s “revoke active sessions” button writes exactly that) or deleting the role.
Role chaining is using temporary role credentials to assume another role, and chained sessions are capped at one hour flat, regardless of MaxSessionDuration; ask for more and the call fails. The cap is anti-laundering: without it, a stolen session could re-mint itself indefinitely. It’s also the constraint that shapes hub-and-spoke designs (authenticate centrally, chain into workload accounts), which must build refresh into the tooling because every chained hop is a one-hour lease.
External ID solves the cross-account confused deputy. A SaaS monitoring vendor assumes roles in thousands of customer accounts from one vendor account. Attacker signs up as a customer, then points the vendor at your account’s role ARN, hoping the vendor’s credentials plus your permissive trust policy add up to access. The fix is a shared secret of sorts: your trust policy requires sts:ExternalId to equal a value the vendor generated for you, and the vendor always sends the ID belonging to the tenant on whose behalf it’s acting, so a request made for the attacker carries the attacker’s ID and fails your condition. (The service-to-service cousin of this attack is solved differently: aws:SourceArn and aws:SourceAccount conditions in trust and resource policies pin which specific resource a service principal may be acting for.)
Federation rounds out the family. AssumeRoleWithSAML exchanges a signed IdP assertion for a session; AssumeRoleWithWebIdentity does the same for an OIDC token, and neither call is itself signed, since the token is the proof. The OIDC path is what killed CI/CD access keys: GitHub Actions requests a job-scoped token from GitHub’s issuer, your role’s trust policy matches claims (repo:greenbox/platform:ref:refs/heads/main), and the job holds credentials that die with it. The token mechanics behind that dance are OAuth and OIDC under the hood. IAM Roles Anywhere covers the machines that predate all this: an on-premises server holds an X.509 certificate from a CA you’ve registered as a trust anchor, signs a request with the certificate’s private key (SigV4 with the certificate in place of an access key), and trades it for role credentials, up to 12 hours’ worth since 2024. And for auditability across all of these, a session can carry a SourceIdentity, set once at federation time, immutable through chaining, and stamped into every CloudTrail event the session produces.
Use the regional STS endpoints (sts.ap-southeast-2.amazonaws.com) rather than the legacy global one: lower latency, no dependency on us-east-1, and tokens from either now work in all regions by default.
Identity Center: the front door for humans
IAM Identity Center is the renamed AWS SSO (2022) and the answer AWS wants you to give for workforce access. It holds (or federates to) your user directory, and it revolves around permission sets: templates combining managed policies, inline policy, session duration (one to twelve hours), and optionally a boundary.
The mechanism underneath is unglamorous and worth knowing: assign a permission set to a user or group in an account and Identity Center provisions an ordinary IAM role in that account, named AWSReservedSSO_<permission-set>_<hash>, with a trust policy naming Identity Center’s SAML provider. Sign-in is federation: you authenticate once (with your external IdP or the built-in directory), pick an account and permission set from the portal, and under the hood an AssumeRoleWithSAML mints a session for the corresponding role. The CLI flow (aws sso login) uses an OIDC device grant and caches short-lived credentials that refresh silently. Edit the permission set and Identity Center re-provisions the role in every assigned account. There is no new authorisation engine anywhere in this; it’s a fleet-management layer for roles and federation, which is exactly why it composes cleanly with everything above: SCPs bound the sessions, CloudTrail logs them, and resource policies can match them (matching on the permission-set name via ArnLike wildcards, because the role names carry per-account hashes).
The genuinely new capability is trusted identity propagation: instead of a session flattening every user into “the role”, Identity Center can embed the actual workforce user’s identity context into a session, and services like Redshift, Athena, Lake Formation, S3 Access Grants and QuickSight authorise and log against the human, not the role. That closes a long-standing audit gap where every BI query arrived as the same service role.
Access Analyzer and the automated reasoning story
IAM policies compose in ways that defeat eyeballing: half a dozen policy types, unions here, intersections there, wildcards and conditions everywhere. AWS’s response was to make policy analysis a formal-methods problem. Zelkova, built internally from 2016 onward, translates IAM policies into logical formulae and hands questions (“does any request exist that this policy allows and that reaches outside the organisation?”) to SMT solvers, which either prove no such request exists or produce a witness. This is exhaustive in a way no amount of testing is: it reasons over the entire request space, all possible IPs, ARNs, and condition values at once. Amazon has said the fleet answers over a billion solver queries a day, backing S3 Block Public Access, console warnings, and the whole Access Analyzer product.
What that means, concretely, in IAM Access Analyzer today: external access findings (free) enumerate every resource in your zone of trust (account or organisation) that a resource policy makes reachable from outside it, continuously, with proofs rather than samples. Internal access findings (added at re:Inforce 2025) answer the inverse question for selected resources: which principals inside the organisation can reach this bucket, computed from the intersection of every applicable policy layer. Unused access findings (paid) watch roles, keys, passwords, and individual permissions against actual activity over a configurable window (90 days by default) and tell you what to delete; for what remains, Access Analyzer will draft the tightened policy. Policy generation works from the other end, reading CloudTrail history and proposing a policy matching what a role actually did. Policy validation runs 100-plus checks for errors and sloppy patterns, and custom policy checks put the solver in your CI pipeline: CheckNoNewAccess proves a policy change grants nothing beyond a reference policy, CheckAccessNotGranted proves specific actions and resources stay unreachable, CheckNoPublicAccess proves a resource policy opens nothing to the world. A failed check blocks the merge with a mathematical counterexample rather than a reviewer’s hunch.
The edges
The limits you’ll actually hit. A managed policy caps at 6,144 characters (whitespace excluded), and role inline policies at 10,240 in aggregate, which sounds roomy until autogenerated data-platform policies meet it; the workarounds are splitting across multiple managed policies (default ten per role, raisable to 25) or collapsing ARN lists with wildcards and tags. Trust policies default to 2,048 characters (raisable to 8,192 since May 2026), a limit that bites OIDC trust policies enumerating many repos. Roles default to 1,000 per account, raisable to 10,000, and Identity Center’s role-per-permission-set-per-account provisioning is the thing that eats the quota at scale. SCPs and RCPs each cap at 5,120 characters with at most five attached per node of the organisation tree. Access keys: two per user, so rotation is possible without downtime. And quota increases for IAM go through us-east-1, because of course they do.
Beyond quotas, the sharp edges are semantic. iam:PassRole is the permission that governs handing a role to a service (an EC2 instance profile, a Lambda execution role), and unscoped iam:PassRole on * is privilege escalation in one move: pass an admin role to a Lambda you control, invoke it, done. Scope it to specific role ARNs, always. Wildcard Principal: "*" in a resource policy means the entire internet unless conditions rein it in, which is why S3 Block Public Access exists as a blunt independent override. And the eventual-consistency window from earlier applies to everything: policies, keys, trust relationships; write your automation as if every IAM read is a few seconds stale, because it is.
What it costs
IAM costs nothing. So do STS, Identity Center, Organizations with all its policy types, and IAM Roles Anywhere. The metered exceptions are narrow: Access Analyzer’s unused access analysis (around US$0.20 per role or user per month), its custom policy checks (per API call), and CloudTrail beyond the free management-event trail.
The real cost is operational, and it’s large. Policy sprawl is unbounded: thousands of roles, each with policies someone wrote under deadline and nobody has read since. Every hour an engineer spends decoding an AccessDenied is IAM cost. Every over-broad s3:* that turns a leaked key into a data breach is IAM cost, billed at incident rates. The teams that keep the cost down treat IAM as code with the same rigour as application code: policies in version control, reviewed like code, checked in CI by the solver, pruned on a schedule by unused-access findings. The teams that don’t, pay in the currency of 2 a.m. and legal counsel.
Running it in anger
Debugging access denied is the daily reality, and it has improved: deny messages now name the policy type responsible (... with an explicit deny in a service control policies), which immediately tells you whether to look at the role or argue with the platform team. The routine: read the error for principal, action, resource, and policy type; check CloudTrail (in us-east-1, if it’s an IAM action) for the full event including condition context; reproduce with the policy simulator or, for anything involving organisation policies, a live call from a scratch resource, since the simulator’s coverage of the newer layers is patchy. The classic silent killer is a condition key that’s absent from the request context, so a StringEquals on aws:PrincipalTag/team fails because the tag was never set, not because it was wrong.
For architecture, the consensus pattern in 2027 is stable. Many small accounts, because the account is the strongest blast-radius boundary AWS offers. Organizations on top, with a thin layer of SCPs for what principals may never do (leave allowed regions, disable CloudTrail, delete the boundary policies) and RCPs for the data perimeter (no resource serves principals outside the org). Identity Center for every human; roles for every machine; zero IAM users as the target state, enforced with an SCP on iam:CreateUser and an exception process. Permission boundaries where teams self-serve role creation. A break-glass path that does not depend on the IdP: one heavily alarmed role assumable with hardware-MFA’d credentials from a vault, tested quarterly, because the day your IdP is down is the day you need AWS access most.
And least privilege is a ratchet, worked over time, rather than a state you write correctly on day one. Start workloads with honest, moderately scoped policies; let them run; use unused-access findings and policy generation to tighten to observed reality; wire CheckNoNewAccess into CI so the ratchet only turns one way. Nobody hand-writes a perfect least-privilege policy for a system that doesn’t exist yet, and the tooling has finally caught up to that fact.
What experience teaches
Guardrails beat grants: a Deny you write once at the organisation level outweighs a thousand carefully reviewed Allows, because explicit deny always wins and applies to principals that don’t exist yet. Trust policies deserve more review than permissions policies, since who can become an identity matters more than what the identity can do. Assume any long-lived credential will eventually leak, and build so that leaking a credential yields an hour of narrow access instead of a decade of broad access; that single design habit, more than any tool, is what separates the incidents that make the news from the ones that make a ticket. Treat IAM changes like deploys, with review, CI proofs, and rollback, because that’s what they are: changes to the system that decides everything else. And when AWS says no, believe it and read the message carefully; the evaluation engine is one of the most heavily verified pieces of software you will ever use, and in fifteen years of people insisting otherwise, it has almost never been wrong about whose policy said what.