In short

Run workloads under platform identities (IAM roles, managed identities, attached service accounts), federate CI, and block new long-lived keys by policy. Require IMDSv2 in the account default and every launch template. Block public storage at account or organization level. Export audit logs: CloudTrail organization trail, Azure Activity Log diagnostic setting, Google aggregated sink, plus data-access logs for sensitive stores. Close inbound SSH and RDP. Keep secrets in Secrets Manager, Key Vault or Secret Manager.

  • Give each workload its own platform identity and federate CI, then block new keys with an SCP, an Entra app management policy or iam.managed.disableServiceAccountKeyCreation.
  • Launch settings beat the AWS account default, so require IMDSv2 in every launch template too and keep the hop limit at 1 unless a container needs IMDS.
  • Azure and Google metadata endpoints rely on a fixed request header, which stops only SSRF bugs that control just the URL; fix the SSRF and keep the identity's permissions small.
  • Block public storage at the highest level: S3 Block Public Access for the account or organization, AllowBlobPublicAccess and Shared Key off, storage.publicAccessPrevention enforced.
  • Export audit logs before you need them: CloudTrail event history and the Azure Activity Log keep 90 days, and data-plane logs are off by default on all three clouds.
  • Tools can disagree with the platform: azurerm before 5.0 defaulted allow_nested_items_to_be_public to true, and Checkov 3.3.26 ignores azurerm's newer public_network_access argument.

Verify it

  1. Run the read-only checks below with a read-only role in every account, subscription and project, and in every AWS Region you use; schedule them and alert on changes.
  2. On a test EC2 instance, call IMDS without a token and expect 401, then watch the MetadataNoTokenRejected metric after requiring IMDSv2.
  3. In a sandbox, try to create a service account key, an S3 bucket policy that grants Principal "*", and an anonymous blob container; each must be refused.
  4. Make a harmless IAM change and find it in the CloudTrail archive, the Log Analytics workspace and the Google aggregated sink, and run aws cloudtrail validate-logs on a recent range.
  5. Run terraform test with mock providers and an IaC scanner on every change, and check each scanner rule against the current provider documentation before acting on it.
Applies to
  • Terraform 1.7 or later for mock-provider tests (tested 1.16.5)
  • hashicorp/aws provider 6.0 or later for the per-resource region argument (tested 6.68.0)
  • hashicorp/azurerm provider 5.5 or later for public_network_access (tested 5.9.0)
  • hashicorp/google provider Tested 8.6.0
  • Checkov 3.3.26

Workloads and accounts on AWS, Azure and Google Cloud. Provider defaults change over time, so check each control against the current account and policy state.

Part of
Workflows: Fix a finding, Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 44 minutes: 13 to read and 31 for the exercise

What you will learn

  • Choose the identity, metadata, storage, encryption, logging, network and secrets settings for each provider.
  • Enforce them at organization, account and resource level with Terraform.
  • Test the Terraform offline and check live accounts with read-only commands.
  • Recognize provider, tool and scanner defaults that differ from the platform's.

Before you start

Developers, platform engineers and AppSec reviewers who create or review AWS accounts, Azure subscriptions and Google Cloud projects

  • Read Terraform and basic IAM policies.
  • Know how your workloads authenticate to their cloud provider.
  • For the live checks, a read-only role in a sandbox account, subscription or project.
Go to the guided practice

Enforce the baseline at three levels

A cloud baseline is the short list of settings every account, subscription and project needs before an application lands in it. The failures it prevents need no clever exploit: a leaked long-lived key, a server-side request forgery (SSRF) bug that reads role credentials from the metadata endpoint, a bucket policy that grants access to everyone, or logs that were never exported.

Set each control at the highest level the provider offers, because resource settings drift and new accounts start empty: organization guardrails first (AWS service control and declarative policies, Azure Policy on management groups, Google Cloud organization policies), then account defaults, then resources in Terraform. For auditors, the CIS Foundations Benchmarks cover the same ground: AWS v7.1.0, Azure v6.0.0 and Google Cloud v5.0.0. The VM, node and container images you run in those accounts need their own benchmark: see /domains/runtime/containers/image-hardening-benchmarks/.

