Identity and access management · Intermediate

Roles, temporary credentials and explicit deny

4 min read

Why it matters

Long-lived keys stored in CI systems and laptops are one of the most common ways cloud accounts are compromised. Roles solve this: a workload assumes a role and receives credentials that expire in minutes. Explicit denies add guard rails that no later Allow can override.

The concept

  • A role has two policies: a trust policy (who may assume it) and a permissions policy (what it can do).

  • AssumeRole returns temporary credentials: an access key starting with ASIA (long-lived user keys start with AKIA), a secret, a session token and an expiry.

  • Workload identity: CI systems such as GitHub Actions, and Kubernetes service accounts, can exchange their own signed identity token for role credentials through OpenID Connect, so no cloud secret is stored in the CI system at all.

  • Explicit deny always wins. Use it for guard rails: "nobody deletes releases", "nothing outside these regions".

Worked example: a CI deploy role

roles_and_deny.py
import json

import boto3
from botocore.exceptions import ClientError
from moto import mock_aws
from moto.core import set_initial_no_auth_action_count

REGION = "ap-south-1"

TRUST_POLICY = {  # who may assume the role: here, principals in this account
    "Version": "2012-10-17",
    "Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, "Action": "sts:AssumeRole"}],
}

DEPLOY_POLICY = {
    "Version": "2012-10-17",
    "Statement": [
        {"Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::acme-site/*"},
        {"Sid": "NeverDeleteReleases", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::acme-site/releases/*"},
    ],
}


@mock_aws
@set_initial_no_auth_action_count(6)  # setup and AssumeRole run unchecked; every S3 call with the role's credentials is enforced
def main():
    iam = boto3.client("iam", region_name=REGION)
    s3 = boto3.client("s3", region_name=REGION)
    s3.create_bucket(Bucket="acme-site", CreateBucketConfiguration={"LocationConstraint": REGION})
    s3.put_object(Bucket="acme-site", Key="releases/v41.tar.gz", Body=b"old release")
    s3.put_object(Bucket="acme-site", Key="tmp/build.log", Body=b"log")
    role = iam.create_role(RoleName="ci-deploy", AssumeRolePolicyDocument=json.dumps(TRUST_POLICY))["Role"]
    iam.put_role_policy(RoleName="ci-deploy", PolicyName="deploy-site", PolicyDocument=json.dumps(DEPLOY_POLICY))
    creds = boto3.client("sts", region_name=REGION).assume_role(
        RoleArn=role["Arn"], RoleSessionName="pipeline-run-812", DurationSeconds=900
    )["Credentials"]
    print("temporary key starts with:", creds["AccessKeyId"][:4], "| expires:", creds["Expiration"] is not None)

    ci = boto3.client("s3", region_name=REGION, aws_access_key_id=creds["AccessKeyId"],
                      aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"])
    attempts = [
        ("upload new release", lambda: ci.put_object(Bucket="acme-site", Key="releases/v42.tar.gz", Body=b"new")),
        ("delete temporary log", lambda: ci.delete_object(Bucket="acme-site", Key="tmp/build.log")),
        ("delete old release", lambda: ci.delete_object(Bucket="acme-site", Key="releases/v41.tar.gz")),
    ]
    for label, call in attempts:
        try:
            call()
            print(f"{label:22} allowed")
        except ClientError as error:
            print(f"{label:22} denied ({error.response['Error']['Code']})")


main()
shellsession
$ python3 roles_and_deny.py
temporary key starts with: ASIA | expires: True
upload new release     allowed
delete temporary log   allowed
delete old release     denied (AccessDenied)

The deploy role's Allow covers s3:DeleteObject on the whole bucket, so deleting a temporary log works. The NeverDeleteReleases statement is a Deny, so deleting an old release fails even though the Allow matches. Removing that Deny statement and running again allows all three actions, which shows the deny is what protects the release.

In a real pipeline, the job would assume the role through OpenID Connect rather than with a stored key. In this local emulation, assuming the role happens in the unchecked setup phase; the S3 calls made with the temporary credentials are all evaluated against the role's policy.

Common mistakes

  • Trust policies that are too broad, such as letting any principal in another account assume a powerful role.

  • Session durations of many hours for automated jobs that need a few minutes.

  • Relying only on Allow statements for things that must never happen. Use explicit denies or organisation-level guard rails.

  • Sharing one role across many pipelines, which makes audit trails useless. One role per workload.

Interview angle

"How should a CI pipeline authenticate to the cloud?" (OIDC federation to a scoped role, no stored keys). "What is the difference between a trust policy and a permissions policy?" "A user has an Allow and a Deny for the same action. What happens?" (Deny wins).

Practice task

  1. Add a condition to the trust policy so the role can only be assumed with a specific session name prefix, and test it.

  2. Read how GitHub Actions uses OpenID Connect with AWS, and write the trust policy that allows only your repository's main branch to assume the role.

Further reading