Step 21 of 25
Cloud identity & access
Write least-privilege AWS IAM policies, replace users and access keys with roles and short-lived credentials, federate CI with OIDC and plan for break-glass.
In the cloud, identity is the perimeter. There's no firewall between an attacker holding valid credentials and your data: if a leaked key is allowed to read a bucket, the bucket is read. That makes identity and access management (IAM) the control with the largest blast radius in your whole stack. This lesson uses AWS IAM for concrete examples, but every major cloud has the same building blocks. You'll learn how policies are evaluated, how to write least-privilege ones, why roles and short-lived credentials beat users and keys, how CI can deploy with no stored secret at all, and how to keep an emergency way in.
How cloud authorisation works
Every API call is made by a principal (a user, a role, or a federated identity) and is checked against policies: JSON documents that allow or deny actions on resources. An AWS policy looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadInvoices",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::acme-orders-invoices-prod/invoices/*"
}
]
}Each statement has an Effect (Allow or Deny), the Actions it covers (service:Operation), the Resources by ARN (Amazon Resource Name), and optionally Conditions. Policies come in several kinds:
| Kind | Attached to | Answers |
|---|---|---|
| Identity-based policy | A user, group or role | What may this principal do? |
| Resource-based policy | A resource such as an S3 bucket, KMS key or role (the trust policy) | Who may use this resource? |
| Permissions boundary | A user or role | The maximum an identity can ever be granted |
| Service control policy (SCP) | An account or organisational unit in AWS Organizations | The maximum anything in these accounts can do |
The evaluation rule is short and worth memorising:
- Everything starts as an implicit deny.
- An explicit
Denyin any applicable policy wins, always. - Otherwise, the request is allowed only if it is allowed by the identity or resource policy and not excluded by any boundary or SCP. (That "or" is for requests within one account; a cross-account request needs an allow on both sides: the caller's identity policy and the resource's policy.)
That's why guardrails are written as SCP denies ("no one in production accounts may disable CloudTrail or leave the approved regions"): no identity policy, however generous, can override them.
The rule is short, but applying it across four kinds of policy is where real mistakes happen. Step through the scenarios below, then break things on purpose: switch statements off, detach the boundary, change the action. For each request, predict the verdict before you look at the trace.
Least privilege in practice
Least privilege means each identity can do exactly what its job requires and nothing more. Compare two policies for a service that reads invoices and decrypts them:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:*", "Resource": "*" },
{ "Effect": "Allow", "Action": "kms:*", "Resource": "*" }
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadInvoiceObjects",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-orders-invoices-prod/invoices/*"
},
{
"Sid": "ListInvoicePrefix",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-orders-invoices-prod",
"Condition": { "StringLike": { "s3:prefix": ["invoices/*"] } }
},
{
"Sid": "DecryptViaS3Only",
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:eu-west-1:123456789012:key/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
"Condition": { "StringEquals": { "kms:ViaService": "s3.eu-west-1.amazonaws.com" } }
}
]
}If the first service is compromised, the attacker can read, overwrite and delete every bucket in the account and schedule every KMS key for deletion. With the second, they can read invoices, which the service could already do.
Notice the details: s3:ListBucket applies to the bucket ARN while s3:GetObject applies to object ARNs (bucket/key), a common source of "access denied" confusion. The kms:ViaService condition means the key can only be used through S3, not called directly.
How to get to least privilege without guessing:
- Start from nothing and add permissions as access-denied errors and tests show they're needed. Error messages name the missing action and resource.
- Generate from activity. IAM Access Analyzer can generate a policy from the actions a role actually called, as recorded in CloudTrail over a period you choose.
- Prune with last-accessed data. IAM shows when each service was last used by each identity. Permissions unused for 90 days are candidates for removal.
- Use conditions to narrow further:
aws:SourceVpce(only from your VPC endpoint),aws:PrincipalOrgID(only principals in your organisation),aws:RequestedRegion, resource tags.
Roles, not users
An IAM user has long-lived credentials: a password and up to two access keys that never expire until someone rotates them. Leaked keys are the single most common cause of cloud compromise: they end up in Git, in laptops' ~/.aws/credentials, in CI variables, in logs.
A role has no credentials of its own. A trusted principal assumes it and receives temporary credentials from STS (the Security Token Service), valid for 15 minutes to 12 hours. A role has two policies: a trust policy saying who may assume it, and permission policies saying what it may then do.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "aws:SourceAccount": "123456789012" } }
}
]
}That trust policy lets ECS tasks in your account assume the role; the SDK inside the container picks up rotating credentials automatically. The pattern is the same everywhere: EC2 instance profiles, Lambda execution roles, EKS Pod Identity. Workloads in the cloud should never hold access keys.
Humans should sign in through a central identity provider (AWS IAM Identity Center, backed by your company's IdP with MFA) and receive short-lived role credentials per account. The AWS CLI supports this natively:
# ~/.aws/config
[sso-session acme]
sso_start_url = https://acme.awsapps.com/start
sso_region = eu-west-1
sso_registration_scopes = sso:account:access
[profile orders-dev]
sso_session = acme
sso_account_id = 123456789012
sso_role_name = OrdersDeveloper
region = eu-west-1aws sso login --sso-session acme
aws sts get-caller-identity --profile orders-dev{
"UserId": "AROAEXAMPLEID:[email protected]",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_OrdersDeveloper_0123abcd/[email protected]"
}The assumed-role ARN shows you're using temporary credentials, and the session name records who, so CloudTrail logs stay attributable. When Jane leaves, disabling her in the IdP removes all cloud access at once.
OIDC federation from CI
CI pipelines are a favourite target because they hold deploy credentials. The secrets management lesson stressed short-lived credentials; OIDC federation gets CI there with no stored secret at all.
On each job, GitHub Actions can mint a signed JSON Web Token describing the job. Its claims look like this:
{
"iss": "https://token.actions.githubusercontent.com",
"aud": "sts.amazonaws.com",
"sub": "repo:acme/orders-api:environment:production",
"repository": "acme/orders-api",
"ref": "refs/heads/main",
"workflow_ref": "acme/orders-api/.github/workflows/deploy.yml@refs/heads/main",
"exp": 1791712800
}AWS trusts GitHub's OIDC issuer once (an IAM OIDC identity provider for token.actions.githubusercontent.com), and a role's trust policy says which jobs may assume it:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/orders-api:environment:production"
}
}
}
]
}The workflow requests the token with id-token: write and exchanges it:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/orders-api-deploy
aws-region: eu-west-1
- run: aws sts get-caller-identityThe credentials last an hour, exist only on that runner, and can only be obtained by a job running in the production environment of that one repository, which you can protect with required reviewers.
GitLab works the same way: declare id_tokens with aud: sts.amazonaws.com in the job, and match subjects like project_path:acme/orders-api:ref_type:branch:ref:main. Google Cloud (Workload Identity Federation) and Azure (federated credentials) accept the same tokens with their own trust configuration.
Analysing access
Policies accumulate. Analysis tools answer the questions you can't answer by reading JSON one file at a time.
IAM Access Analyzer provides several analyses:
- External access findings: resources (buckets, KMS keys, roles, queues, secrets) whose resource policies grant access to principals outside your account or organisation. A bucket shared with an unknown account ID shows up here.
- Unused access findings: roles, access keys and passwords not used for a set number of days, and permissions granted but never exercised.
- Policy validation: lints a policy for errors and risky patterns before you deploy it.
- Custom policy checks: answer yes/no questions in CI, such as "does this policy grant anything new compared with the current one?" or "does it grant
s3:DeleteBucket?"
aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY \
--policy-document file://deploy-policy.json \
--query 'findings[].[findingType,issueCode]' --output textSECURITY_WARNING PASS_ROLE_WITH_STAR_IN_RESOURCE
SUGGESTION REDUNDANT_ACTIONaws accessanalyzer check-access-not-granted --policy-type IDENTITY_POLICY \
--policy-document file://deploy-policy.json \
--access '[{"actions": ["iam:CreateUser", "s3:DeleteBucket"]}]' \
--query result --output textPASSRun validation in the same pipeline that plans your Terraform, and review external-access and unused-access findings on a regular schedule. Other clouds offer equivalents: Google Cloud's IAM Recommender and Policy Analyzer, and Microsoft Entra's access reviews.
Break-glass access
Least privilege and SSO create a new risk: if the identity provider is down or misconfigured, nobody can get in during the incident that needs it most. A break-glass procedure is a deliberately designed emergency route.
- The root user of each AWS account can do anything, including things no policy can restrict. Protect it with a hardware MFA key, delete any root access keys, and use it only for the handful of tasks that require it.
- One or two emergency IAM users or roles with administrative access in a dedicated account, not tied to the identity provider. Credentials are stored offline (a safe, or a password manager vault with split custody), protected by hardware MFA.
- Every use triggers an alert. Break-glass is legitimate but rare, so a sign-in should page the security team immediately:
{
"source": ["aws.signin"],
"detail-type": ["AWS Console Sign In via CloudTrail"],
"detail": {
"userIdentity": {
"type": ["Root"]
}
}
}This EventBridge pattern matches root console sign-ins. Sign-in events arrive in the Region where CloudTrail recorded them, which for the root user is usually us-east-1, so deploy the rule there and in your primary Region. Add a second rule matching your emergency principals' ARNs.
- Test it twice a year: retrieve the credentials, sign in, confirm the alert fired, rotate the credentials, and record it. A break-glass procedure nobody has tried will fail when you need it.
Practice
A workflow in acme/website publishes a static site on pushes to main: it runs aws s3 sync ./dist s3://acme-website-prod --delete, then invalidates CloudFront distribution E2QWRUHEXAMPLE. Today it uses an IAM user's access keys stored as repository secrets.
- Write the least-privilege permission policy for the deploy role.
- Write the trust policy so only workflows on
mainin that repository can assume it. - Write the workflow step that obtains credentials, and list the clean-up needed for the old user.
Show solution
Permission policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListSiteBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-website-prod"
},
{
"Sid": "WriteSiteObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-website-prod/*"
},
{
"Sid": "InvalidateCdn",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::123456789012:distribution/E2QWRUHEXAMPLE"
}
]
}sync lists the bucket to compare files, uploads changed ones and, because of --delete, removes stale ones. CloudFront ARNs have no region.
Trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/website:ref:refs/heads/main"
}
}
}
]
}If the job declares an environment:, the subject becomes repo:acme/website:environment:<name> instead, so the two must match.
Workflow:
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/website-deploy
aws-region: eu-west-1
- run: aws s3 sync ./dist s3://acme-website-prod --delete
- run: aws cloudfront create-invalidation --distribution-id E2QWRUHEXAMPLE --paths "/*"Clean-up: confirm a deploy works with the role, then delete the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY repository secrets, deactivate and delete the user's access keys (check CloudTrail and the key's last-used date first), and delete the IAM user. Finally, validate the new policy with aws accessanalyzer validate-policy and expect no findings.
Key takeaways
- An explicit deny always wins; otherwise access requires an allow and no SCP or boundary blocking it.
- Least privilege means specific actions on specific ARNs with conditions; generate and prune policies from real activity rather than guessing.
- Treat
iam:PassRole, policy-editing actions and broadsts:AssumeRoleas privilege escalation paths. - Use roles and short-lived STS credentials for workloads and SSO for humans; IAM users with access keys should be rare exceptions.
- Federate CI with OIDC, and make the trust policy's
subcondition match an exact repository and branch or environment. - Use Access Analyzer for external, unused and pre-deploy policy checks, and keep a tested, alerting break-glass procedure.
Check yourself
Quiz
0/4 correct · 4 left
1.A role's identity policy allows `s3:*` on all resources, and an SCP on its account denies `s3:DeleteBucket`. Can the role delete a bucket?
2.A trust policy for GitHub OIDC checks only the `aud` claim, not `sub`. What is the risk?
3.Why does `s3:ListBucket` need the resource `arn:aws:s3:::my-bucket` while `s3:GetObject` needs `arn:aws:s3:::my-bucket/*`?
4.Which is the best practice for break-glass access?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.