Infrastructure as code moves the power to build and destroy cloud estates into state files, pipeline credentials and reusable modules, and each of those becomes a target in its own right. A Terraform state file can hold database passwords in plain text, the identity that runs terraform apply is often the most privileged account in the cloud, and a single shared module can push a weak setting into hundreds of deployments. This guide goes deeper than the general misconfiguration picture: it covers Terraform state security, secrets in templates, plan and apply privilege, module and provider governance, IaC scanning and policy as code, and drift detection, with notes for OpenTofu, CloudFormation, Azure Bicep and ARM templates, and Pulumi. For the wider picture see the Cloud Misconfiguration Guide. It is written for platform engineers, cloud security teams and the people who review infrastructure pull requests.
Key takeaways
- State is a secrets store whether you planned it or not. Terraform records generated passwords and tokens in state and plan files, and
sensitivedoes not keep them out. - Split plan and apply identities. The plan stage needs read access; only the apply stage, after review, should hold write permissions, ideally as short-lived federated credentials scoped to the run phase.
- Pin what you pull in. Commit
.terraform.lock.hclfor providers, and pin modules to exact versions or commit SHAs, because the lock file does not cover modules. - Scan twice: source and plan. An IaC scanner on the pull request catches obvious mistakes cheaply; a policy-as-code engine evaluating the plan sees resolved values and blocks what static scanning misses.
- Drift is a security signal. A change made outside the pipeline bypassed review; detect it on a schedule and resolve it in code, not in the console.
What infrastructure as code adds to the attack surface
Infrastructure as code (IaC) describes cloud resources in declarative files that a tool reconciles with reality. It removes console click-ops but adds assets that need as much protection as the infrastructure itself.
| IaC asset | What it holds or does | Typical failure | Primary control |
|---|---|---|---|
| Source templates (HCL, YAML, JSON, Bicep) | Desired configuration | Misconfiguration merged without review; hard-coded secrets | Branch protection, code owners, IaC scanning |
| State file | Real resource IDs and attributes, often including secrets | Readable by too many people, committed to Git, unencrypted | Encrypted remote backend, least-privilege access, versioning |
| Saved plan file | Proposed changes and resolved values | Left as a pipeline artefact with secrets in it | Short artefact retention, restricted artefact access |
| Pipeline identity | Credentials that create, change and delete resources | Long-lived admin key in a CI variable | Federated, short-lived, per-phase credentials |
| Modules | Reusable resource patterns shared across teams | Unpinned source pulls a changed or hostile version | Private registry, exact versions, module ownership |
| Providers and plugins | Executable code that calls cloud APIs with your credentials | Unverified binary or unexpected upgrade | Dependency lock file with checksums, provider mirror |
MITRE ATT&CK lists configuration files containing passwords as a target of T1552.001 Credentials In Files, and state files, plan files and .tfvars files fit that description exactly.
Terraform state security
HashiCorp's documentation is direct: state and plan files hold resource attributes that can include secrets such as initial database passwords and API tokens, and local state is a plaintext file. Values marked sensitive are redacted in CLI output but are still stored in state and plan, and terraform output -json or -raw prints them in plain text. Treat the state file as a credential store.
- Use a remote backend: never keep state on laptops or in Git. Ignore
*.tfstateand real.tfvarsfiles, and scan history for any that slipped through. - Encrypt at rest with a key you control: HCP Terraform encrypts state at rest and in transit; the S3 backend encrypts state and lock files when
encryptis set, andkms_key_idmoves that to a customer managed KMS key (Terraform then needskms:Encrypt,kms:Decryptandkms:GenerateDataKey). A customer managed key adds a second access control point with its own audit trail. - Lock state: concurrent applies corrupt state. On S3, set
use_lockfile = truefor native S3 locking; it defaults to false, and DynamoDB-based locking is deprecated and will be removed in a future minor version. - Version the bucket: HashiCorp "highly" recommends bucket versioning so state can be recovered after deletion or corruption.
- Grant the minimum: the S3 backend needs
s3:ListBucketon the bucket ands3:GetObjectands3:PutObjecton the state key;s3:DeleteObjectis needed only on the lock file, not on state. Split state per environment and team so read access to development state does not expose production secrets. - Restrict cross-state reads:
terraform_remote_stateexposes another state's entire output set; publish only needed outputs or share values through a parameter store.
Keeping secrets out of state in the first place
Encryption limits who can read state; ephemeral values keep secrets out of it. From Terraform 1.10, ephemeral variables, module outputs and resource blocks exist during a run and are omitted from state and plan. From Terraform 1.11, write-only arguments (a _wo suffix, such as password_wo, with a _wo_version argument to trigger updates) send a value to the provider without persisting it. Provider support varies. The target pattern: read the secret from a vault through an ephemeral resource and pass it to a write-only argument.
OpenTofu can encrypt state and plan files itself, in any backend, using the aes_gcm method with key providers such as aws_kms, gcp_kms, azure_vault or pbkdf2. Set enforced = true once migrated, and back up keys first: encrypted state is unrecoverable without them.
Infrastructure as code secrets across tools
Every IaC tool can keep secrets out of source and logs; leaks happen when teams skip it or forget outputs.
| Tool | Where state lives | Secret handling | Common leak | Native drift check |
|---|---|---|---|---|
| Terraform | Backend you configure, or HCP Terraform | sensitive (redaction only), ephemeral values, write-only arguments | Generated passwords in state; secrets in .tfvars | Plan with -refresh-only; HCP Terraform health assessments |
| OpenTofu | Backend you configure | Built-in state and plan encryption | Encryption not enforced, keys lost | Plan with -refresh-only |
| AWS CloudFormation | Managed by AWS in the stack | Dynamic references to Secrets Manager or Parameter Store; NoEcho parameters | Secrets passed as plain parameters or written to outputs | Stack drift detection |
| Azure Bicep and ARM templates | Azure Resource Manager deployment history | @secure() parameters; getSecret() from Key Vault | Secrets returned as outputs, which are logged to deployment history | What-if preview against current state |
| Pulumi | Pulumi Cloud or a self-managed backend | Secrets encrypted in state with per-stack keys; config set --secret; additionalSecretOutputs | Values not marked secret stored in plain text | Refresh and preview |
- CloudFormation security: AWS recommends dynamic references because "CloudFormation never stores the actual reference value", a service role so stack permissions are separate from the user's, and CloudTrail logging of CloudFormation API calls. Add stack policies and termination protection to production stacks.
- Azure Bicep: Azure Resource Manager does not log or display
@secure()parameter values, but Microsoft warns: "Don't use Bicep outputs for secure data. Outputs are logged to the deployment history". Pass Key Vault secrets withgetSecret(). - Everywhere: the best IaC secret is the one that does not exist: prefer managed identities and workload identity federation. See the Secrets Management Guide.
Pipeline identity and infrastructure as code privilege
The identity that applies infrastructure can usually create users, open network rules and delete databases: a non-human identity with standing administrative power that attackers on a CI runner look for first.
- Separate plan from apply:
terraform planneeds to read every managed resource;terraform applyneeds to change them. Give the plan job a read-only role and the apply job a write role that is only available after approval on a protected branch. Pull requests from forks should never reach either. - Federate instead of storing keys: HCP Terraform dynamic provider credentials issue an OIDC-compliant workload identity token for each plan and apply, which AWS, Azure, Google Cloud, Vault and Kubernetes exchange for temporary credentials that are discarded when the run ends. The token's
subclaim includes organisation, project, workspace andrun_phase, and aterraform_run_phaseclaim is also issued, so the cloud trust policy can hand a read-only role to plan and a write role only to apply. See the CI/CD Pipeline Identity Security Guide. - Scope by blast radius: one apply role per environment and, in large estates, per workspace or account, rather than one organisation-wide administrator. Deny the apply role the ability to modify its own trust policy, the state bucket policy and the organisation guardrails.
- Remove human apply rights in production: engineers keep read access and a break-glass path. See the Break-Glass Account Guide.
- Protect pipeline definitions: whoever edits the workflow file can change which role is requested, so require code owner review for pipeline and backend configuration.
Terraform modules, providers and the private module registry
Modules and providers are third-party code that runs with your cloud credentials. Treat them as part of the software supply chain.
Providers
- Commit the dependency lock file:
.terraform.lock.hclrecords the selected version and checksums for each provider, and HashiCorp says to keep it in version control so dependency changes are reviewed. Runterraform providers lockfor every platform your runners and engineers use, sozh:andh1:hashes exist for each. - Upgrade deliberately: without
-upgrade,terraform initreuses the locked version, so upgrades arrive as reviewed lock file changes. - Mirror for regulated estates: a provider mirror keeps runners off the public registry and makes the approved set explicit.
Modules and module governance
- Pin exactly: the lock file "tracks only provider dependencies", and Terraform otherwise selects the newest module version allowed by the constraint. Use exact
versionvalues for registry modules. A Git source withoutreffollows the default branchHEAD; pinrefto a commit SHA rather than a movable tag for anything sourced outside your organisation. - Run a private module registry: HCP Terraform and Terraform Enterprise host private registries, addressed as
hostname/organisation/name/provider. Publish approved, scanned, versioned modules there and block direct public module sources by policy. - Bake secure defaults into modules: encryption on, public access off, logging on, tags required, so a control is fixed once for every consumer.
- Assign ownership: every module has an owning team, semantic versioning, code owner review and a deprecation path; security fixes ship as new versions with a consumer deadline.
- Vet external modules: read what they create, copy them into your registry at a reviewed version, and rescan on each update. See the Software Supply Chain Security Guide.
IaC scanning and policy as code in the pipeline
Infrastructure as code scanning works at two levels. Static IaC scanners read source templates and flag known misconfiguration patterns. Policy-as-code engines evaluate the machine-readable plan (for Terraform, terraform show -json on a saved plan), which contains resolved values and the exact create, update and delete actions.
| Stage | Control | Catches | Misses | Action on failure |
|---|---|---|---|---|
| Editor and pre-commit | Formatting, validation, IaC scanner, secret scanner | Typos, hard-coded secrets, obvious misconfiguration | Anything an engineer skips locally | Warn |
| Pull request | IaC scanner on changed files and modules | Public storage, open management ports, wildcard IAM, missing encryption | Values computed at plan time, cross-stack effects | Block on high severity |
| After plan, before apply | Policy-as-code engine on plan JSON; HCP Terraform policy sets | Resolved values, destroys of protected resources, unapproved providers or module sources, cost or region rules | Changes made outside the pipeline | Block; documented override |
| Cloud control plane | Azure Policy deny, AWS SCPs, Google Cloud constraints | Any non-compliant API call | Fine-grained rules | Request denied |
| After deployment | Drift detection and posture management | Console changes and legacy resources | Nothing in real time | Ticket or auto-revert |
- HCP Terraform policy enforcement: policy sets in Sentinel, the Rego language, or the newer HCL-based Terraform policy framework are checked against the plan on every run and, depending on enforcement level, stop it. Sentinel's levels are advisory, soft-mandatory (overridable by authorised users) and hard-mandatory. Start new rules as advisory, measure failures, then promote.
- Gate on a short list: block rules that map to real breaches (public data, exposed management ports, wildcard permissions, disabled logging, unencrypted state backends) and report the rest.
- Govern suppressions: inline skips carry a reason, owner and expiry, reviewed monthly.
- Secure the scanners themselves: pin scanner container images by digest and keep scanner jobs away from deployment credentials. Security tooling is a supply chain target too, as the 2026 poisoning of an open source IaC scanner's Docker images showed.
Infrastructure as code drift and terraform import
HashiCorp describes configuration drift as changes "made outside Terraform's regular process": someone bypassed review, or an emergency fix never reached code.
- Terraform and OpenTofu: schedule
plan -refresh-only -detailed-exitcodeper workspace with the read-only plan role; exit code 2 means differences exist. Send the result to the owning team. - HCP Terraform: health assessments run drift detection (Terraform 0.15.4 or later) and continuous validation of preconditions, postconditions and
checkblocks (1.3.0 or later) on a schedule, for workspaces in remote or agent execution mode on the Standard and Premium editions. - CloudFormation: stack drift detection reports stacks as
DRIFTED,IN_SYNCorNOT_CHECKEDand resources asMODIFIEDorDELETED, but only for properties set explicitly in the template, not default values, nested stacks or theKMSKeyIdproperty. - Bicep and ARM templates: run a scheduled what-if deployment and alert on unexpected changes.
- Resolve in code: either revert by applying the configuration, or update the configuration to accept the change through a reviewed pull request. Treat security-relevant drift (a rule opened, logging disabled, a role added) as an incident until explained.
- Adopt unmanaged resources:
importblocks (Terraform 1.5 and later, replacing one-by-oneterraform importcommands), withterraform planand the generate-config-out option, bring console-built resources under code. Review generated configuration before merging: it copies whatever weak settings were built.
Implementation roadmap
- Inventory: list every state backend, workspace, pipeline identity and module source; search repositories for committed state and keys, and rotate anything found.
- Lock down state: move to an encrypted, versioned, locked remote backend with access limited to the pipeline roles and a small admin group. Test restore from a previous version before relying on it.
- Federate pipeline identity: replace static cloud keys with OIDC federation, one workspace at a time; keep the old key disabled, not deleted, for a week as rollback, then delete it.
- Split plan and apply: introduce the read-only plan role and gated apply role; confirm plans still succeed before removing write rights from the plan job.
- Pin dependencies: commit lock files, pin modules and stand up the private registry, starting with the most used modules.
- Add scanning in report mode: run the IaC scanner and plan policies as advisory for two to four weeks, tune false positives, then block the short list. Rollback is a one-line change to the enforcement level.
- Turn on drift detection: daily for production, weekly elsewhere, routed to owners with response targets.
- Move to ephemeral secrets: refactor generated passwords out of state as providers add write-only arguments, then rotate them.
Decision guidance
- Managed platform or self-managed backend: HCP Terraform provides encrypted state, per-run federated credentials, policy sets and drift detection. A self-managed S3, Azure Storage or GCS backend is cheaper, but you build access logging, policy and drift jobs yourself.
- Terraform or OpenTofu: OpenTofu's built-in state encryption helps where secrets cannot yet leave state; Terraform's write-only arguments avoid storing many secrets at all. Use one tool per estate so policy stays consistent.
- Static scanning or plan policy: start with a pull request scanner, which needs no cloud access; add plan-time policy as modules and variables start hiding the values that matter.
- Small or large: a small team is well served by one remote backend, one federated apply role per environment, a pull request scanner and a weekly drift job. Large estates add a private registry, per-team workspaces and roles, and central policy sets with drift metrics per team.
Detection and measurement
- State access: log and alert on reads of production state objects by anything other than pipeline roles (CloudTrail S3 data events, Azure Storage diagnostic logs, Cloud Audit Logs data access, HCP Terraform audit trail), and on changes to the state bucket policy or encryption key policy.
- Pipeline identity misuse: alert when the apply role is used outside a pipeline run or from an unexpected network.
- Out-of-band changes: production write calls not made by the pipeline identity, and every policy override.
Metrics:
- Share of production resources managed by IaC (target: rising towards all, with exceptions listed).
- Number of static cloud credentials used by pipelines (target: zero).
- Open drift findings in production and median days to resolve.
- Share of module sources from the private registry with exact version pins.
- Blocking policy failures per 100 runs and active suppressions past expiry.
Common failure patterns
- State in the wrong place:
terraform.tfstatecommitted to a repository, or a state bucket readable by every developer, exposes every generated password. Public repositories leak cloud keys for months, as the CISA contractor GitHub leak of AWS GovCloud administrator keys showed. - Trusting
sensitive: it only hides values from CLI output; they are still in state and plan. - One god-mode pipeline key: a long-lived administrator key shared by every workspace and reachable from pull request builds.
- Floating modules: Git module sources with no
refor a movable tag, so a change in another repository silently alters production on the next apply. - Scanner theatre: scans never block, or block everything and get suppressed.
- Drift ignored: emergency console fixes are never written back, so the next apply reverts them.
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | CIS Controls v8.1 | ISO/IEC 27001:2022 Annex A | NIST CSF 2.0 |
|---|---|---|---|---|
| Baseline templates and secure modules | CM-2, CM-6 | 4.1 | A.8.9 | PR.PS-01 |
| Reviewed changes, plan and apply gates | CM-3, SA-10 | 16.1 | A.8.32, A.8.25 | PR.PS-01 |
| Pipeline identity least privilege | AC-6, IA-5 | 5.5, 6.8 | A.8.2, A.5.15 | PR.AA-05 |
| Secrets out of templates and state | IA-5(7), SC-28 | 3.11 | A.8.24, A.5.17 | PR.DS-01 |
| Drift detection and inventory | CM-8, SI-7 | 1.1, 4.1 | A.8.9, A.5.9 | DE.CM-09 |
| Modules and providers as supply chain | SR-3, SR-11 | 16.4 | A.5.21 | GV.SC-07 |
NIST SP 800-204D's strategies for supply chain security in CI/CD pipelines apply directly to infrastructure pipelines.
Practitioner checklist
- Move all state to an encrypted, versioned, locked remote backend with a customer managed key, and search repositories for committed state and
.tfvarsfiles. - Limit state read access to pipeline roles and a named admin group, split per environment, and alert on other reads.
- Replace static cloud keys in pipelines with OIDC federation, with a read-only plan role and an approval-gated apply role scoped by run phase.
- Commit
.terraform.lock.hclwith hashes for every platform and pin modules to exact versions or commit SHAs. - Publish approved modules with secure defaults in a private registry, each with an owner and versioned changelog.
- Run an IaC scanner on every pull request and plan-time policy before every apply; promote rules from advisory to blocking after tuning.
- Schedule drift detection daily for production and resolve findings in code within an agreed time.
- Use ephemeral values, write-only arguments, dynamic references or
@secure()to keep secrets out of state, outputs and logs.
Standards and references
- HashiCorp: Sensitive data in state
- HashiCorp: Terraform S3 backend
- HashiCorp: Dependency lock file
- HashiCorp: Dynamic provider credentials
- HashiCorp: Health assessments
- AWS: Detect unmanaged configuration changes to stacks and resources with drift detection
- Microsoft Learn: Manage secrets by using Bicep
- NIST: SP 800-204D Strategies for the Integration of Software Supply Chain Security in DevSecOps CI/CD Pipelines
Related NHI Mgmt Group resources: Cloud Misconfiguration Guide · CI/CD Pipeline Identity Security Guide · Secrets Management Guide · Software Supply Chain Security Guide · CNAPP Guide