HACKADEMICS

Cloud Security Configuration Showdown: AWS vs. Azure for DevOps Teams

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

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:

  1. Enabling audit logging everywhere (CloudTrail, Activity Log)
  2. Using least privilege IAM/RBAC from day one
  3. Locking down network egress, not just ingress
  4. Encrypting everything with customer-managed keys for sensitive data
  5. Eliminating long-lived credentials in favor of OIDC/managed identities
  6. 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.