The same controls on each provider

The names differ but the controls line up. Defaults are as of October 2026.

The same controls on each provider
ControlAWSAzureGoogle Cloud
Workload identityIAM role: instance profile, task or execution role, EKS Pod IdentityManaged identity, user-assigned where lifecycles differAttached service account; Workload Identity Federation for GKE
CI and other cloudsOIDC provider and AssumeRoleWithWebIdentityFederated identity credentialWorkload Identity Federation
Stop new long-lived keysSCP that denies iam:CreateUser and iam:CreateAccessKeyApp management policy that blocks password additioniam.managed.disableServiceAccountKeyCreation
Human accessIAM Identity Center; MFA required on every root userMFA enforced for portal, CLI, PowerShell and IaC toolsConsole 2-step verification being phased in; impersonate service accounts, no key files
Metadata endpointIMDSv2: PUT for a session token, IP TTL 1Metadata: true header; MSP signing is opt-inMetadata-Flavor: Google header
Public storageS3 Block Public Access for the account or organizationAllowBlobPublicAccess and AllowSharedKeyAccess falsestorage.publicAccessPrevention; uniform bucket-level access
Encryption at restSSE-S3 on all new objects; EBS default is per-Region opt-inAlways on; temp disks need encryption at hostAlways on, AES-256
Control-plane audit logEvent history 90 days; add an organization trailActivity Log 90 days; add a diagnostic settingAdmin Activity always on, 400 days
Data-plane audit logCloudTrail data events, off by defaultResource logs, off until a diagnostic setting existsData Access logs, off except BigQuery
Inbound admin accessSession Manager, no open port; VPC Block Public AccessAzure Bastion or private access; no public IPs on VMsIAP TCP forwarding; skip the default network
SecretsSecrets Manager; RDS-managed master passwordsKey Vault with RBACSecret Manager

Replace keys with platform identities

Every compute service on the three clouds can run code under an identity whose credentials the platform issues and rotates: an IAM role, a managed identity or an attached service account. The SDKs' default credential chains find them, so the code holds no secret. Give each workload its own identity with only the actions and resources it uses; the cloud IAM guide at /domains/runtime/cloud-posture/cloud-iam-trust/ covers reviewing those grants.

CI systems and workloads elsewhere federate instead: the job exchanges an OIDC token for short-lived credentials, as the GitHub Actions guide at /domains/supply-chain/ci-cd/github-actions-hardening/ shows. Then close the old path. Google's iam.managed.disableServiceAccountKeyCreation constraint blocks new service account keys and Cloud Storage HMAC keys; an SCP that denies iam:CreateAccessKey does the same for AWS IAM users; a Microsoft Entra app management policy with the passwordAddition restriction blocks new client secrets.

Humans sign in through federation with MFA. AWS requires MFA on every root user, and centralized root access lets you delete root credentials in member accounts. On 1 October 2025 Azure began enforcing MFA for create, update and delete operations from the CLI, PowerShell, REST API and IaC tools. Workload identities are exempt, one more reason to move automation off user accounts. Google Cloud is phasing in 2-step verification for console sign-in.

Treat the metadata endpoint as a credential store

A VM fetches its identity's tokens from a link-local HTTP endpoint at 169.254.169.254, so anything that can make it send a request, including an SSRF bug, can try to read them. AWS IMDSv2 opens a session with a PUT that returns a token, valid for up to six hours, which every read must carry. It refuses token requests that include X-Forwarded-For, and the token response has an IP TTL of 1, so it is dropped before it can leave an instance misconfigured as a router, NAT device or VPN.

Azure and Google require a fixed header (Metadata: true, Metadata-Flavor: Google) and reject requests carrying X-Forwarded-For. That stops SSRF bugs that control only the URL. AWS found that many SSRF bugs also let the attacker set headers, and that the PUT plus session token blocks the large majority of them. Azure's Metadata Security Protocol adds request signing by an in-guest agent, but it is off by default. None of this replaces fixing the SSRF, which the SSRF guide at /domains/appsec/secure-features/ssrf-egress-boundaries/ covers.

