# 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.

DevSecOps Engineer roadmap · Secure the platform · 19 min read · https://levelcode.org/roadmaps/devsecops/cloud-iam

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:

```json
{
  "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 `Action`s it covers (`service:Operation`), the `Resource`s by ARN (Amazon Resource Name), and optionally `Condition`s. 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:

1. Everything starts as an **implicit deny**.
2. An **explicit `Deny`** in any applicable policy wins, always.
3. 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.

> **Interactive: Why was this request allowed or denied?.** Try it: https://levelcode.org/labs/iam-evaluator

## 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:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": "s3:*", "Resource": "*" },
    { "Effect": "Allow", "Action": "kms:*", "Resource": "*" }
  ]
}
```

```json
{
  "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.

> [!WARNING]
> Some actions are privilege escalation in disguise: `iam:PassRole` with `"Resource": "*"` lets a principal hand *any* role to a service it launches; `iam:CreatePolicyVersion`, `iam:AttachRolePolicy` and `iam:PutRolePolicy` let it grant itself anything; `sts:AssumeRole` on `*` lets it become any role that trusts the account. Scope these to specific ARNs or don't grant them.

## 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.

```json
{
  "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:

```ini
# ~/.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-1
```

```bash
aws sso login --sso-session acme
aws sts get-caller-identity --profile orders-dev
```

```json
{
    "UserId": "AROAEXAMPLEID:jane@acme.example",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_OrdersDeveloper_0123abcd/jane@acme.example"
}
```

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:

```json
{
  "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:

```json
{
  "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:

```yaml
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-identity
```

The 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.

> [!IMPORTANT]
> The `sub` condition is the security boundary. Without it, or with `repo:*`, **any GitHub repository in the world** could assume your role. Use `StringEquals` on an exact subject; if you need `StringLike`, keep the wildcard after your org and repo (`repo:acme/orders-api:ref:refs/tags/v*`). Subjects for pull requests (`repo:acme/orders-api:pull_request`) should only ever map to read-only roles.

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`?"

```bash
aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY \
  --policy-document file://deploy-policy.json \
  --query 'findings[].[findingType,issueCode]' --output text
```

```text
SECURITY_WARNING	PASS_ROLE_WITH_STAR_IN_RESOURCE
SUGGESTION	REDUNDANT_ACTION
```

```bash
aws accessanalyzer check-access-not-granted --policy-type IDENTITY_POLICY \
  --policy-document file://deploy-policy.json \
  --access '[{"actions": ["iam:CreateUser", "s3:DeleteBucket"]}]' \
  --query result --output text
```

```text
PASS
```

Run 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:

```json
{
  "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.

> [!TIP]
> Keep the procedure in a document that doesn't depend on the systems it rescues: a printed runbook in the safe, or a wiki outside the cloud account. It should say who may authorise use, how to retrieve credentials, and what to do afterwards.

## 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.

1. Write the least-privilege permission policy for the deploy role.
2. Write the trust policy so only workflows on `main` in that repository can assume it.
3. Write the workflow step that obtains credentials, and list the clean-up needed for the old user.

**Solution**

Permission policy:

```json
{
  "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:

```json
{
  "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:

```yaml
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 broad `sts:AssumeRole` as 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 `sub` condition 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.

## Quiz

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?

   A. Yes, because the identity policy is more specific
   B. Yes, if the bucket policy also allows it
   C. No, because an explicit deny in any applicable policy always wins
   D. Only if the role was created before the SCP

   **Answer: C.** Explicit denies override every allow. SCPs set the maximum for the account, so no identity or resource policy inside it can grant past them.

2. A trust policy for GitHub OIDC checks only the `aud` claim, not `sub`. What is the risk?

   A. Workflows in any GitHub repository, including an attacker's, could assume the role
   B. Tokens expire too quickly
   C. Only pull requests could assume the role
   D. The role can't be used from the main branch

   **Answer: A.** Every GitHub Actions token is issued by the same issuer with the same audience. The sub claim is what identifies your repository and branch or environment.

3. Why does `s3:ListBucket` need the resource `arn:aws:s3:::my-bucket` while `s3:GetObject` needs `arn:aws:s3:::my-bucket/*`?

   A. ListBucket is a legacy action that ignores resources
   B. ListBucket acts on the bucket itself, GetObject acts on objects within it
   C. Wildcards are not allowed for ListBucket
   D. GetObject requires a resource-based policy instead

   **Answer: B.** IAM matches the resource each action operates on. Granting ListBucket on bucket/* or GetObject on the bare bucket ARN results in access denied.

4. Which is the best practice for break-glass access?

   A. Share the root password with all senior engineers so someone is always available
   B. Use the root user for daily administration so its credentials stay fresh
   C. Disable the root user's MFA so it can be used quickly in an emergency
   D. Store emergency credentials offline with hardware MFA, alert on every use, and test the procedure regularly

   **Answer: D.** Break-glass must work when normal access fails, yet any use must be visible. Offline storage, MFA, alerting and regular drills achieve both.
