Cloud Tech
SecurityIntermediate

Infrastructure as Code Security

How plan-stage policy blocks unsafe AWS and Azure infrastructure before deployment, with practical policy testing and enforcement guidance.

Reviewed Jul 28, 2026Victor Nwoke5 min read

Written and maintained by Victor Nwoke. Technical behavior is reviewed against the primary references listed on this page.

Overview

Infrastructure as code security means checking a proposed change before it is provisioned, not only auditing it afterward. Policy-as-code frameworks like HashiCorp Sentinel evaluate a Terraform plan and can block the run from proceeding to apply if a rule is violated. That timing is the point: an unsafe AWS bucket or Azure storage account is stopped before it exists. Static source scanning remains useful, but the plan shows the concrete resource changes after variables, data sources, and modules have been evaluated. Some values can still be unknown until apply, so a production policy must explicitly decide whether an unknown security-sensitive value fails closed or receives a narrowly documented exception.

Quick Reference

Check pointSeesCan prevent provisioning?
Source scanning (linting)Raw Terraform filesOnly obvious, statically-visible issues
Plan-based policy (Sentinel, OPA/Conftest)Planned changes plus explicitly represented unknown valuesYes, blocks the run before apply
Post-apply audit (cloud config scanning)Already-provisioned live resourcesNo, detective only, after the fact

Syntax

hcl
# sentinel.hcl - attach a policy to every plan for this workspace
policy "no-public-s3-buckets" {
  source            = "./no-public-s3-buckets.sentinel"
  enforcement_level  = "hard-mandatory"
}

Examples

AWS: prevent public S3 access

python
# no-public-s3-buckets.sentinel - evaluated against the plan, not the
# source, so it sees actually-resolved config. Checks the legacy inline
# acl argument, the modern separate aws_s3_bucket_acl resource, and
# public-access-block settings that would otherwise allow a public
# bucket policy through; it does not evaluate bucket policy documents
# themselves.
import "tfplan/v2" as tfplan

public_acl_buckets = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_s3_bucket" and rc.change.after.acl is "public-read"
}

public_acl_resources = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_s3_bucket_acl" and rc.change.after.acl is "public-read"
}

open_access_blocks = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_s3_bucket_public_access_block" and
    (rc.change.after.block_public_acls is false or
     rc.change.after.restrict_public_buckets is false)
}

main = rule {
  length(public_acl_buckets) is 0 and
    length(public_acl_resources) is 0 and
    length(open_access_blocks) is 0
}

Azure: prevent anonymous blob access and old TLS

python
# secure-azure-storage.sentinel
import "tfplan/v2" as tfplan

insecure_storage_accounts = filter tfplan.resource_changes as _, rc {
  rc.mode is "managed" and
    rc.type is "azurerm_storage_account" and
    rc.change.actions is not ["delete"] and
    (rc.change.after.allow_nested_items_to_be_public is true or
     rc.change.after.min_tls_version is not "TLS1_2")
}

main = rule {
  length(insecure_storage_accounts) is 0
}

The Azure rule checks the planned AzureRM provider values, not a screenshot or an already-deployed account. In a real policy library, add explicit handling for after_unknown, unit-test create/update/delete plans, and expand the controls to match the organization's threat model. Public network access, for example, may be permitted only when a restrictive firewall is present, while higher-risk state or secrets storage may require a private endpoint.

Check the plan, not just the source

Static scanning of .tf files cannot see values resolved through runtime inputs, data sources, and module composition. Policy-as-code evaluates Terraform's best prediction of the resource changes about to be applied. Security-sensitive values that remain unknown must be handled explicitly rather than silently treated as compliant.

Policy Validation Workflow