metadata-requests.shShell
# AWS, IMDSv2: open a session with PUT, then send the token with every read.
TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \
  "http://169.254.169.254/latest/meta-data/iam/security-credentials/"

# Azure: one static header. Microsoft also says to bypass any proxy.
curl -sS -H "Metadata: true" --noproxy "*" \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F"

# Google Cloud: one static header.
curl -sS -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"

AWS: require IMDSv2 at both levels

Account IMDS defaults apply only to new instances, and anything set at launch overrides them: a launch template that says HttpTokens optional still produces IMDSv1 instances. Set both. Find stragglers with the MetadataNoToken metric, which counts IMDSv1 calls, and require tokens on existing instances once it stays at zero. The file also turns on EBS default encryption in each Region, through the per-resource region argument added in AWS provider 6.0, and S3 Block Public Access for the account.

Keep the hop limit at 1 unless a container must reach IMDS, since a container network adds a hop. On EKS nodes whose pods use EKS Pod Identity or IRSA, a limit of 1 also stops pods from borrowing the node role. Finally, enforce IMDSv2 for the account (HttpTokensEnforced) so IMDSv1 launches fail. Provider 6.68 cannot set it; use aws ec2 modify-instance-metadata-defaults --http-tokens-enforced enabled or an EC2 declarative policy.

Configurationbaseline.tfHCL
# Account guardrails. IMDS and EBS defaults are per Region; S3 BPA is account-wide.
locals {
  regions = toset(["eu-west-1", "us-east-1"]) # every Region you use
}

resource "aws_ec2_instance_metadata_defaults" "imds" {
  for_each                    = local.regions
  region                      = each.key
  http_tokens                 = "required" # new instances launch IMDSv2-only
  http_put_response_hop_limit = 1          # 2 only where containers must reach IMDS
}

resource "aws_ebs_encryption_by_default" "on" {
  for_each = local.regions
  region   = each.key
  enabled  = true
}

resource "aws_s3_account_public_access_block" "all" {
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

# Launch settings override account defaults, so require IMDSv2 here too.
resource "aws_launch_template" "app" {
  name_prefix   = "app-"
  image_id      = var.ami_id
  instance_type = "m7g.large"

  iam_instance_profile {
    name = aws_iam_instance_profile.app.name # role scoped to this app only
  }

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }
}

Block public storage from the top

Turn on all four S3 Block Public Access settings for the account, or for the organization through an Organizations policy, which applies them as a set to member accounts. New buckets have had them on, with ACLs disabled, since April 2023, but a bucket owner can switch them off; an account-level setting wins because S3 applies the most restrictive combination. Since April 2026 new general purpose buckets also block SSE-C uploads.

On Azure, set AllowBlobPublicAccess to false and, once clients authenticate with Microsoft Entra ID, AllowSharedKeyAccess to false; that also stops account and service SAS tokens, leaving user delegation SAS. Older accounts may show null for either property, which Azure treats as permissive. On Google Cloud, enforce storage.publicAccessPrevention on the organization: unlike uniform bucket-level access, it is not in the set new organizations get by default.

None of these blocks stop presigned URLs, SAS tokens or signed URLs, which are bearer credentials signed by an identity you control. Issue them per object with a short expiry.

The 2023 Microsoft SAS token exposure shows what happens when one of these bearer URLs is too broad and too long-lived, and why Microsoft's own scanner finding it was not enough: /domains/runtime/cloud-posture/case-study-microsoft-sas-token/.

Encryption is on by default; key control is a choice

All three encrypt stored data by default: S3 has applied SSE-S3 to every new object since 5 January 2023, Azure Storage encryption cannot be disabled, and Google encrypts customer content at rest with AES-256. The gaps are narrow but real: EBS encryption by default is a per-Region setting you must turn on, and Azure VM temporary disks are not encrypted unless you enable encryption at host.

