“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:
- AWS IAM Best Practices
- NIST SP 800-53: Access Control
- CIS AWS Foundations Benchmark
- Azure RBAC Best Practices
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:
- AWS IAM Identity Center (SSO) with time-boxed permissions
- Azure Privileged Identity Management (PIM)
- GCP IAM Conditions with time-based access
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:
- Just-in-Time Access - Google BeyondCorp
- Privileged Access Management Best Practices - NIST
- CIS Control 6: Access Control Management
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:
- Workload Identity Federation - Google Cloud
- OIDC for GitHub Actions
- Service Account Best Practices - Kubernetes
- AWS Security Token Service (STS)
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:
- Gitleaks - Secret Scanning
- TruffleHog - Find Credentials
- AWS IAM Access Analyzer
- GitHub Secret Scanning
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-rolenew-role-2023backend-team-rolerole-1,role-2,role-3
Good role names (descriptive and purposeful):
production-webapp-deployerstaging-readonly-developerbackup-service-s3-writerincident-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:
- 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
- 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}")
- 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"
}
}
- 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:
- OAuth 2.0 Security Best Practices
- SAML Security Cheat Sheet - OWASP
- Azure AD Conditional Access
- Okta Security Recommendations
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:
- AWS Secrets Manager Best Practices
- HashiCorp Vault Documentation
- Google Secret Manager
- OWASP: Cryptographic Storage Cheat Sheet
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:
- List all OAuth apps with their scopes
- For each app, determine actual minimum needed scopes
- Request scope reduction from vendor or reconfigure
- 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:
- OAuth 2.0 Threat Model - RFC 6819
- GitHub OAuth Best Practices
- Google OAuth 2.0 Scopes
- OWASP: OAuth Security Cheat Sheet
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:
- Find your oldest unused service account
- Check what permissions it has
- If it’s not been used in 90 days, delete it
- 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.