Back to Home

Back to Index

“Every identity is a potential attacker.”

IAM — Identity and Access Management — is one of the most under-audited and over-privileged parts of any system. Whether it’s cloud roles, SSH keys, service accounts, or OAuth scopes, the IAM layer quietly controls the blast radius of almost every breach.

IAM ASR is the practice of ruthlessly trimming, isolating, and simplifying identity and permission structures to reduce your exposure. When credentials are compromised (and they will be), proper IAM ASR limits what attackers can do with them.


1. Principle of Least Privilege

Least privilege isn’t just a best practice — it’s a requirement for meaningful security. Every permission granted is a capability an attacker gains when (not if) that identity is compromised.

Start with Zero, Not “Read-Only by Default”

The problem: Many organizations start with broad permissions and try to restrict later. This fails because:

  • Existing permissions are hard to remove without breaking things
  • Teams resist permission reductions
  • Nobody knows what’s actually being used

The solution: Start with zero access. Grant only what’s proven necessary through a request and approval process.

Bad IAM (overly permissive):

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

Good IAM (scoped and explicit):

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:GetObject",
      "s3:PutObject"
    ],
    "Resource": [
      "arn:aws:s3:::my-app-uploads/*"
    ],
    "Condition": {
      "StringEquals": {
        "s3:x-amz-server-side-encryption": "AES256"
      }
    }
  }]
}

Grant Access by Use Case, Not by Title

Anti-pattern: “All developers get admin access” Better: “Deploy automation gets write access to production S3; developers get read access to staging”

Use case-based IAM:

# Example: Permission matrix by use case
permissions_matrix = {
    "deploy_production": {
        "principals": ["github-actions-deploy-role"],
        "actions": ["s3:PutObject", "cloudfront:CreateInvalidation"],
        "resources": ["arn:aws:s3:::prod-app/*"],
        "conditions": {"IpAddress": {"aws:SourceIp": ["198.51.100.0/24"]}}  # From CI/CD only
    },
    "read_logs": {
        "principals": ["engineering-team-role"],
        "actions": ["logs:FilterLogEvents", "logs:GetLogEvents"],
        "resources": ["arn:aws:logs:*:*:log-group:/aws/lambda/prod-*"],
        "conditions": {"DateGreaterThan": {"aws:CurrentTime": "2024-01-01T00:00:00Z"}}
    },
    "backup_database": {
        "principals": ["backup-service-account"],
        "actions": ["rds:CreateDBSnapshot"],
        "resources": ["arn:aws:rds:us-east-1:*:db:production-db"],
        "conditions": {"StringEquals": {"aws:RequestedRegion": "us-east-1"}}
    }
}

Use Explicit Allowlists, Not Role Inheritance

Problem with role inheritance: Complex nested groups where nobody understands who actually has what access.

# Example: Group hierarchy that's too complex
# Users → Team Groups → Department Groups → Global Groups → Permissions
# Result: Nobody knows why Alice can access the database

Better approach: Direct, explicit grants with clear documentation

# Simple, explicit RBAC
roles:
  - name: production-deployer
    description: "Deploy to production infrastructure"
    permissions:
      - deploy:production:write
      - logs:production:read
    assigned_to:
      - github-actions-service-account
    reviewed_by: security-team
    last_review: 2024-01-15
    
  - name: developer-readonly
    description: "Read-only access to staging and dev"
    permissions:
      - read:staging:*
      - read:dev:*
    assigned_to:
      - engineering-team
    reviewed_by: engineering-lead
    last_review: 2024-01-15

AWS IAM Permission Boundaries

Permission boundaries limit the maximum permissions a role can have:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:*",
      "dynamodb:*",
      "logs:*"
    ],
    "Resource": "*"
  }]
}

Attach this as a permission boundary, then even if someone grants "Action": "*", they can only do S3, DynamoDB, and Logs operations.

Real-World Impact

The 2019 Capital One breach occurred because a firewall misconfiguration allowed SSRF, which was exploited to access AWS metadata service and retrieve IAM credentials. Those credentials had overly broad permissions allowing access to S3 buckets containing customer data.

If least privilege had been applied, the stolen credentials would have had minimal impact.

Further reading:


2. Human Account Hygiene

Human accounts are the weakest link in any IAM system. They’re susceptible to phishing, credential stuffing, and social engineering. Minimizing their number and privileges is critical.