Customer-managed keys in AWS KMS, Key Vault or Cloud KMS add control rather than secrecy: a separate key policy, logs of key use and the option to make data unreadable by disabling the key. Use them for regulated or multi-tenant data, turn on rotation (off by default in KMS, then every 365 days unless you choose 90 to 2,560), and guard against deletion: KMS waits 7 to 30 days, Cloud KMS holds a version scheduled for destruction for 30 days by default, and Key Vault needs purge protection, which is off by default.

Keep audit logs longer than the provider does

CloudTrail event history keeps 90 days of management events per account and Region. A multi-Region organization trail sends every account's events to one bucket, member accounts cannot change or stop it, and log file validation adds signed SHA-256 digests that expose edited or deleted files. The KMS key must be in the bucket's Region. The Azure Activity Log also keeps 90 days: export it with a subscription diagnostic setting, with Entra sign-in and audit logs (7 days on Free, 30 on P1 or P2). Google keeps Admin Activity logs 400 days in _Required; an aggregated sink copies them to a project you control.

Data-plane logs are off by default: CloudTrail data events, Azure resource logs and Google Data Access logs (except BigQuery). Turn them on for stores that hold sensitive data, because an investigation needs to know who read which object. Advanced event selectors replace basic ones, so the trail below keeps a selector for management events.

Configurationtrail.tfHCL
# Organization trail, created in the management account (or the CloudTrail delegated administrator).
# The key policy and bucket policy must allow cloudtrail.amazonaws.com; see the CloudTrail guide.
resource "aws_kms_key" "trail" {
  description             = "CloudTrail log files"
  enable_key_rotation     = true
  rotation_period_in_days = 365
  deletion_window_in_days = 30
  policy                  = data.aws_iam_policy_document.trail_key.json
}

resource "aws_cloudtrail" "org" {
  name                       = "org-trail"
  s3_bucket_name             = var.log_archive_bucket # bucket in the log-archive account
  kms_key_id                 = aws_kms_key.trail.arn
  is_organization_trail      = true
  is_multi_region_trail      = true
  enable_log_file_validation = true

  # Advanced selectors replace basic ones, so management events need their own selector.
  advanced_event_selector {
    name = "All management events"
    field_selector {
      field  = "eventCategory"
      equals = ["Management"]
    }
  }

  advanced_event_selector {
    name = "Object reads and writes in the customer-data bucket"
    field_selector {
      field  = "eventCategory"
      equals = ["Data"]
    }
    field_selector {
      field  = "resources.type"
      equals = ["AWS::S3::Object"]
    }
    field_selector {
      field       = "resources.ARN"
      starts_with = ["arn:aws:s3:::acme-customer-data/"]
    }
  }
}

Azure: storage, Key Vault and the Activity Log

The storage account accepts only Microsoft Entra ID requests and no public network traffic, and the app reaches it through a user-assigned managed identity with one data-plane role. Set storage_use_azuread in the provider, or Terraform's own data-plane calls fail once Shared Key is off. Write allow_nested_items_to_be_public = false even though azurerm 5.0 made it the default, because 4.x defaulted to true. Provider 5.5 added public_network_access. Private endpoints and DNS zones are omitted.

Configurationbaseline.tfHCL
provider "azurerm" {
  features {}
  storage_use_azuread = true # Terraform itself must not need account keys
}

data "azurerm_client_config" "current" {}

resource "azurerm_user_assigned_identity" "app" {
  name                = "id-app-prod"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
}

resource "azurerm_storage_account" "data" {
  name                              = "stappdataprod001"
  resource_group_name               = azurerm_resource_group.app.name
  location                          = azurerm_resource_group.app.location
  account_tier                      = "Standard"
  account_replication_type          = "ZRS"
  allow_nested_items_to_be_public   = false      # the azurerm 4.x default was true
  shared_access_key_enabled         = false      # Entra ID only: no account keys, no key-signed SAS
  public_network_access             = "Disabled" # reach it through a private endpoint
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true
  default_to_oauth_authentication   = true
}

resource "azurerm_role_assignment" "app_blob" {
  scope                = azurerm_storage_account.data.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azurerm_user_assigned_identity.app.principal_id
}

