Terraform Secrets Management
Overview
Secrets — database passwords, API keys, TLS certificates — must be handled carefully in Terraform. The state file and plan output can expose sensitive values if not properly managed. This reference covers strategies for minimizing secret exposure, from basic variable sensitivity to IAM-based authentication that eliminates secrets entirely.
The secret management problem
Terraform stores resource attributes in the state file. If you pass a database password through a variable, that password ends up in:
terraform.tfstate— The state file contains resource attributes, including passwords.- Plan output —
terraform planprints the planned changes, which may include sensitive values. - Environment variables —
TF_VAR_*values can leak through process listings and CI logs.
Each of these surfaces increases the attack footprint. The goal of secret management in Terraform is to minimize or eliminate plaintext secrets at every layer.
Strategy 1: Mark variables sensitive
The simplest mitigation — hide values from plan/apply output:
variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}
resource "aws_db_instance" "mysql" {
# ...
password = var.db_password
}
What sensitive = true does:
- Hides the value in
terraform planandterraform applyoutput. - Hides the value in
terraform output(when output is also marked sensitive). - Does NOT prevent the value from being stored in the state file in plaintext.
Set via environment:
export TF_VAR_db_password="supersecret"
terraform apply
Limitation: The password is still stored in terraform.tfstate as a resource attribute. Anyone with access to the state file can read it.
Strategy 2: Encrypt state at rest
Always use a remote backend with encryption:
terraform {
backend "s3" {
bucket = "terraform-state"
key = "prod/terraform.tfstate"
encrypt = true # S3 server-side encryption
dynamodb_table = "terraform-lock"
}
}
And restrict access to the state bucket:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::terraform-state",
"arn:aws:s3:::terraform-state/*"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::123456789:role/terraform-ci-role"
]
}
}
}
Strategy 3: KMS-encrypted secrets in files
Encrypt secrets with AWS KMS before they reach Terraform, storing only the ciphertext:
# Encrypt a secret (one-time setup)
aws kms encrypt \
--key-id alias/db-creds \
--plaintext fileb://db-creds.yml \
--output text \
--query CiphertextBlob > db-creds.yml.encrypted
# Read the encrypted file
data "aws_kms_secrets" "db" {
secret {
name = "db"
payload = file("${path.module}/db-creds.yml.encrypted")
}
}
# Decode and use
locals {
db_creds = yamldecode(data.aws_kms_secrets.db.plaintext["db"])
}
resource "aws_db_instance" "mysql" {
username = local.db_creds["username"]
password = local.db_creds["password"]
}
How it works:
- Secrets are encrypted with KMS and stored as ciphertext in your repo.
- Terraform uses the
aws_kms_secretsdata source to decrypt at plan/apply time. - Only principals with KMS
kms:Decryptpermission can read the secrets.
Limitation: After decryption, the plaintext values appear in local values, which are stored in state. The state file still contains plaintext secrets.
Strategy 4: AWS Secrets Manager
Secrets Manager stores, rotates, and audits secrets — Terraform reads from it rather than holding the secret itself:
# Store the secret (one-time via CLI, outside Terraform)
aws secretsmanager create-secret \
--name db-creds \
--secret-string '{"username":"admin","password":"supersecret","host":"db.example.com"}'
# Read secret at plan/apply time
data "aws_secretsmanager_secret_version" "db" {
secret_id = "db-creds"
}
locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}
resource "aws_db_instance" "mysql" {
username = local.db_creds["username"]
password = local.db_creds["password"]
}
Advantages over KMS-encrypted files:
- Secrets Manager handles automatic password rotation.
- Integrated with CloudTrail for detailed audit logs.
- Can use resource-based policies to restrict access.
Limitation: Like KMS, the decrypted values end up in state. Secrets Manager reduces but doesn't eliminate state exposure.
Strategy 5: Avoid secrets entirely with IAM roles
The best secret is no secret. For EC2 instances and other compute resources, use IAM roles instead of hardcoded credentials:
# IAM role for EC2
resource "aws_iam_role" "ec2_role" {
name = "ec2-app-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# Attach the policy that grants needed permissions
resource "aws_iam_role_policy" "app_permissions" {
name = "app-permissions"
role = aws_iam_role.ec2_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:*", "dynamodb:*"]
Resource = ["*"]
}]
})
}
# Attach the role to an instance via an instance profile
resource "aws_iam_instance_profile" "app" {
name = "app-profile"
role = aws_iam_role.ec2_role.name
}
resource "aws_instance" "app" {
iam_instance_profile = aws_iam_instance_profile.app.name
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed
}
How it works:
- The EC2 instance assumes the role automatically.
- The AWS SDK on the instance uses instance metadata to get temporary credentials.
- No long-lived credentials stored anywhere in Terraform configuration or state.
Strategy 6: OIDC for CI/CD
For CI/CD pipelines (GitHub Actions, GitLab CI), use OpenID Connect instead of long-lived IAM user credentials:
# Create the OIDC provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}
# Fetch GitHub OIDC certificate thumbprint
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com"
}
# IAM role that GitHub Actions can assume
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = [
for repo in var.allowed_repos : "repo:${repo.org}/${repo.repo}:*"
]
}
}
}]
})
}
GitHub Actions workflow:
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-terraform-role
aws-region: us-east-2
- run: terraform apply
Why OIDC:
- No long-lived AWS credentials stored in GitHub secrets.
- Each repository gets short-lived tokens scoped to its specific needs.
- Can restrict by repository, branch, or environment.
Best practices summary
| Practice | Effect |
|---|---|
sensitive = true on all password/secrets variables | Hides values from plan output and logs. |
encrypt = true on S3 backend | State file encrypted at rest. |
| Restrict state bucket access via IAM policy | Only CI/CD roles and trusted admins can read state. |
| Use Secrets Manager or KMS for persistent secrets | Reduces plaintext copies in config files. |
| Use IAM roles for compute resources | Eliminates credentials from EC2 instances, Lambda functions, etc. |
| Use OIDC for CI/CD | Eliminates IAM user credentials from CI systems. |
Never commit .tfvars with secrets to version control | Use environment variables or secrets managers instead. |
| Rotate secrets regularly | Secrets Manager can automate rotation for RDS and other services. |
See also
- Managing Terraform State — state encryption and access control
- Working with Multiple Providers — cross-account IAM roles