Deactivate Stale Accounts Aggressively

The problem: Organizations keep inactive accounts indefinitely:

  • Former employees (major risk)
  • Contractors whose engagements ended
  • Interns from 3 years ago
  • Test accounts from POCs

Automated deactivation policy:

# Example: Auto-disable accounts inactive for 90 days
from datetime import datetime, timedelta
import boto3

iam = boto3.client('iam')

threshold = datetime.now() - timedelta(days=90)

for user in iam.list_users()['Users']:
    username = user['UserName']
    access_keys = iam.list_access_keys(UserName=username)['AccessKeyMetadata']
    
    for key in access_keys:
        last_used = iam.get_access_key_last_used(AccessKeyId=key['AccessKeyId'])
        last_used_date = last_used.get('AccessKeyLastUsed', {}).get('LastUsedDate')
        
        if not last_used_date or last_used_date < threshold:
            print(f"Deactivating key {key['AccessKeyId']} for {username}")
            iam.update_access_key(
                UserName=username,
                AccessKeyId=key['AccessKeyId'],
                Status='Inactive'
            )

Offboarding checklist:

## Employee Offboarding - IAM Checklist

- [ ] Disable AWS IAM user
- [ ] Revoke all access keys
- [ ] Remove from all IAM groups
- [ ] Disable SSO access (Okta, Azure AD, Google Workspace)
- [ ] Revoke GitHub organization membership
- [ ] Remove from Slack/Teams
- [ ] Disable VPN access
- [ ] Rotate any shared credentials they knew
- [ ] Remove SSH keys from servers
- [ ] Revoke database access
- [ ] Remove from PagerDuty/on-call rotation
- [ ] Audit: What did they have access to? (for documentation)

Eliminate Shared Logins

Anti-pattern: “dev-team” account used by 12 people

Problems:

  • No attribution (who made that change?)
  • No accountability (who leaked the credentials?)
  • Can’t revoke access for one person without affecting all
  • Password rotation affects everyone

Solution: Individual accounts + groups/roles

# Azure AD: Group-based access, individual accounts
groups:
  - name: engineering-team
    members:
      - alice@company.com
      - bob@company.com
      - carol@company.com
    assigned_roles:
      - Contributor (Staging Resource Group)
      - Reader (Production Resource Group)

# Not this:
# Username: dev-team
# Password: shared123

Just-in-Time Access

Principle: Grant temporary elevated access only when needed, automatically revoke after time limit.

Implementation example (AWS SSO + temporary elevation):

# Request elevated access for 4 hours
def request_elevated_access(user_email, role_arn, duration_hours=4):
    """
    Grant temporary admin access that auto-expires
    """
    sts = boto3.client('sts')
    
    response = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName=f"elevated-{user_email}",
        DurationSeconds=duration_hours * 3600,
        Tags=[
            {'Key': 'Purpose', 'Value': 'Incident Response'},
            {'Key': 'RequestedBy', 'Value': user_email},
            {'Key': 'ExpiresAt', 'Value': str(datetime.now() + timedelta(hours=duration_hours))}
        ]
    )
    
    # Log the elevation
    log_to_security_siem({
        'event': 'elevated_access_granted',
        'user': user_email,
        'role': role_arn,
        'duration': duration_hours,
        'credentials_expiry': response['Credentials']['Expiration']
    })
    
    return response['Credentials']

Commercial solutions:

Real-World Impact

The 2020 Twitter hack occurred because attackers socially engineered Twitter employees to gain access to an internal admin tool. If JIT access had been enforced, the compromised accounts would have had limited capabilities.

The Uber 2022 breach happened because an attacker bought credentials from the dark web belonging to a contractor whose access wasn’t properly deprovisioned.

Further reading:


3. Service Identity Discipline

Service accounts, API keys, and machine identities often outnumber human accounts 10:1 and are much less audited. They’re long-lived, broadly scoped, and rarely rotated.

Every Service Gets Its Own Identity

Anti-pattern: One “application” service account used by web app, background jobs, cron tasks, and reporting.

Problem: Can’t scope permissions appropriately, can’t trace which component did what, can’t revoke without breaking everything.

Better approach:

# Kubernetes: Separate service accounts
apiVersion: v1
kind: ServiceAccount
metadata:
  name: webapp-sa
  namespace: production
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: background-worker-sa
  namespace: production
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: cronjob-backup-sa
  namespace: production

Each with minimal, scoped permissions:

# webapp-sa can read database, write to S3
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: webapp-role
rules:
- apiGroups: [""]
  resources: ["secrets"]
  resourceNames: ["db-credentials"]
  verbs: ["get"]
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["app-config"]
  verbs: ["get"]

---
# background-worker-sa can read queue, write to S3
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: worker-role
rules:
- apiGroups: [""]
  resources: ["secrets"]
  resourceNames: ["queue-credentials", "s3-credentials"]
  verbs: ["get"]

Scope Access to Exactly What’s Needed

Example: Backup service

Bad:

{
  "Effect": "Allow",
  "Action": "s3:*",
  "Resource": "*"
}

Good:

{
  "Effect": "Allow",
  "Action": [
    "s3:PutObject",
    "s3:PutObjectAcl"
  ],
  "Resource": "arn:aws:s3:::backups-bucket/daily-backups/*",
  "Condition": {
    "StringEquals": {
      "s3:x-amz-server-side-encryption": "aws:kms"
    },
    "IpAddress": {
      "aws:SourceIp": "10.0.1.0/24"
    }
  }
}

This backup service can ONLY:

  • Write to a specific S3 prefix
  • Must use KMS encryption
  • Must originate from the backup server subnet

Prefer Short-Lived Credentials Over Static Keys

Static access keys are dangerous:

  • Never expire
  • Often copy-pasted into multiple places
  • Hard to rotate without breaking things
  • Frequently committed to git

Short-lived tokens:

# AWS: Use STS AssumeRole instead of static keys
import boto3

def get_short_lived_credentials(role_arn):
    """
    Get 1-hour credentials instead of static access keys
    """
    sts = boto3.client('sts')
    response = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName='application-session',
        DurationSeconds=3600  # 1 hour
    )
    return response['Credentials']

# Use these credentials
creds = get_short_lived_credentials('arn:aws:iam::123456789:role/app-role')
s3 = boto3.client('s3',
    aws_access_key_id=creds['AccessKeyId'],
    aws_secret_access_key=creds['SecretAccessKey'],
    aws_session_token=creds['SessionToken']
)

Even better: Use OIDC or Workload Identity Federation

# GitHub Actions: No static credentials needed
- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
    aws-region: us-east-1
    # GitHub's OIDC token is automatically exchanged for AWS credentials

Service Account Key Rotation

If you must use static keys, rotate them frequently:

# Automated key rotation script
#!/bin/bash
# Rotate AWS IAM access keys for service account

SERVICE_ACCOUNT="app-service-user"
KEY_AGE_DAYS=90

# Get current keys
KEYS=$(aws iam list-access-keys --user-name "$SERVICE_ACCOUNT" --query 'AccessKeyMetadata[*].[AccessKeyId,CreateDate]' --output text)

while IFS=$'\t' read -r KEY_ID CREATE_DATE; do
    # Calculate age
    CREATE_EPOCH=$(date -d "$CREATE_DATE" +%s)
    NOW_EPOCH=$(date +%s)
    AGE_DAYS=$(( ($NOW_EPOCH - $CREATE_EPOCH) / 86400 ))
    
    if [ $AGE_DAYS -gt $KEY_AGE_DAYS ]; then
        echo "Key $KEY_ID is $AGE_DAYS days old - rotating"
        
        # Create new key
        NEW_KEY=$(aws iam create-access-key --user-name "$SERVICE_ACCOUNT")
        
        # Update application config with new key
        # (specific to your setup - could be K8s secret, AWS Secrets Manager, etc.)
        update_application_credentials "$NEW_KEY"
        
        # Wait for propagation
        sleep 60
        
        # Delete old key
        aws iam delete-access-key --user-name "$SERVICE_ACCOUNT" --access-key-id "$KEY_ID"
        echo "Rotation complete"
    fi
done <<< "$KEYS"

Real-World Impact

The 2018 Tesla Kubernetes cryptojacking incident occurred because a Kubernetes dashboard was exposed without authentication, allowing access to service account credentials with excessive permissions.

The CircleCI security incident (2023) exposed customer secrets including OAuth tokens and API keys stored in their systems. Organizations using short-lived credentials had significantly less exposure.