resource "azurerm_key_vault" "app" {
  name                          = "kv-app-prod-001"
  location                      = azurerm_resource_group.app.location
  resource_group_name           = azurerm_resource_group.app.name
  tenant_id                     = data.azurerm_client_config.current.tenant_id
  sku_name                      = "standard"
  rbac_authorization_enabled    = true
  purge_protection_enabled      = true
  soft_delete_retention_days    = 90
  public_network_access_enabled = false

  network_acls {
    bypass         = "None"
    default_action = "Deny"
  }
}

resource "azurerm_role_assignment" "app_secrets" {
  scope                = azurerm_key_vault.app.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.app.principal_id
}

# Keep the subscription Activity Log beyond its 90 days.
resource "azurerm_monitor_diagnostic_setting" "activity_log" {
  name                       = "activity-to-security-workspace"
  target_resource_id         = "/subscriptions/${data.azurerm_client_config.current.subscription_id}"
  log_analytics_workspace_id = var.security_workspace_id

  dynamic "enabled_log" {
    for_each = ["Administrative", "Security", "Policy", "Alert"]
    content {
      category = enabled_log.value
    }
  }
}

Close inbound management paths

Remove SSH and RDP from the internet everywhere. Google's default network ships with rules that allow TCP 22, TCP 3389 and ICMP from 0.0.0.0/0; enforce compute.skipDefaultNetworkCreation, delete the network from existing projects, and use IAP TCP forwarding, which needs ingress only from 35.235.240.0/20. On AWS, Session Manager gives a shell with no inbound port, and VPC Block Public Access can block internet gateway traffic for a Region, with exclusions. On Azure, use Azure Bastion or a private path instead of public IPs on VMs.

Reach data services privately: private endpoints on Azure, VPC endpoints with endpoint policies on AWS, Private Service Connect on Google Cloud. With API versions released after 31 March 2026, subnets in new Azure virtual networks are private by default and need an explicit outbound method such as a NAT gateway.

Google Cloud: organization policies and VMs

Organizations created on or after 3 May 2024 already enforce the key creation, key upload, default service account and uniform bucket-level access constraints; older ones have none of them unless someone set them. Declare the policies anyway so the baseline is explicit, try them with a dry_run_spec first, and grant exceptions with tag-based conditions rather than lifting a constraint. The VM runs as its own service account with the cloud-platform scope, so IAM roles on that account set its permissions, and it has no external IP.

Configurationbaseline.tfHCL
# Organization guardrails. Orgs created since 3 May 2024 already enforce some of these.
resource "google_org_policy_policy" "enforce" {
  for_each = toset([
    "iam.managed.disableServiceAccountKeyCreation",
    "iam.managed.disableServiceAccountKeyUpload",
    "iam.automaticIamGrantsForDefaultServiceAccounts",
    "storage.publicAccessPrevention",
    "storage.uniformBucketLevelAccess",
    "compute.skipDefaultNetworkCreation",
    "compute.managed.vmExternalIpAccess",
  ])
  name   = "organizations/${var.org_id}/policies/${each.key}"
  parent = "organizations/${var.org_id}"
  spec {
    rules {
      enforce = "TRUE"
    }
  }
}

resource "google_storage_bucket" "data" {
  project                     = var.project_id
  name                        = "acme-prod-customer-data"
  location                    = "EU"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
}

# Data Access audit logs are off by default for everything except BigQuery.
resource "google_project_iam_audit_config" "storage" {
  project = var.project_id
  service = "storage.googleapis.com"
  audit_log_config { log_type = "DATA_READ" }
  audit_log_config { log_type = "DATA_WRITE" }
}

resource "google_service_account" "app" {
  project    = var.project_id
  account_id = "vm-app-prod"
}

resource "google_compute_instance" "app" {
  project      = var.project_id
  name         = "app-prod-1"
  zone         = "europe-west1-b"
  machine_type = "e2-standard-2"

  boot_disk {
    initialize_params { image = "debian-cloud/debian-12" }
  }
  network_interface {
    subnetwork = var.subnet_id # no access_config block, so no external IP
  }
  service_account {
    email  = google_service_account.app.email # never the default compute SA
    scopes = ["cloud-platform"]               # IAM roles on the SA do the limiting
  }
  shielded_instance_config {
    enable_secure_boot = true
  }
  metadata = {
    enable-oslogin         = "TRUE"
    block-project-ssh-keys = "true"
  }
}

