Both AWS and Azure will let you deploy insecure infrastructure at scale—they just make you fail in different ways. The real question isn't which cloud is "more secure" (neither is, by default), but which security model is harder for your team to screw up when you're shipping code at 2 AM.
IAM: Where Most Breaches Actually Start
Let's get real: most cloud breaches don't exploit zero-days. They abuse overly permissive IAM policies that some DevOps engineer copy-pasted from Stack Overflow at 3 PM on a Friday.
AWS IAM: JSON Hell
AWS IAM policies are JSON documents that control who can do what. They're powerful, flexible, and a security nightmare when misused.
Here's what happens when you Google "S3 bucket policy" and paste without reading:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}
Congrats, you just gave someone God mode on every S3 bucket in your account. I've seen this in production at companies you've heard of.
What least privilege actually looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::prod-app-uploads/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
}
}
}
]
}
This restricts actions to specific objects in one bucket and requires encryption. Still too permissive for most use cases, but at least you're thinking.
The AWS IAM gotcha nobody tells you about:
Policy evaluation is Byzantine. You've got:
- Identity-based policies (attached to users/roles)
- Resource-based policies (attached to S3, SQS, etc.)
- Permission boundaries (limits for delegated admins)
- Service Control Policies (organization-wide deny rules)
- Session policies (temporary credential restrictions)
All of these interact. An explicit deny anywhere beats allows everywhere. The IAM policy simulator exists because humans can't mentally compute the result.
Azure RBAC: Role-Based Simplicity (Until It Isn't)
Azure uses role assignments at different scopes: management group, subscription, resource group, or individual resource. You assign a role to a principal (user, group, managed identity) at a scope.
# Grant least privilege: Storage Blob Data Contributor at specific container
az role assignment create \
--assignee-object-id <managed-identity-id> \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/<sub-id>/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/prodstore/blobServices/default/containers/uploads"
The good: This is conceptually cleaner than AWS policy documents. Fewer "wait, what does this actually allow?" moments.
The bad: Built-in roles are often too broad. "Contributor" sounds reasonable until you realize it grants nearly full control over resources. Custom roles exist but require maintenance.
Azure's killer feature for DevOps teams: Conditional Access
You can require MFA, device compliance, or specific network locations before granting access:
# This requires CLI + Azure AD policies, but the concept:
# "Storage Blob Data Contributor" only from company VPN with MFA
AWS has no native equivalent for this level of contextual access control. You can build it with Lambda authorizers and custom logic, but Azure ships it.
Service Accounts: Where CI/CD Pipelines Go Wrong
AWS: IAM roles for EC2, ECS, Lambda. Attach a role to a compute resource, it gets temporary credentials via the instance metadata service.
Common mistake:
# Using IMDSv1 (the old, vulnerable version)
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name
IMDSv1 is vulnerable to SSRF attacks. If an attacker can make your application send HTTP requests to arbitrary URLs, they can steal credentials from the metadata service.
Fix: Enforce IMDSv2 (requires PUT request with TTL header):
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
Azure: Managed identities (system-assigned or user-assigned). Similar concept, different implementation.
# Enable system-assigned managed identity on VM
az vm identity assign \
--name prod-app-vm \
--resource-group prod-rg
Azure's metadata service has similar SSRF risks but requires a secret header (Metadata: true) which slightly raises the bar. Don't rely on this—validate and sanitize any user input that becomes part of URLs.
Network Segmentation: Because Flat Networks Are For 1995
AWS VPCs and Security Groups
Security groups in AWS are stateful firewalls. You define inbound and outbound rules. Return traffic is automatically allowed.
What most teams do (wrong):
# Allow SSH from anywhere
aws ec2 authorize-security-group-ingress \
--group-id sg-0123456789abcdef0 \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0
This shows up in every Shodan scan. Don't do this unless you want your SSH logs filled with Chinese botnets trying admin/admin.
What you should do:
# Allow SSH only from bastion host security group
aws ec2 authorize-security-group-ingress \
--group-id sg-web-tier \
--protocol tcp \
--port 22 \
--source-group sg-bastion
# Web tier allows HTTPS from load balancer only
aws ec2 authorize-security-group-ingress \
--group-id sg-web-tier \
--protocol tcp \
--port 443 \
--source-group sg-load-balancer
Security group chaining is how you build defense in depth. Load balancer talks to web tier, web tier talks to app tier, app tier talks to database. Nothing else.
The thing AWS gets wrong: default egress rules
By default, security groups allow all outbound traffic. Your compromised web server can beacon to command-and-control infrastructure all day.
Lock it down:
# Remove default allow-all egress
aws ec2 revoke-security-group-egress \
--group-id sg-0123456789abcdef0 \
--ip-permissions '[{"IpProtocol": "-1", "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]'
# Add explicit allows for DNS, NTP, package repos
aws ec2 authorize-security-group-egress \
--group-id sg-0123456789abcdef0 \
--protocol udp \
--port 53 \
--cidr 10.0.0.2/32 # Internal DNS
Most teams won't do this because it breaks stuff initially. But that's the point—you learn what your app actually needs instead of giving it the keys to the kingdom.
Azure NSGs and Application Security Groups
Network Security Groups (NSGs) in Azure are similar to AWS security groups but with priority-based evaluation. Lower priority number = evaluated first.
# Block all inbound by default (priority 4096)
az network nsg rule create \
--resource-group prod-rg \
--nsg-name prod-nsg \
--name deny-all-inbound \
--priority 4096 \
--access Deny \
--protocol '*' \
--destination-port-ranges '*'
# Allow HTTPS from specific IPs (priority 100)
az network nsg rule create \
--resource-group prod-rg \
--nsg-name prod-nsg \
--name allow-https \
--priority 100 \
--source-address-prefixes 203.0.113.0/24 \
--destination-port-ranges 443 \
--access Allow \
--protocol Tcp
Application Security Groups (ASGs) are cleaner than AWS security group chaining:
# Create ASGs for logical tiers
az network asg create --name web-tier-asg --resource-group prod-rg
az network asg create --name db-tier-asg --resource-group prod-rg
# Assign VMs to ASGs
az network nic ip-config update \
--resource-group prod-rg \
--nic-name web-vm-nic \
--name ipconfig1 \
--application-security-groups web-tier-asg
# NSG rules reference ASGs, not IP ranges
az network nsg rule create \
--nsg-name prod-nsg \
--name allow-web-to-db \
--priority 110 \
--source-asgs web-tier-asg \
--destination-asgs db-tier-asg \
--destination-port-ranges 5432 \
--access Allow \
--protocol Tcp
As your infrastructure scales, ASGs scale with it. No updating IP ranges in security rules.
Storage Encryption: What's Actually Happening
AWS S3: Encryption Off by Default (Still!)
Despite years of breaches from unencrypted S3 buckets, AWS still doesn't enable encryption by default on new buckets. You have to opt in.
# Enable default encryption with AWS-managed keys
aws s3api put-bucket-encryption \
--bucket prod-data \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": true
}]
}'
BucketKeyEnabled reduces KMS API calls and costs if you're using customer-managed keys.
For actually sensitive data, use customer-managed KMS keys:
aws s3api put-bucket-encryption \
--bucket prod-secrets \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
},
"BucketKeyEnabled": true
}]
}'
This lets you control key rotation, access policies, and audit logs. AWS-managed keys are opaque—you don't control rotation or access.
Azure Storage: Encrypted by Default (Finally)
Azure encrypts storage accounts by default using Microsoft-managed keys. This is the right default. You can switch to customer-managed keys if you need control:
# Create Key Vault and key
az keyvault create --name prod-keyvault --resource-group prod-rg --location eastus
az keyvault key create --vault-name prod-keyvault --name storage-key --kty RSA --size 2048
# Update storage account to use customer-managed key
az storage account update \
--name prodstorageacct \
--resource-group prod-rg \
--encryption-key-source Microsoft.Keyvault \
--encryption-key-vault https://prod-keyvault.vault.azure.net \
--encryption-key-name storage-key
The gotcha: Key Vault access policies. If you lock yourself out of the Key Vault, you lose access to your encrypted data. Test your disaster recovery process before you need it.
Secrets Management: Stop Hardcoding Credentials
AWS Secrets Manager vs. Parameter Store
Secrets Manager is purpose-built for secrets with automatic rotation. Parameter Store is cheaper and simpler but less featured.
import boto3
import json
client = boto3.client('secretsmanager', region_name='us-east-1')
# Retrieve database credentials
response = client.get_secret_value(SecretId='prod/db/credentials')
secret = json.loads(response['SecretString'])
# Use the credentials
db_username = secret['username']
db_password = secret['password']
Set up automatic rotation for RDS credentials:
aws secretsmanager rotate-secret \
--secret-id prod/db/credentials \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:SecretsManagerRDSRotation \
--rotation-rules AutomaticallyAfterDays=30
Azure Key Vault: Centralized Secrets with RBAC
Key Vault stores secrets, keys, and certificates. Access control uses Azure RBAC or Key Vault access policies.
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
credential = DefaultAzureCredential()
client = SecretClient(vault_url="https://prod-keyvault.vault.azure.net", credential=credential)
# Retrieve secret
secret = client.get_secret("db-password")
db_password = secret.value
The win here is managed identities. Your application running on Azure VM or App Service gets credentials automatically without storing anything:
# App Service connects to Key Vault using managed identity
# No credentials in code, config, or environment variables
Audit Logging: CYA When Things Go Wrong
AWS CloudTrail: Log Everything
CloudTrail logs every API call in your AWS account. Enable it immediately, send logs to a separate security account that developers can't touch.
aws cloudtrail create-trail \
--name org-audit-trail \
--s3-bucket-name central-audit-logs \
--is-multi-region-trail \
--enable-log-file-validation \
--is-organization-trail
--enable-log-file-validation signs log files so you can detect tampering. --is-organization-trail logs all accounts in your AWS Organization.
Set up alerts for suspicious activity:
# CloudWatch Logs metric filter for root account usage
aws logs put-metric-filter \
--log-group-name CloudTrail/DefaultLogGroup \
--filter-name RootAccountUsage \
--filter-pattern '{ $.userIdentity.type = "Root" }' \
--metric-transformations \
metricName=RootAccountUsageCount,metricNamespace=CloudTrailMetrics,metricValue=1
# Alarm when root account is used
aws cloudwatch put-metric-alarm \
--alarm-name RootAccountUsage \
--alarm-description "Alert on root account usage" \
--metric-name RootAccountUsageCount \
--namespace CloudTrailMetrics \
--statistic Sum \
--period 60 \
--evaluation-periods 1 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold
Azure Activity Log: Enabled by Default, Still Needs Configuration
Activity Log captures subscription-level events automatically. Export to Log Analytics workspace for long-term retention and querying:
az monitor diagnostic-settings create \
--name export-activity-log \
--resource /subscriptions/abc-123 \
--logs '[{"category": "Administrative", "enabled": true}, {"category": "Security", "enabled": true}]' \
--workspace /subscriptions/abc-123/resourceGroups/monitoring/providers/Microsoft.OperationalInsights/workspaces/prod-logs
Query logs with KQL:
AzureActivity
| where Caller contains "suspicious-user"
| where OperationNameValue contains "delete"
| project TimeGenerated, Caller, OperationNameValue, ResourceGroup
| order by TimeGenerated desc
DevOps-Specific Security Wins and Fails
CI/CD Pipeline Credentials: The Weakest Link
AWS: Use OIDC federation for GitHub Actions, GitLab CI, etc. No long-lived credentials.
# GitHub Actions workflow
- 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
The GitHub Actions runner gets temporary credentials via OIDC. No secrets in GitHub, nothing to leak.
Azure: Similar pattern with federated credentials for managed identities:
# GitHub Actions workflow
- name: Azure Login
uses: azure/login@v1
with:
client-id: }
tenant-id: }
subscription-id: }
Both approaches eliminate long-lived secrets in CI/CD. If you're still using IAM user access keys or service principal passwords in your pipelines, you're doing it wrong.
Infrastructure as Code: Where Security Should Start
AWS with Terraform:
# Enable S3 bucket encryption by default via policy
resource "aws_s3_bucket_public_access_block" "prod_data" {
bucket = aws_s3_bucket.prod_data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "prod_data" {
bucket = aws_s3_bucket.prod_data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
Azure with Bicep:
resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = {
name: 'prodstorageacct'
location: resourceGroup().location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
networkAcls: {
defaultAction: 'Deny'
virtualNetworkRules: [
{
id: vnetSubnet.id
}
]
}
}
}
Codifying security in IaC means it's repeatable, reviewable, and testable. Manual console clicks are not.
The Bottom Line for DevOps Teams
Neither AWS nor Azure is inherently more secure. Both require competent configuration to avoid being pwned.
Choose AWS if:
- You need granular IAM policy control and can handle the complexity
- Your team has deep AWS expertise
- You're already invested in AWS tooling (GuardDuty, Security Hub)
Choose Azure if:
- You prefer role-based access over policy documents
- Your team uses Microsoft tooling (Azure AD, Entra)
- Conditional access and managed identities fit your use case
Choose both if:
- You hate simplicity and enjoy maximum pain
Actually secure your cloud by:
- Enabling audit logging everywhere (CloudTrail, Activity Log)
- Using least privilege IAM/RBAC from day one
- Locking down network egress, not just ingress
- Encrypting everything with customer-managed keys for sensitive data
- Eliminating long-lived credentials in favor of OIDC/managed identities
- Codifying security in IaC and requiring reviews
The cloud providers give you tools. They don't give you judgment, threat models, or incident response plans. Your job as a DevOps team is to understand the threat landscape, configure defenses appropriately, and have a plan for when (not if) something breaks.
And for the love of all that's holy, stop copy-pasting IAM policies from the internet without reading them. That's how you end up in the next Capital One breach report.