Treat policy code like production application code:

  1. I run terraform plan -out=tfplan with representative inputs and modules.
  2. Render the saved plan with terraform show -json tfplan and verify the exact provider resource types, attribute names, action lists, and unknown values the policy receives.
  3. Build fixtures for allowed and denied AWS/Azure creates, updates, replacements, imports, and deletes. A policy tested only against one happy-path create will fail unexpectedly during real lifecycle operations.
  4. I start in advisory mode long enough to measure false positives, then make critical controls mandatory with an owned exception process and expiration date.
  5. Preserve the reviewed plan and policy result so the applied artifact is the exact plan that passed enforcement.

Plan files and their JSON output can contain sensitive values. I keep them out of source control and restrict CI artifact access and retention.

Visual Diagram

Common Mistakes

  • Relying only on static source scanning and assuming it catches everything a plan-based policy check would, missing runtime-resolved misconfigurations entirely.
  • Writing policy-as-code rules but setting them to advisory rather than mandatory enforcement, so a violation is logged but never actually blocks the run.
  • Discovering a misconfigured, publicly-exposed resource only via a post-apply cloud security scan, after it's already been live and potentially exploited.
  • Treating policy-as-code as a one-time setup instead of updating rules as new resource types and misconfiguration classes are adopted.
  • Copying an AWS-only rule set into a multi-cloud pipeline and assuming it protects Azure resources whose provider types and attributes are completely different.
  • Ignoring unknown plan values or delete/replace actions, causing a policy to fail open on the riskiest values or block legitimate lifecycle operations.

Performance

  • Plan-based policy evaluation adds a bounded check to the plan-to-apply workflow, proportional to the number of resources in the plan and the number of policies evaluated, not the size of the entire infrastructure.
  • Preventive, plan-stage checks are unambiguously cheaper than the alternative: remediating an already-provisioned, possibly-exploited misconfiguration costs far more than blocking it before it exists.

Best Practices

  • Evaluate policy against the plan, not just the source, so runtime-resolved values are actually checked before anything is provisioned.
  • Set genuinely important policies to mandatory (hard-blocking) enforcement, not advisory, or they function as a report nobody has to act on.
  • Pair plan-based policy checks with post-apply cloud configuration scanning as defense in depth, not as the primary control.
  • I keep policy rules current as new resource types are adopted; an IaC security process that never adds new checks quietly falls behind what's actually being provisioned.
  • I test policies against sanitized plan fixtures for every supported provider and pin provider versions so schema changes are reviewed alongside policy changes.
  • Link every exception to an owner, justification, narrow scope, and expiration date; an undocumented permanent bypass is not an exception process.

Interview questions

What problem does policy-as-code solve that a manual infrastructure change review does not?
A manual review depends on a human noticing a specific misconfiguration, an open security group, an unencrypted storage bucket, in a plan diff that may span hundreds of resources, and that scrutiny has to be repeated consistently by every reviewer on every change. Policy-as-code encodes the same rule once as executable logic and runs it automatically against every plan, so an overly permissive security group is caught the same way on the hundredth change as the first, without depending on which reviewer happened to be paying attention that day.
At what point in the Terraform workflow are Sentinel (or similar policy-as-code) checks evaluated, and why does that timing matter?
Policy checks evaluate against the plan, the output of `terraform plan`, before `terraform apply` actually provisions anything, which means a policy violation blocks the run from proceeding to apply at all. Evaluating against the plan rather than the already-applied state is what makes this a preventive control instead of a detective one; the non-compliant resource is stopped before it exists, not flagged for cleanup afterward once it's already live and potentially already been exploited or has already incurred cost.
Why is scanning IaC source (Terraform files) not sufficient on its own, without also checking the plan?
Static scanning can catch hardcoded insecure defaults but cannot see the complete result of runtime inputs, data sources, and module composition. A Terraform plan is the best prediction of the concrete resource changes Terraform is about to make, so plan policy sees substantially more than source scanning. It is not guaranteed to know every value before apply, however; security-sensitive unknown values need an explicit fail-closed or exception rule rather than being assumed safe.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement