Security & Compliance

Zero-Plaintext Secret Architecture

Why sensitive = true doesn't protect secrets in state, how to keep database passwords out of Terraform entirely, passing secret ARNs instead of values, and replacing static access keys with OIDC roles.

Module: 04 of 11 Level: Security & Compliance Time: 8 min read Updated:
All modules in this track (11)

In this module, you'll learn to:

  • Explain why sensitive = true doesn't keep a secret out of state
  • Keep database passwords out of Terraform, and pass secret ARNs instead of values
  • Replace static access keys with OIDC roles, and harden access to state

Before you start: read State Isolation, Remote Locking & Blast Radius Control. This module builds on its state bucket protections.

Principle

The sensitive = true misconception

Terraform provides a sensitive = true attribute for variables and outputs:

variable "db_password" {
  type      = string
  sensitive = true
}

output "connection_string" {
  value     = "postgresql://admin:${var.db_password}@db.example.com"
  sensitive = true
}

The catch

Marking a value sensitive = true only hides it from the terminal and CI logs.

It does not protect it in the state file. Open terraform.tfstate and the password is there in plain JSON. Anyone who can read the state bucket can read every secret Terraform has touched.

So the goal is simple: keep secrets out of Terraform altogether wherever you can.

Pattern

Let AWS create and keep the password

Never pass production passwords in through terraform.tfvars. For RDS and Aurora, let AWS create the master password and keep it in Secrets Manager for you:

resource "aws_rds_cluster" "primary" {
  cluster_identifier            = "prod-aurora-cluster"
  engine                        = "aurora-postgresql"
  master_username               = "dbadmin"
  manage_master_user_password   = true # RDS creates and rotates it in Secrets Manager
  master_user_secret_kms_key_id = aws_kms_key.secrets.arn
  # ...
}

Terraform never sees the password, so it never reaches state. Applications read it at runtime from Secrets Manager with their own IAM role (or EKS Pod Identity), and no engineer needs to know it.

Why not random_password? A random_password resource is better than a typed-in password, but its result is still stored in state. Use it only where the service can't manage its own secret, and protect the state bucket as in Module 02.
Pattern

Pass references, not values

Reading a secret into Terraform puts it in state too. A data "aws_secretsmanager_secret_version" lookup stores the secret's value in the state file, exactly like a variable would. Where the consumer can fetch the secret itself, give it the secret's ARN and let it read the value at runtime:

# AVOID: the password lands in state and in the job's arguments
default_arguments = {
  "--db_password" = data.aws_secretsmanager_secret_version.db.secret_string
}

# PREFER: the job reads the secret at runtime with its own IAM role
default_arguments = {
  "--db_secret_arn" = aws_secretsmanager_secret.db_credentials.arn
}

The workload's IAM role is then granted secretsmanager:GetSecretValue on that one secret, and nothing secret passes through Terraform at all.

The same rules hold everywhere: no secrets in terraform.tfvars, in variable defaults, or in any .tf file. Any variable that genuinely has to carry a sensitive value is declared sensitive = true, with the caveat above that this only hides it from output.

Identity

Eliminate static AWS access keys

A common anti-pattern is creating static aws_iam_access_key resources to give services or CI/CD pipelines access to AWS:

# ANTI-PATTERN: Generating static keys in Terraform
resource "aws_iam_user" "ci" {
  name = "github-actions-deployer"
}

resource "aws_iam_access_key" "ci" { # Stored in plaintext state!
  user = aws_iam_user.ci.name
}

The alternative: OIDC federation

CI pipelines such as GitHub Actions, GitLab CI, and CircleCI can assume a temporary IAM role through OIDC federation, with no long-lived keys at all:

# Secure OIDC Trust Policy for GitHub Actions
resource "aws_iam_role" "ci_deployer" {
  name = "github-actions-terraform-deployer"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com"
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
            # only the main branch of this one repository can assume the role
            "token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}
State Defense

Hardening remote state access

Some sensitive values will always end up in state, so protect the bucket as well (the full checklist is in Module 02):

  • A dedicated KMS key: only the CI/CD roles and the read-only plan roles may decrypt state.
  • Network limits: allow state reads only through your VPC endpoint or from approved CI runners.
  • Access logging: turn on S3 data events in CloudTrail, and alert on unexpected GetObject calls to *.tfstate.
Recap

Key terms

  • sensitive = true: hides a value in CLI and CI output only. It's still plain text in state.
  • manage_master_user_password: RDS or Aurora creates the master password and keeps it in Secrets Manager. Terraform never sees it.
  • random_password: better than a typed-in password, but its result is still stored in state.
  • Secret reference: a secret's ARN, passed instead of its value. The workload reads the value at runtime with its own IAM role.
  • OIDC federation: a pipeline assumes a short-lived IAM role, limited by audience and subject, instead of using access keys.
Check yourself

Pop quiz

Five questions: three on the ideas in this module, and two scenarios where you apply them. The order changes every time you take it, and 4 out of 5 passes.

5 questions. Decide on your answer to each, then check it.

  1. Concept

    A variable is marked sensitive = true. Where can its value still be read?

    1. Nowhere; it's encrypted
    2. In the state file, in plain text
    3. Only in the plan output
    4. Only in CloudTrail
    Show answer

    In the state file, in plain text

    sensitive only hides the value from terminal and CI output. Anyone who can read the state can read it.

  2. Concept

    How does manage_master_user_password = true keep the database password out of state?

    1. RDS creates the password and keeps it in Secrets Manager, so Terraform never handles it
    2. Terraform encrypts it before writing state
    3. It moves the password to SSM Parameter Store
    4. It marks the password as sensitive
    Show answer

    RDS creates the password and keeps it in Secrets Manager, so Terraform never handles it

    A value Terraform never receives can't end up in state. Applications read it from Secrets Manager at runtime.

  3. Concept

    In the GitHub Actions OIDC trust policy, what does the sub condition limit?

    1. The AWS region
    2. Which repository and branch may assume the role
    3. How long the session lasts
    4. Which IAM user created the role
    Show answer

    Which repository and branch may assume the role

    repo:my-org/my-repo:ref:refs/heads/main means only the main branch of that one repository can assume the role.

  4. Scenario

    A Glue job gets its database password from data "aws_secretsmanager_secret_version", passed in default_arguments. What's wrong, and what's the fix?

    1. Nothing; a data source only reads
    2. Mark the argument sensitive = true
    3. The password lands in state; pass the secret's ARN and let the job's role read it at runtime
    4. Move the password into terraform.tfvars
    Show answer

    The password lands in state; pass the secret's ARN and let the job's role read it at runtime

    Reading a secret into Terraform stores its value in state. Pass the ARN and grant the job's role secretsmanager:GetSecretValue on that one secret.

  5. Scenario

    A pipeline deploys with an IAM user whose aws_iam_access_key is created in Terraform. What do you replace it with?

    1. The same key, rotated every 90 days
    2. The key stored as a CI secret
    3. An IAM role the pipeline assumes through OIDC, with no long-lived keys
    4. The key output marked sensitive
    Show answer

    An IAM role the pipeline assumes through OIDC, with no long-lived keys

    The access key sits in state in plain text and never expires. OIDC gives the pipeline short-lived credentials instead.

Questions about this learning path?

I'm happy to go into more detail on any of this: module design trade-offs, moving state safely, testing modules, or running a security gate in CI/CD.

anupojuprudhvi@gmail.com