Security Best Practices and Secrets Management
Security Best Practices and Secrets Management
OpenTofu’s privilege is the privilege of the credentials it holds, plus whoever can read the state. This chapter is how to keep both smaller than “the whole cloud.”
Learning goals
- Encrypt state in transit and at rest, including client-side encryption
- Authenticate providers with roles, not with long-lived keys in files
- Scope the apply role to the resources this root manages
- Notice the audit record you do and do not get from
tofuitself
Securing State Files: Encryption at Rest and in Transit
State contains resource attributes. Those include generated passwords if a resource created one, access keys if a resource created one, and the raw values behind anything you marked sensitive. Assume the state is a secret store even when you tried not to put secrets in it.
Three different locks, and you want the ones that match the backend:
| Control | What it actually does |
|---|---|
| TLS to the backend | Stops a network observer reading the state on the wire. The S3, Azure, and GCS backends use HTTPS |
| Server-side encryption | The bucket or account encrypts bytes at rest. encrypt = true on the S3 backend is this. Turn it on |
| Client-side encryption | OpenTofu encrypts before upload, so the cloud account stores ciphertext. The encryption block in chapter 7. enforced = true so a misconfiguration cannot write plaintext |
| Backend IAM | Who may GetObject and PutObject. This is the real access list |
| Versioning | Recovers from a bad write. It also retains old secrets until the versions expire. Expire on purpose |
A local terraform.tfstate on a laptop is an unencrypted secret if the disk is not encrypted, and a stolen secret if the laptop is. Do not leave production state local “just for the migration” longer than that migration’s window.
Plans are state-shaped. tofu plan -out=tfplan writes sensitive values into the plan file. Delete it after apply. Do not archive plans in the build artifacts without the same encryption as state.
Managing Secrets: Never Hardcoding Credentials
A credential in a .tf or .tfvars file will be committed, or copied into a plan log, or both. Patterns that hold up:
- Provider credentials from the environment or from a workload identity, never from a
variablethat is stored in tfvars - Application secrets generated by a secrets manager resource, or written into the secrets manager by its own API, not interpolated into a user-data string that then lands in state in clear text
sensitive = trueon the variable so logs redact it. Remember the flag is not encryption- Ephemeral values and write-only arguments, which OpenTofu 1.11 understands, when the provider offers them. A write-only argument is not stored back into state. Prefer it for a password the provider can accept that way
- A secret that must be passed as a variable comes from
TF_VAR_...injected by the pipeline for that job, and is not echoed
If a secret has already been committed, rotate it. Deleting the file in a follow-up commit does not remove it from history. Treat the old value as public.
Provider Authentication Patterns: IAM Roles, Service Principals, Workload Identity
The apply process assumes a role that exists only for that run.
AWS. A pipeline’s OIDC token assumes an IAM role. No static access key. Locally, a human assumes a role with a short session. The provider block stays region only.
Azure. Workload identity federation or a managed identity for the runner. A service principal with a client secret is the legacy path. If you still have the secret, it lives in the pipeline secret store and is injected as environment variables the azurerm provider already understands.
GCP. Workload identity federation from the pipeline, or the attached service account on the runner. GOOGLE_APPLICATION_CREDENTIALS pointing at a JSON key on disk is the pattern to retire.
On-premises appliances. Use the provider’s token with the same rule: short-lived, injected, scoped to the device the root manages. A shared break-glass password in a provider block is how the network team and the OpenTofu state end up with the same god credential.
Humans and pipelines use different roles. A human role might plan in production and apply only in development. The pipeline role applies in production and cannot be assumed from a laptop. Chapter 13 is where that split sits in the workflow.
Least Privilege for OpenTofu Execution: IAM Policies and RBAC
The apply role can create and destroy whatever the configuration mentions, and anything the provider does as a side effect of those calls. Scope it.
- One role per root module, not one role for the whole company
- Permissions for the resource types that root manages, in the accounts it manages
- A separate, smaller permission set for state: read and write of one bucket prefix, plus the lock, and nothing else. The state role and the infrastructure role can be the same principal in a small team. They should still be two policies you can read
- No permission to edit the state bucket’s own policy from the infrastructure role. Otherwise a bad plan can lock you out, or open the state
- Plan in CI can often use the apply role if you accept that plan-time data sources run with apply power. Say so. A read-only plan role is better when data sources allow it, and some providers do not plan cleanly as read-only. Test that before you promise it
Review the role when the configuration gains a new resource type. A pipeline that applies with AdministratorAccess will work and will teach nothing about the blast radius.
Audit Logging and Compliance: Tracking Who Changed What and When
OpenTofu writes a plan and a state serial. It is not an audit log. The audit log is the combination of:
| Record | Question it answers |
|---|---|
The git commit that changed the .tf |
Who asked for the change, and what the text was |
| The pipeline run that applied a saved plan | Who approved, when, and which plan digest |
| Cloud audit logs (CloudTrail, Activity Log, Cloud Audit Logs) | Which API calls the role made |
| Backend access logs | Who read or wrote the state object |
tofu show of the plan artifact, if you retain it under the state encryption rules |
What the apply was predicted to do |
Retain those where your compliance regime says to retain them. A chat message that says “I applied” is not a record. A pipeline that applies from a laptop using a shared user is not a record either.
State encryption does not remove the need for access logs. It changes what a stolen object reveals. Someone who can assume the apply role can still read the decrypted view by running tofu state pull with the key. Log that too if the backend and the KMS allow it.
Common Security Pitfalls: Exposed Keys, Overly Permissive Policies, and Insecure Backends
| Pitfall | What it looks like | The fix |
|---|---|---|
A key in terraform.tfvars |
The next commit, or a plan log | Environment or workload identity. Rotate the key that leaked |
| State in git | terraform.tfstate in the repository history |
Backend, rotate anything the file held, purge history if the repo is shared |
| A public state bucket | The backend ACL or a policy of Principal: * |
Block public access. Review who GetObject allows |
| No lock | Two applies, a corrupt serial | use_lockfile or the backend’s lock. Refuse to apply without it |
enforced left off |
One init writes plaintext and you do not notice | enforced = true after the migration |
| Apply with an admin role | A bad module creates a second admin | A role per root, reviewed when resources change |
| A plan file as a build artifact | The artifact store becomes a second state bucket | Encrypt or delete. Do not keep plans next to unrelated logs |
Shared default workspace |
The production apply runs wherever the CLI last sat | Separate roots, or an explicit workspace select that fails closed |
Summary
- State and plans hold secrets. TLS, server-side encryption, client-side encryption, and IAM are four different controls
- Credentials come from a role the pipeline assumes. They are not file contents
- One apply role per root, plus a tight state policy
- Git, the pipeline, and cloud audit logs together are the audit trail. The CLI is not
- A leaked key is rotated. It is not merely deleted in the next commit