Further reading:


4. Shadow Access Discovery

Shadow access is permissions you don’t know about: forgotten credentials, orphaned keys, credentials in repos, and access through unexpected paths.

Discovery Techniques

Find unused IAM roles (AWS):

# Roles not used in 90 days
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | base64 -d > cred_report.csv

# Parse for unused roles
awk -F',' '$5 == "N/A" || $5 == "no_information" {print $1}' cred_report.csv

Find CLI profiles on dev machines:

# Audit developer workstations
find /home -name ".aws" -type d 2>/dev/null
find /home -name ".kube" -type d 2>/dev/null
find /home -name "credentials" 2>/dev/null | xargs grep -l "aws_access_key_id"

# Check for credentials in shell history
grep -r "export AWS" /home/*/.bash_history 2>/dev/null

Find credentials in repos:

# Use gitleaks or trufflehog
docker run --rm -v "$PWD:/pwd" trufflesecurity/trufflehog:latest filesystem /pwd

# Common patterns
git grep -E "AKIA[0-9A-Z]{16}"  # AWS access keys
git grep -E "ghp_[a-zA-Z0-9]{36}"  # GitHub tokens
git grep -E "xox[baprs]-[0-9a-zA-Z]{10,}"  # Slack tokens

Find credentials in CI logs:

# GitHub Actions: Search for exposed secrets
# (GitHub automatically masks known patterns, but custom secrets might leak)

# Check if secret masking is working
echo "Test: ${{ secrets.API_KEY }}"  # Should appear as ***

Credentials in .env files:

# Find .env files in repos
find . -name ".env" -o -name ".env.*" | xargs cat

# These should NEVER be committed
# Use .gitignore
echo ".env*" >> .gitignore

Automated Credential Scanning

Pre-commit hooks:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/trufflesecurity/trufflehog
    rev: v3.63.0
    hooks:
      - id: trufflehog
        name: TruffleHog
        entry: trufflehog filesystem
        language: system
        pass_filenames: false

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Secret scanning in CI/CD:

# GitHub Actions: Secret scanning
name: Security Scan
on: [push, pull_request]

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for scanning
      
      - name: Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Fail on secrets found
        if: failure()
        run: exit 1

Audit and Rotate Regularly

Quarterly IAM audit checklist:

## IAM Audit - Q1 2024

### Human Accounts
- [ ] Review all user accounts - deactivate unused
- [ ] Check last login dates - flag >90 days
- [ ] Audit MFA status - enforce for all
- [ ] Review admin access - justify or remove

### Service Accounts
- [ ] List all service accounts and their purpose
- [ ] Identify unused service accounts (no activity >90 days)
- [ ] Audit permissions - are they still appropriate?
- [ ] Rotate all static access keys

### Secrets and Keys
- [ ] Scan codebase for committed secrets
- [ ] Check developer workstations for credentials
- [ ] Audit secrets in CI/CD systems
- [ ] Verify secret rotation policies are working

### Third-Party Access
- [ ] Review OAuth tokens granted to apps
- [ ] Audit SaaS integrations (see section 8)
- [ ] Check for orphaned integrations
- [ ] Verify scopes are minimal

Real-World Impact

The Codecov breach (2021) occurred when attackers modified Codecov’s bash uploader to exfiltrate environment variables from customer CI/CD pipelines. This exposed thousands of secrets that customers had in their build environments.

Research by GitGuardian found that in 2022, 10 million secrets were exposed on GitHub, including API keys, credentials, and private keys.

Further reading:


5. Role Explosion Control

As organizations grow, IAM roles proliferate. Without discipline, you end up with hundreds of roles, many overlapping or unused.

The Problem

Symptoms of role explosion:

  • “We have 847 IAM roles and nobody knows what they all do”
  • Duplicate roles with slightly different names
  • Roles created for one-time tasks that never get deleted
  • Naming conventions that don’t reflect actual permissions

Naming Conventions That Track Purpose

Bad role names:

  • john-temp-role
  • new-role-2023
  • backend-team-role
  • role-1, role-2, role-3

Good role names (descriptive and purposeful):

  • production-webapp-deployer
  • staging-readonly-developer
  • backup-service-s3-writer
  • incident-response-elevated-access

Naming convention template:

{environment}-{service/component}-{access-level}

Examples:
- production-api-readonly
- staging-database-admin
- development-full-access
- production-lambda-executor

Consolidate Without Breaking Things

Process for role consolidation:

  1. Audit what exists:
# AWS: List all roles with last used date
aws iam list-roles --query 'Roles[*].[RoleName,CreateDate]' --output table

for role in $(aws iam list-roles --query 'Roles[*].RoleName' --output text); do
    echo "Role: $role"
    aws iam get-role --role-name "$role" --query 'Role.RoleLastUsed'
done
  1. Identify duplicates or overlapping permissions:
# Compare role policies to find duplicates
import boto3
import json

iam = boto3.client('iam')

def get_role_permissions(role_name):
    policies = []
    
    # Inline policies
    inline_policies = iam.list_role_policies(RoleName=role_name)
    for policy_name in inline_policies['PolicyNames']:
        policy = iam.get_role_policy(RoleName=role_name, PolicyName=policy_name)
        policies.append(policy['PolicyDocument'])
    
    # Attached policies
    attached = iam.list_attached_role_policies(RoleName=role_name)
    for policy in attached['AttachedPolicies']:
        policy_version = iam.get_policy_version(
            PolicyArn=policy['PolicyArn'],
            VersionId=iam.get_policy(PolicyArn=policy['PolicyArn'])['Policy']['DefaultVersionId']
        )
        policies.append(policy_version['PolicyVersion']['Document'])
    
    return policies

# Find roles with identical permissions
roles = iam.list_roles()['Roles']
permission_groups = {}

for role in roles:
    perms = json.dumps(get_role_permissions(role['RoleName']), sort_keys=True)
    if perms not in permission_groups:
        permission_groups[perms] = []
    permission_groups[perms].append(role['RoleName'])

# Show duplicates
for perms, role_list in permission_groups.items():
    if len(role_list) > 1:
        print(f"Duplicate permissions: {role_list}")
  1. Create consolidated roles with clear documentation:
# Infrastructure as Code: Well-documented consolidated role
resource "aws_iam_role" "webapp_production" {
  name        = "production-webapp-role"
  description = "Production web application - read DB, write S3, invoke Lambda"
  
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })
  
  tags = {
    Purpose     = "Production webapp execution"
    Owner       = "platform-team"
    CreatedDate = "2024-01-15"
    ReviewDate  = "2024-07-15"
    Replaces    = "old-webapp-role,webapp-role-v2,legacy-app-role"
  }
}
  1. Migrate gradually:
# Parallel run: Use new role alongside old for 30 days
# Monitor for any issues
# Then deprecate old roles

# Mark as deprecated
aws iam tag-role --role-name old-webapp-role --tags Key=Status,Value=DEPRECATED

# After validation period, delete
aws iam delete-role --role-name old-webapp-role

Regular Cleanup

# Automated cleanup: Delete roles unused for 180 days
#!/bin/bash

THRESHOLD_DAYS=180
THRESHOLD_DATE=$(date -d "$THRESHOLD_DAYS days ago" +%Y-%m-%d)

aws iam list-roles --query 'Roles[*].[RoleName]' --output text | while read role; do
    LAST_USED=$(aws iam get-role --role-name "$role" --query 'Role.RoleLastUsed.LastUsedDate' --output text)
    
    if [[ "$LAST_USED" < "$THRESHOLD_DATE" ]] || [[ "$LAST_USED" == "None" ]]; then
        echo "Role $role unused since $LAST_USED - candidate for deletion"
        # Add approval workflow before actual deletion
    fi
done

Real-World Impact

Organizations with poor role management often discover during audits that:

  • 40-60% of roles are unused
  • Multiple roles grant the same permissions
  • Nobody can explain what many roles are for
  • Compliance audits take weeks because of role complexity

Further reading:


6. Federated Identity Risks

SSO and federated identity are convenient but introduce new attack vectors. A compromised identity provider can grant access to everything.

Audit All SSO Providers and SaaS Integrations

Discovery:

# Google Workspace: List all connected apps
# Admin console → Security → API Controls → Manage Third-Party App Access

# Azure AD: List service principals (OAuth apps)
az ad sp list --all --query '[].{Name:displayName, AppId:appId, Created:createdDateTime}' --output table

# Okta: List applications via API
curl -X GET "https://your-domain.okta.com/api/v1/apps" \
  -H "Authorization: SSWS ${OKTA_API_TOKEN}"

What to look for:

  • Apps you don’t recognize
  • Apps last used >180 days ago
  • Apps with overly broad scopes
  • Apps from untrusted publishers

Token Lifespan Issues

The problem: Many OAuth tokens don’t expire, creating permanent access.

Check token expiration:

# Example: Decode JWT to check expiration
import jwt
from datetime import datetime

def check_token_expiry(token):
    try:
        decoded = jwt.decode(token, options={"verify_signature": False})
        exp = decoded.get('exp')
        if exp:
            exp_date = datetime.fromtimestamp(exp)
            print(f"Token expires: {exp_date}")
            if exp_date.year > 2030:
                print("WARNING: Token has very long expiration!")
        else:
            print("WARNING: Token has no expiration!")
        return decoded
    except Exception as e:
        print(f"Error decoding token: {e}")

Enforce token rotation:

# OAuth configuration: Short-lived tokens
oauth:
  access_token_lifetime: 3600  # 1 hour
  refresh_token_lifetime: 2592000  # 30 days
  refresh_token_rotation: true  # Rotate on each use
  refresh_token_reuse_interval: 0  # No reuse

Enforce MFA Even Through Federated Systems

The risk: Phishing-resistant MFA at the IdP level doesn’t mean individual apps enforce it.

Azure AD Conditional Access:

{
  "displayName": "Require MFA for all users",
  "state": "enabled",
  "conditions": {
    "users": {
      "includeUsers": ["All"]
    },
    "applications": {
      "includeApplications": ["All"]
    }
  },
  "grantControls": {
    "operator": "AND",
    "builtInControls": ["mfa"]
  }
}

Okta: Enforce MFA policy:

# Okta Sign-On Policy
- name: Require MFA for sensitive apps
  priority: 1
  conditions:
    - app_in: ["AWS", "GitHub", "Production Database"]
  actions:
    - require_factor: ["push", "totp"]
    - session_lifetime: 4h

Disable Unnecessary SCIM/Provisioning APIs

SCIM (System for Cross-domain Identity Management) allows automatic user provisioning but also automatic access grants.

Risks:

  • Overly permissive default roles
  • Auto-provisioning creates accounts you don’t track
  • Deprovisioning delays or failures
  • SCIM tokens with broad access

Audit SCIM integrations:

# Check what apps have SCIM enabled
# Most IdPs have admin UI for this

# Questions to ask:
# - Do we need automatic provisioning?
# - What role is assigned by default?
# - How quickly does deprovisioning work?
# - Who has access to SCIM tokens?

Best practice: Manual provisioning for critical systems, automatic for low-risk apps.

Real-World Impact

The 2023 LastPass breach was partially enabled by attackers accessing a cloud storage bucket through federated access to an employee’s personal cloud account that was linked to corporate resources.

The The Uber breach exposed 57 million users’ data partly because AWS keys were found in a GitHub repo that was accessible via SSO-integrated account.

Further reading:


7. Secret Lifecycle ASR

Secrets (passwords, API keys, tokens, certificates) require active lifecycle management. Creation, rotation, expiration, and deletion must all be deliberate.

Rotate, Scope, and Expire Secrets

Default policy for all secrets:

  • Maximum lifetime: 90 days
  • Automatic rotation where possible
  • Scope to minimum necessary
  • Monitor all usage
  • Alert on anomalies

Implementation with AWS Secrets Manager:

import boto3
import json

secrets_client = boto3.client('secretsmanager')

# Create secret with automatic rotation
secrets_client.create_secret(
    Name='production/database/password',
    Description='Production database credentials - rotates every 30 days',
    SecretString=json.dumps({
        'username': 'db_user',
        'password': generate_secure_password()
    }),
    Tags=[
        {'Key': 'Environment', 'Value': 'production'},
        {'Key': 'RotationSchedule', 'Value': '30days'},
        {'Key': 'Owner', 'Value': 'platform-team'}
    ]
)

# Enable automatic rotation
secrets_client.rotate_secret(
    SecretId='production/database/password',
    RotationLambdaARN='arn:aws:lambda:us-east-1:123456789012:function:SecretsManagerRotation',
    RotationRules={
        'AutomaticallyAfterDays': 30
    }
)

HashiCorp Vault: Dynamic secrets:

# Dynamic database credentials (generated on-demand, expire automatically)
vault write database/config/my-postgresql-database \
    plugin_name=postgresql-database-plugin \
    allowed_roles="readonly,readwrite" \
    connection_url="postgresql://{{username}}:{{password}}@postgres:5432/mydb"

# Create role that generates 1-hour credentials
vault write database/roles/readonly \
    db_name=my-postgresql-database \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
    default_ttl="1h" \
    max_ttl="24h"

# Application requests credentials
vault read database/creds/readonly
# Returns: username=v-token-readonly-xyz, password=..., lease_duration=3600

Don’t Keep Secrets in Repos

Prevention is better than detection:

# .gitignore for secrets
# Add these patterns

.env
.env.*
*.pem
*.key
*.p12
*.pfx
secrets.yaml
credentials.json
config/secrets/*

# Also ignore common secret directories
.aws/
.kube/config
.ssh/id_*

Git hooks to prevent commits:

#!/bin/bash
# .git/hooks/pre-commit

# Check for common secret patterns
if git diff --cached | grep -E "(password|api_key|secret|token)\s*=\s*['\"]?[^'\"]+['\"]?" ; then
    echo "ERROR: Potential secret detected in commit"
    echo "Please use environment variables or secret management"
    exit 1
fi

# Check for file patterns
if git diff --cached --name-only | grep -E "\.(pem|key|p12)$" ; then
    echo "ERROR: Private key file in commit"
    exit 1
fi

Avoid Secrets in CI/CD Unless Ephemeral

Bad practice: Storing long-lived secrets in CI/CD

# DON'T DO THIS
env:
  AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
  AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Good practice: Use OIDC or temporary credentials

# GitHub Actions: OIDC (no static secrets)
- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
    role-session-name: github-actions-${{ github.run_id }}
    aws-region: us-east-1

# GitLab CI: Vault integration (dynamic secrets)
deploy:
  script:
    - export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=gitlab-ci jwt=$CI_JOB_JWT)
    - export DB_PASS=$(vault read -field=password database/creds/myapp)
    - ./deploy.sh

Real-World Impact

The Travis CI 2021 security incident exposed environment variables and secrets from thousands of open source projects due to a configuration issue.

Research shows that hardcoded credentials are found in ~6% of all GitHub commits, often leading to cloud account compromises.

Further reading:


8. SaaS and Vendor Identity Exposure

Every SaaS integration is a new surface that can impersonate, access, or misconfigure your internal systems. Treat third-party OAuth permissions like you treat production database access — with extreme caution.

Review OAuth Applications

GitHub OAuth audit:

# List authorized OAuth apps for your org
gh api orgs/{org}/installations --paginate

# For personal account
gh api /user/installations --paginate

# What to look for:
# - Apps you don't recognize
# - Apps with full repo access (instead of specific repos)
# - Apps from unknown developers
# - Apps not used in 6+ months

Google Workspace connected apps:

# Via GAM (Google Workspace CLI)
gam all users show tokens

# Check for:
# - Broad OAuth scopes (Gmail read/write, Drive full access)
# - Apps connected to many users
# - Consumer apps connected to corporate accounts

Revoke Stale or Unused Tokens

Automated OAuth cleanup:

# Example: Revoke GitHub OAuth tokens not used in 90 days
import requests
from datetime import datetime, timedelta

GITHUB_TOKEN = "your_admin_token"
ORG = "your_org"

headers = {"Authorization": f"token {GITHUB_TOKEN}"}

# Get all OAuth applications
installs = requests.get(f"https://api.github.com/orgs/{ORG}/installations", headers=headers).json()

threshold = datetime.now() - timedelta(days=90)

for install in installs:
    last_used = install.get('last_used_at')
    if last_used:
        last_used_date = datetime.strptime(last_used, '%Y-%m-%dT%H:%M:%SZ')
        if last_used_date < threshold:
            app_id = install['id']
            print(f"Revoking unused app: {install['app_slug']} (last used: {last_used})")
            requests.delete(f"https://api.github.com/app/installations/{app_id}", headers=headers)

Audit Scopes

The problem: Most apps request far more permissions than they need.

Example - GitHub App requesting excessive scopes:

# App requests:
permissions:
  contents: write  # Full repo write access
  pull_requests: write
  issues: write
  metadata: read
  
# App actually needs:
permissions:
  contents: read  # Just read code
  issues: write  # Create issues only

Google OAuth scope review:

// Bad: Requesting everything
scopes: [
  'https://www.googleapis.com/auth/drive',  // Full Drive access
  'https://www.googleapis.com/auth/gmail.modify'  // Full Gmail access
]

// Good: Minimal scopes
scopes: [
  'https://www.googleapis.com/auth/drive.file',  // Only files created by app
  'https://www.googleapis.com/auth/gmail.send'  // Send only, no read
]

Audit process:

  1. List all OAuth apps with their scopes
  2. For each app, determine actual minimum needed scopes
  3. Request scope reduction from vendor or reconfigure
  4. Revoke and reinstall with narrower scopes

SCIM and Auto-Provisioning Risks

Shadow users from auto-provisioning:

# Find users created by SCIM that you don't recognize
# AWS: Check for users with specific tag
aws iam list-users --query 'Users[*].[UserName,CreateDate,Tags]' | \
  grep "Source: SCIM"

# Ask: Should all these users still have access?

Disable auto-provisioning for critical systems:

# Okta Application Settings
provisioning:
  to_app:
    enabled: false  # No automatic user creation
  to_okta:
    enabled: false  # No automatic updates from app
    
authentication:
  type: SAML
  sso_url: https://app.example.com/saml/sso
  
# Require manual approval for access
assignments:
  auto_assign: false
  require_approval: true
  approvers: ["security-team"]

Monitor What SaaS Systems Create

Audit log monitoring:

# Example: Monitor unexpected OAuth grants in Azure AD
from azure.identity import DefaultAzureCredential
from azure.mgmt.authorization import AuthorizationManagementClient
from datetime import datetime, timedelta

credential = DefaultAzureCredential()
auth_client = AuthorizationManagementClient(credential, subscription_id)

# Check for new OAuth consent grants
threshold = datetime.now() - timedelta(days=7)

# Alert on new grants
for grant in auth_client.permissions.list():
    if grant.created_time > threshold:
        print(f"New OAuth consent: {grant.client_id} for {grant.principal_id}")
        print(f"Scopes: {grant.scope}")
        # Send to SIEM or alert

Real-World Impact

The 2022 Slack OAuth token breach exposed customer data when attackers stole OAuth tokens from a GitHub repository. Those tokens had broad permissions to Slack workspaces.

The 2020 Microsoft OAuth attack used stolen OAuth tokens to access emails and other resources without needing passwords or MFA.

Further reading:


9. Guidelines for IAM ASR

IAM Problem ASR Practice Implementation
Too many users Offboard aggressively, automate cleanup 90-day inactive = auto-disable; quarterly audits
Static secrets Use short-lived tokens or expiring credentials STS AssumeRole; OIDC; workload identity federation
Over-permissioned roles Audit and scope all privileges Permission boundaries; explicit deny policies; regular reviews
Shadow access Inventory credentials, keys, and tokens Automated scanning; pre-commit hooks; secret rotation
Role sprawl Consolidate by purpose, not org chart Naming conventions; regular cleanup; IaC with documentation
SaaS OAuth creep Revoke unused integrations, audit scopes Quarterly reviews; scope minimization; approval workflows
CI jobs with full access Split responsibilities and restrict to minimal permissions OIDC for CI/CD; ephemeral credentials; scoped roles

10. Final Thought

IAM is your real perimeter. Everything else is an illusion.

By practicing IAM ASR, you don’t just prevent breaches — you limit their scale, accelerate incident response, and build defensible systems that survive human mistakes.

Make it a habit:

  • Audit: Quarterly review of all identities and permissions
  • Remove: Aggressively delete unused accounts and over-permissioned roles
  • Reduce: Scope every permission to minimum necessary
  • Rotate: Short-lived credentials and automated rotation

And above all, treat access as radioactive — safe only when properly contained, dangerous when ignored.

Start tomorrow:

  1. Find your oldest unused service account
  2. Check what permissions it has
  3. If it’s not been used in 90 days, delete it
  4. Repeat daily until you’ve audited everything

Every identity removed is an attack path closed. Every permission scoped is a blast radius reduced. Every secret rotated is a window of vulnerability narrowed.

Less access = less risk. That’s IAM ASR.