How to run a Terraform audit: The Four-Layer Framework and Tools that Help
A Terraform audit that starts and ends with login logs isn't an audit. Code, run history, state, and access control are four separate surfaces with four separate failure modes, and treating any one of them as the whole picture is how a real incident goes undetected until it gets found by the auditor.
You might run a Terraform audit for several different reasons. Your security team may be preparing evidence for SOC 2, or PCI DSS may need proof that every production change was reviewed and authorized.
An engineer might be doing incident response, tracing an unauthorized change back to its origin, and want to see exactly what ran, when, and who approved it.
Someone evaluating a new Terraform CI/CD platform might want to know whether it keeps a record worth handing to an auditor, rather than just a UI to scroll through.
All three are legitimate, but none provide you with all the information you could get from a Terraform audit.
In this article, we explain why a Terraform audit should cover four separate, independently verifiable surfaces: the code itself, the run lifecycle that executes it, the state file that results, and the backend and access layer that controls who can touch any of it.
Each surface can fail in its own way, and a clean result on one tells you nothing about the others. By the end, you'll have a specific list of what a Terraform CI/CD tool with audit logs should actually capture, and a step-by-step process you can run against your own infrastructure.
What is a Terraform audit?
Terraform itself keeps no audit trail. Set TF_LOG=DEBUG, and you'll get a firehose of internal provider calls and HTTP traffic, useful for debugging a stuck apply, but useless for proving who changed a security group a week ago.
That log is about the binary's own behavior, not about the humans and pipelines directing it. If the only proof your organization can offer that a change was authorized is a Terraform debug log, you don't have an audit trail.
What you actually need breaks into four layers:
- Code. The
.tffiles, modules, and policy definitions themselves, audited before they ever reach a plan. - Run. The lifecycle of an individual execution and the audit events inside it: who triggered it, what the plan proposed, who approved the
apply, and when each step happened. - State. The file that records what Terraform believes exists, and everything that can go wrong with how it's stored and protected.
- Backend and access. Who can read or write any of the above, and whether that access is actually scoped to what a person or pipeline needs.
A pipeline with a spotless run history can still apply code nobody scanned for an open security group. A locked-down backend doesn't tell you whether the state has drifted from reality. Treat these as four checklists, not one.
Auditing the Terraform code before it ever plans
The cheapest place to catch a problem is here, before anything has touched real AWS resources. terraform init, terraform validate, and terraform fmt -check are worth running, but they check syntax and formatting, not security posture; a perfectly valid, perfectly formatted configuration file can still open a security group to the internet.
Static analysis closes that gap. Scanning every .tf file with third-party tools for known-bad patterns – public storage buckets, missing encryption, overly permissive IAM roles, and hardcoded credentials – before a pull request merges turns a security review from a manual read-through into an automated gate.
The output you will get is a specific, addressable finding tied to a file and line, not a vague sense of a missed step.
Policy as code is the layer above that: instead of flagging a pattern, it evaluates the actual plan output and blocks anything that violates an organizational guardrail before it reaches apply. A short Open Policy Agent rule illustrates the shape of it, evaluated against the JSON output of terraform show -json:
That policy doesn't care whether the engineer who wrote the configuration meant to expose the bucket. It fails the run either way, which is the result you want: enforcing security policies at the plan stage removes the question of intent entirely.
The last part of a code audit is provenance, not just content. An unpinned Terraform module is a dependency you've agreed to re-audit every time its maintainer pushes a change, whether you noticed or not.
Pin every one of your Terraform modules and providers to a specific version, review the diff before bumping it, and treat a module sourced from outside your organization's registry with the same scrutiny you'd give an unfamiliar library in application code.
What the run history actually has to capture
Git history shows you what was proposed, but it does not show you what actually executed against production infrastructure, who approved it, or whether the plan that ran matched the one that was reviewed.
A pull request can sit approved for three days while the underlying configuration drifts, or a plan can be regenerated between review and apply with nobody re-checking the diff.
A usable run record needs, at minimum, these event types:
- The user or service identity who triggered the run
- The source of the trigger: a pull request, a scheduled job, or a manual invocation)
- The plan output itself
- The identity who approved the apply
- A timestamp on every log entry
Failed authentication attempts belong in the same record, not a separate one; an attacker probing your pipeline's login page is exactly the kind of event a security team needs surfaced next to the runs they already watch, not buried in a different system nobody checks on the same cadence.
Administrative actions deserve equal billing with the runs themselves: permission changes, workspace or project configuration changes, and who added or removed a teammate's access.
These changes rarely touch infrastructure directly, so they're easy to leave out of a "run log" that's scoped too narrowly to the apply step. An auditor reviewing infrastructure changes for a production environment will ask about both the changes and who was allowed to make them. A tool that only logs the former leaves half the question unaddressed.
Why the state file is evidence, not a log
Terraform's state is a point-in-time snapshot of what the tool believes exists, built from the last successful apply and nothing before it.
Run terraform plan, and you're comparing that snapshot against your configuration right now, which is the most basic form of drift detection available and worth running on a schedule, not just before you intend to change something.
State can't tell you about the past. If someone created a resource through the console, ran a one-off CLI command, or used a different provisioning tool entirely, Terraform has no record of it ever happening: the resource was never part of a plan or apply in the first place.
State is a mirror of the present, not a ledger of events. You need to be able to tell when something started drifting, and state cannot give you that information.
The Terraform state file is also a liability. It stores resource attributes in plaintext by default, which means a generated database password can sit in the same file as a mundane instance ID, equally exposed, protected only by whatever access controls the backend happens to enforce.
Terraform's ephemeral values and write-only arguments are the current best fix for that, keeping a secret out of state entirely rather than relying on sensitive = true to hide it from CLI output (which it does, without encrypting anything underneath).
OpenTofu took a different approach to the same problem with native state encryption, protecting the whole file at rest rather than individual values. Either approach beats the alternative, which is finding out what your state file contains by grepping the raw JSON after the fact.
Backend access control decides who can touch what
A backend audit asks where the state lives, who can read or write it, and whether versioning and locking are actually turned on, not just documented somewhere as a policy.
Remote state storage in an object store with encryption at rest, state locking to prevent two runs from racing each other, and versioning so a corrupted or maliciously altered state can be rolled back are the foundation you should have, not an advanced configuration:
Credentials are the other half of this. Configure Vault or AWS Secrets Manager to issue short-lived credentials to your runner rather than storing long-lived AWS credentials in a CI variable, and scope whatever role your pipeline assumes to exactly the actions it needs:
That policy grants exactly one capability: assume one specific role, scoped to one environment. It's a small example, but it's the entire principle of least privilege in miniature, and it's worth asking whether your current pipeline credentials look anything like it or whether they're a broad administrator role you are yet to narrow.
Where resource-level access changes what you can audit
Every backend-level control above eventually runs into the same limit: Terraform has no concept of partial access to a state file. If an identity can write it, that identity can change every resource inside it, and terraform_remote_state requires full read access to the entire state snapshot just to fetch a handful of declared outputs, which is why you need to think about how to protect anything sensitive.
Bucket-level RBAC can tell you whether an identity can touch the state at all, but it can never tell you which resources within it, or whether that identity is only previewing the change versus actually committing it.
Stategraph's capability-based access control scopes grants to the resource, module, or state level within the dependency graph itself, rather than to the bucket the state happens to sit in, and it separates preview from commit as distinct grants, so a reviewer can be handed plan-only access while apply stays with the people who own the change.
Stategraph stores that graph in a database and resolves the actual dependency graph behind a change, so it can also show the full blast radius of a change across state boundaries, which resources downstream were actually reached, not just which run executed.
A bucket policy can't express this at all: cross-state propagation isn't a permissions question a single backend's ACL was ever built to answer.
What to actually look for in a Terraform CI/CD tool with audit logs
If you're evaluating a Terraform CI/CD tool to run things for your team, audit logging might not be the first priority you consider. However, there are a few criteria that are worth confirming before you commit to one:
Logs should be exportable, not just viewable
Logs need to be append-only and exportable to CSV or JSON, not just viewable in a UI you'd have to screen-record for an auditor; a queryable audit trail API beats both.
Retention is a number you should already know
Retention should be a number you know, not a question you ask support mid-audit; the default retention window on a queryable audit API is often shorter than teams assume, sometimes measured in days rather than months, so confirm it before you need six months of history and discover it was never kept.
Streaming is delivery, not retention
A live feed of events into a tool you already use (a SIEM, Datadog, or an event bus) is genuinely useful, because it lets you see a Terraform run alongside the application alert it caused.
That feed only moves events from one place to another, though; it doesn't keep them. If the receiving end goes down, days of events can disappear without anyone noticing, so treat the feed as one half of the setup and an exportable log you control as the other.
Compliance-grade audit trails often sit behind a paywall
Persistent audit trails that satisfy real compliance requirements are frequently locked behind premium editions or an enterprise tier rather than included in every plan, and the mechanism isn't always consistent even within one vendor's product line.
A hosted offering might expose a queryable API gated by edition, while the self-managed version of the same product handles it differently again, through exported service logs rather than an API at all.
Check both the tier and the actual mechanism before assuming the capability is there.
A useful audit log shows blast radius
Look past login-and-run events for something that can show blast radius: which resources a change actually reached, downstream and across state boundaries, not just which workspace executed.
Most platforms in this category don't display this information, and it's the one that turns an audit log from a record of activity into a record of consequence.
Running a Terraform audit step by step
To audit Terraform, pull the four layers above into a process you can actually run, by hand or with dedicated Terraform audit tools wired into the pipeline, rather than a framework you just nod along to.
Here are the 8 steps:
- Version control hygiene. Confirm every
.tffile is tracked,.tfvarswith sensitive values are excluded via.gitignore, branch protection requires review on the main branch, and commit messages explain intent rather than just stating "update." - Static analysis before merge. Run a scanner against every pull request that touches configuration files, and fail the build on high-severity findings rather than leaving them as a comment someone can ignore.
- Secrets scan across history, not just the current branch. A credential committed and later removed is still in the git history unless that history was rewritten; scan the whole repository, not just the latest commit.
- Module and provider provenance. Review where every module comes from, pin every version, and flag anything sourced from outside a vetted registry for a closer look.
- Backend configuration. Confirm encryption, locking, and versioning are enabled, and that you know exactly who holds read and write access to the bucket or equivalent store.
- Plan output review. Run
terraform planand actually read the output for scope as well as drift; a plan proposing to touch forty resources when two config lines changed is worth another look before you approve it. - Access control audit. Check who holds apply access against who actually needs it, and look specifically for standing administrator-level credentials that could be scoped down to a single environment or role.
- Document the findings. Record the date, the tools and versions used, every issue found with its severity, and the remediation status, stored somewhere version-controlled so the audit becomes part of your compliance history rather than a one-off email nobody can find again in six months.
Security vulnerabilities that an audit should always catch
There are a handful of misconfigurations that IaC engineers are more frequently guilty of creating: they're exactly what step 2 above is meant to catch before anything reaches a production environment.
Open security groups and public storage buckets
Open security groups with overly broad ingress rules (0.0.0.0/0 on a port that has no business facing the internet) are the most common, followed closely by storage buckets left publicly readable through a default or forgotten ACL.
Missing encryption
Missing encryption, at rest or in transit, shows up constantly on resources where the provider supports it but the configuration simply never sets the flag.
Overly permissive IAM roles
Overly permissive IAM roles, often a wildcard resource or action added during initial setup and never revisited, compound every other issue on this list by widening what an attacker can reach once they're in.
Hardcoded credentials
Hardcoded AWS credentials or API keys, whether in a .tf file or buried in commit history, remain the single most preventable failure on the list, given how directly steps 2 and 3 above are built to catch them.
These mistakes are common
None of these are exotic: Orca Security's 2025 State of Cloud Security Report found that 17% of organizations have at least one IaC artifact configuring an S3 bucket to grant public read access, and 20% have an IaC-created IAM role that allows cross-account access without requiring MFA or an external ID, the exact two categories step 2 above is there to catch.
The same report found Terraform in use at 65% of organizations surveyed, second only to Dockerfiles among IaC artifact types, so these are not someone else's misconfigurations to worry about.
Static analysis only helps if it blocks
They're the same handful of mistakes a static analysis tool will catch for free if it's actually wired into the pipeline and its findings are treated as blocking rather than advisory, which is the distinction between a team that has this list memorized and a team that's actually enforcing it.
Conclusion
If you run a Terraform audit that only checks a few of these four surfaces, it will pass the parts it checks and miss everything it wasn't instructed to look at.
Code review without run history tells you the configuration was sound on the day it was written, not what actually executed. A locked-down backend without state hygiene tells you who could reach the file, not whether what's inside it still matches reality.
Treat Terraform auditing as a recurring process wired into the pipeline itself, not a once-a-year scramble before a compliance review. The audit should be a byproduct of how your team already works rather than a separate project competing for your attention.
If you can't see exactly which resources a change reached and who was scoped to touch them, down to the resource rather than the state file, try Stategraph free and see what an audit trail looks like when it's built on the dependency graph instead of bolted onto a bucket.
Terraform audit FAQs
Does Terraform have a built-in audit log?
No. The Terraform CLI writes state files and can produce verbose debug output through TF_LOG, but that output describes the binary's internal behavior for troubleshooting, not a record of who ran what or who approved it. An audit trail has to come from whatever platform or pipeline is actually executing Terraform on your team's behalf.
What's the difference between a Terraform audit and a security scan?
A security scan is one input into a Terraform audit, specifically the code layer. The full audit also covers run history (who executed and approved each change), the state file (whether it's protected and whether it still reflects reality), and backend access control (who can read or write any of it). A clean security scan result doesn't tell you anything about the other three categories.
How often should a Terraform audit run?
Continuously, not annually. Static analysis and policy as code belong in every pull request, drift detection belongs on a schedule independent of whether anyone intends to make a change, and access reviews should happen often enough that a departed teammate's credentials don't sit active for months.
What should a Terraform CI/CD tool's audit log actually include?
At minimum: who triggered each run and from where, the full plan and apply output, who approved the apply, failed authentication attempts, and every administrative action (permission and configuration changes) alongside the infrastructure changes themselves.
It should be exportable to CSV or JSON, have a known retention window, and ideally show which resources a change actually reached rather than just which workspace executed.
What's the fastest way to check if a Terraform state file contains secrets?
Pull the state with terraform show or inspect the raw JSON directly and search it for values that look like credentials, connection strings, or tokens; marking an attribute sensitive = true only hides it from CLI output; it does not remove or encrypt it in the underlying file.
If you find secrets already in state, rotate them immediately, since the exposure already happened regardless of how the file is protected going forward.