Keep the remaining secrets in the provider's store

Some secrets survive all of this: third-party API keys, database passwords, signing keys. Keep them in AWS Secrets Manager, Azure Key Vault or Google Secret Manager, and let each workload's identity read only its own (Key Vault Secrets User on one vault, Secret Manager Secret Accessor on one secret). Prefer platform-managed credentials: with manage_master_user_password, RDS creates the master password in Secrets Manager and rotates it every seven days by default, so it never enters Terraform state.

Rotation differs. Secrets Manager rotates through managed rotation or a Lambda function, while Google Secret Manager only publishes a SECRET_ROTATE message to Pub/Sub on schedule and your code must add the new version. Google also advises reading secrets through the API rather than environment variables, and pinning version numbers instead of latest.

What a failing configuration looks like

This configuration passes terraform validate and works on the first deploy. Every resource breaks the baseline: a permanent access key for CI, SSH open to the internet, an instance with a public IP and IMDSv1, and a bucket public access block that names two settings but turns off all four, because the omitted ones default to false. Checkov 3.3.26 flags each of them (CKV_AWS_273, CKV_AWS_24, CKV_AWS_88, CKV_AWS_79 and CKV_AWS_53 to CKV_AWS_56) among 20 findings.

Vulnerablemain.tfHCL
# Passes terraform validate and works on day one. Every resource breaks the baseline.
resource "aws_iam_user" "ci" {
  name = "ci-deployer"
}

resource "aws_iam_access_key" "ci" { # never expires; ends up in a CI secret store
  user = aws_iam_user.ci.name
}

resource "aws_security_group" "app" {
  name = "app"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # SSH from the whole internet
  }
}

resource "aws_instance" "app" {
  ami                         = var.ami_id
  instance_type               = "m7g.large"
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.app.id]
  # No metadata_options: IMDSv1 stays on unless an account default says otherwise
}

resource "aws_s3_bucket" "exports" {
  bucket = "acme-exports"
}

# Omitted arguments default to false, so this turns off all four settings.
resource "aws_s3_bucket_public_access_block" "exports" {
  bucket                  = aws_s3_bucket.exports.id
  block_public_policy     = false # turned off "temporarily" for a partner
  restrict_public_buckets = false
}

Test the Terraform before it reaches an account

terraform test with mock providers (Terraform 1.7 and later) plans and applies a configuration with no credentials, so baseline assertions can run on every pull request. This test passes against the AWS files above and fails when a launch template sets HttpTokens to optional or the trail loses log file validation.

Run a scanner too, but read its rules. Checkov 3.3.26 flags the Azure account above as publicly reachable (CKV_AZURE_59) because the rule reads only the deprecated public_network_access_enabled argument.

Testtests/baseline.tftest.hclHCL
# tests/baseline.tftest.hcl: `terraform test` runs it with no AWS credentials.
mock_provider "aws" {
  # Mocked values must still pass provider validation, so give them real shapes.
  mock_resource "aws_kms_key" {
    defaults = { arn = "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" }
  }
  mock_data "aws_iam_policy_document" {
    defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" }
  }
}

run "baseline" {
  command = apply

  assert {
    condition     = alltrue([for r in aws_ec2_instance_metadata_defaults.imds : r.http_tokens == "required"])
    error_message = "Every Region must default new instances to IMDSv2"
  }
  assert {
    condition     = aws_launch_template.app.metadata_options[0].http_tokens == "required"
    error_message = "The launch template must not re-enable IMDSv1"
  }
  assert {
    condition     = aws_s3_account_public_access_block.all.restrict_public_buckets
    error_message = "S3 Block Public Access must cover public bucket policies"
  }
  assert {
    condition     = aws_cloudtrail.org.is_multi_region_trail && aws_cloudtrail.org.enable_log_file_validation
    error_message = "The trail must cover every Region and sign its digests"
  }
}

Pitfalls

Most baseline failures are gaps between levels rather than wrong values.

  • A launch template, Auto Scaling configuration or Terraform module that sets HttpTokens optional, which overrides the account default.
  • A hop limit of 2 on every host to fix one container, which also hands the node's credentials to every container on it.
  • Turning off Shared Key before clients and Terraform use Microsoft Entra ID, which breaks them all at once.
  • Trusting the 90-day defaults until an investigation needs day 91.
  • VMs still running as the Compute Engine default service account, which may hold Editor.

Check the live accounts

Configuration in Git is not proof of what runs. Run these read-only checks with a read-only role on a schedule and treat any change as drift to investigate; the drift guide at /domains/runtime/cloud-posture/iac-policy-drift/ covers that workflow.

Testcheck-baseline.shShell
#!/usr/bin/env bash
# Read-only checks. Run with a read-only role in every account, subscription
# and project; repeat the AWS block for each Region you use.
REGION=eu-west-1 ACCOUNT_ID=111122223333 PROJECT=acme-app-prod

# AWS: HttpTokens "required", no instance IDs listed, true, four true flags
aws ec2 get-instance-metadata-defaults --region "$REGION"
aws ec2 describe-instances --region "$REGION" \
  --filters Name=metadata-options.http-tokens,Values=optional \
  --query 'Reservations[].Instances[].InstanceId' --output text
aws ec2 get-ebs-encryption-by-default --region "$REGION"
aws s3control get-public-access-block --account-id "$ACCOUNT_ID"

# Azure: False, False, Disabled (an empty value means the old permissive default)
az storage account list -o table \
  --query "[].{name:name, anonymous:allowBlobPublicAccess, sharedKey:allowSharedKeyAccess, network:publicNetworkAccess}"
for kv in $(az keyvault list --resource-type vault --query "[].name" -o tsv); do
  az keyvault show --name "$kv" -o tsv \
    --query "{name:name, rbac:properties.enableRbacAuthorization, purge:properties.enablePurgeProtection}"
done
az monitor diagnostic-settings subscription list -o table

# Google Cloud: True for each policy, enforced/True buckets, no VM names, no rules
for c in iam.managed.disableServiceAccountKeyCreation storage.publicAccessPrevention \
         compute.skipDefaultNetworkCreation; do
  echo "$c: $(gcloud org-policies describe "$c" --project="$PROJECT" --effective \
    --format='value(spec.rules[0].enforce)')"
done
gcloud storage buckets list --project="$PROJECT" \
  --format="table(name, public_access_prevention, uniform_bucket_level_access)"
gcloud compute instances list --project="$PROJECT" \
  --filter="networkInterfaces[].accessConfigs[].natIP:*" --format="value(name)"
gcloud compute firewall-rules list --project="$PROJECT" \
  --filter="direction=INGRESS AND sourceRanges:0.0.0.0/0 AND disabled=false" \
  --format="table(name, sourceRanges.list())"

Guided practice

A platform team collected the settings below from three new environments with read-only roles on 2026-10-09. Decide what each finding allows an attacker or an incident responder to do, then write the fix and the level it belongs at.

Supplied scenario material
Plain text
AWS account 111122223333, eu-west-1
A1  get-instance-metadata-defaults: HttpTokens=required, HttpPutResponseHopLimit=2
A2  launch template web-lt (used by the web Auto Scaling group): HttpTokens=optional
A3  EKS node launch template: HttpTokens=required, HttpPutResponseHopLimit=2;
    every pod that calls AWS uses EKS Pod Identity
A4  s3control get-public-access-block: BlockPublicAcls=true, IgnorePublicAcls=true,
    BlockPublicPolicy=false, RestrictPublicBuckets=false
A5  IAM user gha-deploy: one active access key, created 2024-03-02, stored as a GitHub Actions secret

Azure subscription prod
Z1  storage account stexports: allowBlobPublicAccess=null, allowSharedKeyAccess=null,
    publicNetworkAccess=Enabled; managed by Terraform with azurerm 4.81, which does not
    set allow_nested_items_to_be_public
Z2  no subscription diagnostic setting; an incident review must find who created a
    role assignment 120 days ago

Google Cloud organization created in 2021, project app-prod
G1  default network present, including default-allow-ssh (0.0.0.0/0, tcp:22)
G2  service account app-sa has two user-managed keys; no key creation constraint is set
G3  bucket exports: public_access_prevention=inherited; storage.publicAccessPrevention not set anywhere
  1. 1. Predict the next web instance

    State which IMDS versions an instance launched from web-lt will accept, and fix A1 and A2.

    Expected result and reasoning

    It accepts IMDSv1, because launch settings override the account default. Set HttpTokens=required and hop limit 1 in web-lt, set the account hop limit to 1 or no preference, wait for MetadataNoToken to reach zero, then turn on HttpTokensEnforced so no launch can re-enable IMDSv1.

  2. 2. Close the pod path to the node role

    Explain what the hop limit of 2 on the EKS nodes allows, and change it.

    Expected result and reasoning

    Pods sit one network hop beyond the node, so with a limit of 2 any pod can fetch the node role's credentials. Every AWS-calling pod uses Pod Identity, so set the limit to 1.

  3. 3. Read the S3 settings

    Decide what A4 permits and write the fix.

    Expected result and reasoning

    Any bucket whose own settings allow it can be made public with a bucket policy, because the account no longer blocks or restricts public policies. Set all four settings to true for the account or through an organization policy, and share with partners by naming their account in a non-public policy or by short-lived presigned URLs.

  4. 4. Retire the CI key

    Replace A5 and stop the next one being created.

    Expected result and reasoning

    Configure GitHub Actions OIDC with a role whose trust policy names the repository and environment, deploy with it, deactivate and then delete the key, and attach an SCP that denies iam:CreateAccessKey and iam:CreateUser outside a break-glass role.

  5. 5. Fix the Azure gaps

    Explain Z1 and Z2 and give the changes.

    Expected result and reasoning

    A null value is permissive for both properties. azurerm 4.81 treats the omitted allow_nested_items_to_be_public as true and reads the null as true, so terraform plan shows no change and nothing closes it. Set it to false (or move to azurerm 5.x), turn off Shared Key once clients use Entra ID, and disable public network access behind a private endpoint. Z2 cannot be answered from Azure: the Activity Log keeps 90 days. Add the diagnostic setting now and record the gap.

  6. 6. Bring the old Google organization up to the baseline

    Fix G1 to G3.

    Expected result and reasoning

    A 2021 organization has none of the May 2024 default constraints. Enforce compute.skipDefaultNetworkCreation and delete the default network or its open rules in app-prod, using IAP forwarding from 35.235.240.0/20. Find what uses the keys in Cloud Audit Logs, move app-sa to an attached identity or federation, delete the keys and enforce iam.managed.disableServiceAccountKeyCreation. Set the bucket to enforced and enforce storage.publicAccessPrevention on the organization.

Your deliverable: A findings table with one row for each of A1 to G3: what it allows, the fix, the level it belongs at (organization, account or resource) and the read-only check that proves it.

Paper exercise: the fixture is complete and needs no cloud account. The Terraform and checks in this guide reproduce each fix in a sandbox.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Amazon EC2 User Guide: configure instance metadata options for new instances (account defaults and IMDSv2 enforcement)
  2. AWS Security Blog: defense in depth against open firewalls, reverse proxies and SSRF with IMDSv2 (November 2019)
  3. Amazon S3 User Guide: blocking public access to your Amazon S3 storage
  4. AWS CloudTrail User Guide: security best practices
  5. Azure Instance Metadata Service: security and authentication
  6. Azure Monitor activity log: retention period
  7. Terraform AzureRM provider 5.0 upgrade guide (azurerm_storage_account and azurerm_key_vault changes)
  8. Google Cloud: organization policies enforced on new organizations (security baseline constraints)
  9. Google Cloud Storage: public access prevention and inheritance
  10. Google Cloud Audit Logs: Data Access audit logs

Reference information

Download this guide as Markdown