Skip to content

Secrets and Credential Management

Provider authentication should come from a role or an identity mechanism rather than a literal key typed into a file, and even pulling an application secret from a real secrets manager at apply time does not fully keep it out of Terraform state.

Never hardcode credentials in a provider block

Section titled “Never hardcode credentials in a provider block”

It is possible to authenticate the AWS provider with a literal access_key and secret_key written directly into the provider "aws" block or into a .tfvars file. It also works, in the narrow sense that terraform plan runs. It is still wrong:

# Anti-pattern — never do this
provider "aws" {
region = "us-east-1"
access_key = "AKIAIOSFODNN7EXAMPLE"
secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}

A key written in a .tf file gets committed to git the first time someone forgets it is there, and it lives on in every clone and every commit in history from that point forward, whether or not it is later deleted from the current revision. The fix is to give the provider no credentials at all in configuration, and let it resolve them from the environment it is running in:

# The provider block declares no credentials.
# Authentication comes from the environment: local env vars,
# an EC2/CI instance role, or OIDC federation in CI.
provider "aws" {
region = "us-east-1"
}

Locally, that usually means environment variables or a named profile from aws configure. In CI, it means the OIDC-federated role assumption covered in the CI/CD lesson — aws-actions/configure-aws-credentials populates the standard AWS environment variables before Terraform ever runs, so the provider block finds credentials without a single key ever being written down anywhere.

Pulling a secret in via a data source, not a variable

Section titled “Pulling a secret in via a data source, not a variable”

A separate question from provider authentication is application secrets — a database password, a third-party API key — that a resource needs as an argument. Defining that secret as a Terraform variable makes the variable declaration the thing you have to treat as the source of truth, which means someone still has to type the real value into a tfvars file, a CI secret, or a prompt at apply time.

The better pattern reads the current value directly from a real secrets manager through a data source, at apply time:

data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/third-party/api-key"
}
resource "aws_lambda_function" "webhook_handler" {
function_name = "webhook-handler"
runtime = "nodejs20.x"
handler = "index.handler"
environment {
variables = {
API_KEY = data.aws_secretsmanager_secret_version.api_key.secret_string
}
}
}

This is not a free pass, and it is worth being precise about why. The State Management module already covered that marking a variable sensitive = true only redacts it from CLI output — the value still lands in terraform.tfstate in plaintext. Reading a secret through a data source does not fully avoid that either: data source results are cached in state too, so the resolved secret_string still ends up recorded there, the same as a sensitive variable would. What the data-source pattern actually improves is the secret’s lifecycle everywhere else: the real value is never typed into a variable, never sits in a .tfvars file someone might commit by accident, and rotating it in Secrets Manager needs no Terraform change at all — the next apply simply reads whatever is current. Secrets Manager is the source of truth; Terraform just reads from it. Protecting the state backend, covered in the State Management module, is still the part that cannot be skipped.

A .gitignore checklist for a Terraform/Terragrunt repository

Section titled “A .gitignore checklist for a Terraform/Terragrunt repository”

A short, deliberate .gitignore prevents most of the accidents above from ever reaching git in the first place:

# Terraform / Terragrunt .gitignore
# Never commit state — it can contain plaintext secrets and is the
# canonical source of truth; committing it invites drift and leaks.
*.tfstate
*.tfstate.backup
# Local provider and module cache, regenerated by `terraform init`.
.terraform/
# May contain sensitive values depending on the file — judge case by
# case rather than blanket-ignoring every *.tfvars in the repo.
*.tfvars
# Terragrunt's local working-directory cache, regenerated automatically.
.terragrunt-cache/

A few of these deserve a one-line reason each:

  • *.tfstate and *.tfstate.backup — never commit state. Beyond the size and merge-conflict problems, state can contain plaintext secrets, as covered above, and it duplicates the job a remote backend already does properly.
  • .terraform/ — this is init’s local cache of downloaded providers and modules. It is large, machine-specific, and fully reproducible from .terraform.lock.hcl, so there is nothing worth versioning here.
  • *.tfvars — treat this one case by case rather than blanket-ignoring it. Some teams deliberately commit a non-sensitive environment.tfvars that just sets instance sizes or region names; the rule is “does this specific file contain a secret,” not “all .tfvars files are always secret.”
  • .terragrunt-cache/ — Terragrunt’s equivalent of .terraform/, one per unit, regenerated on every run. It never needs to be committed, and committing it by accident tends to bloat a repository fast.
flowchart LR
  bad["Hardcoded credential in a .tf / .tfvars file"] -->|committed by accident| leak["Leaked secret in git history"]
  sm["Secrets Manager: source of truth"] -->|data source at apply time| attr["Resource attribute"]
  attr -->|still recorded| state["terraform.tfstate"]
A hardcoded credential in a committed file versus a secrets manager as the real source of truth, read at apply time
What is current best practice for authenticating the AWS provider in a CI pipeline
Why is pulling a secret from a secrets manager via a data source usually better than defining it as a Terraform variable
What nuance still applies even after switching a secret from a Terraform variable to a secrets manager data source
Which of these belongs in a Terraform/Terragrunt repository gitignore, and why