v0.1.0-draft AI Drafted

Terraform Cloud Hardening Guide

IaC Last updated: 2025-12-14

IaC platform security for workspace variables, team access, and run triggers

Overview

Terraform Cloud state files containing plaintext secrets, cloud provider credentials, and workspace configurations make IaC platforms high-value targets. Vault-backed dynamic credentials via OIDC federation represent best practice for eliminating stored secrets. State file exposure reveals database passwords and API keys; malicious provider backdoors infrastructure.

Intended Audience

  • Security engineers managing IaC platforms
  • Platform engineers configuring Terraform
  • GRC professionals assessing infrastructure compliance
  • DevOps teams implementing secure IaC

How to Use This Guide

  • L1 (Baseline): Essential controls for all organizations
  • L2 (Hardened): Enhanced controls for security-sensitive environments
  • L3 (Maximum Security): Strictest controls for regulated industries

Scope

This guide covers Terraform Cloud security configurations including authentication, access controls, and integration security.


Table of Contents

  1. Authentication & Access Controls
  2. Workspace Security
  3. State File Security
  4. Secrets Management
  5. Monitoring & Detection

1. Authentication & Access Controls

1.1 Enforce SSO with MFA

Profile Level: L1 (Baseline) NIST 800-53: IA-2(1)

ClickOps Implementation

Step 1: Configure SSO (Business)

  1. Navigate to: Organization → Settings → SSO
  2. Configure SAML with your IdP
  3. Enforce SSO for all users

Step 2: Configure Team Tokens

  1. Create team tokens with minimum permissions
  2. Set expiration
  3. Rotate quarterly

1.2 Team-Based Access Control

Profile Level: L1 (Baseline) NIST 800-53: AC-3, AC-6

ClickOps Implementation

Step 1: Define Teams

Team Permissions
owners Full organization access
platform Manage workspaces
developers Plan only (no apply)
read-only View only

Step 2: Assign Workspace Permissions

  1. Navigate to: Workspace → Team Access
  2. Grant minimum permissions per team

Code Pack: Terraform
hth-terraform-cloud-1.02-team-based-access-control.tf View source on GitHub ↗
# --- Team definitions ---
# Owners team is managed by Terraform Cloud automatically; do not recreate it.

resource "tfe_team" "platform" {
  name         = "platform"
  organization = var.tfc_organization

  organization_access {
    manage_workspaces = true
    manage_policies   = false
    manage_providers  = true
    manage_modules    = true
    manage_vcs_settings = false
  }
}

resource "tfe_team" "developers" {
  name         = "developers"
  organization = var.tfc_organization

  organization_access {
    manage_workspaces   = false
    manage_policies     = false
    manage_providers    = false
    manage_modules      = false
    manage_vcs_settings = false
  }
}

resource "tfe_team" "readonly" {
  name         = "read-only"
  organization = var.tfc_organization

  organization_access {
    manage_workspaces   = false
    manage_policies     = false
    manage_providers    = false
    manage_modules      = false
    manage_vcs_settings = false
  }
}

# --- Workspace-level access grants ---
# Platform team: admin on all workspaces
resource "tfe_team_access" "platform" {
  for_each     = var.workspace_ids
  team_id      = tfe_team.platform.id
  workspace_id = each.value
  access       = "admin"
}

# Developers: plan-only on all workspaces (no apply)
resource "tfe_team_access" "developers" {
  for_each     = var.workspace_ids
  team_id      = tfe_team.developers.id
  workspace_id = each.value
  access       = "plan"
}

# Read-only: read on all workspaces
resource "tfe_team_access" "readonly" {
  for_each     = var.workspace_ids
  team_id      = tfe_team.readonly.id
  workspace_id = each.value
  access       = "read"
}
Code Pack: API Script
hth-terraform-cloud-1.02-team-based-access-control.sh View source on GitHub ↗
# Fetch all teams in the organization
TEAMS_RESPONSE=$(tfc_get "/organizations/${TFC_ORG}/teams") || {
  fail "1.02 Unable to retrieve teams for org ${TFC_ORG}"
  increment_failed
  summary
  exit 0
}

TEAM_COUNT=$(echo "${TEAMS_RESPONSE}" | jq '.data | length')
info "1.02 Found ${TEAM_COUNT} team(s) in organization ${TFC_ORG}"

# Audit each team's permissions
echo "${TEAMS_RESPONSE}" | jq -r '.data[] | @base64' | while read -r TEAM_B64; do
  TEAM_JSON=$(echo "${TEAM_B64}" | base64 -d)
  TEAM_NAME=$(echo "${TEAM_JSON}" | jq -r '.attributes.name')
  TEAM_ID=$(echo "${TEAM_JSON}" | jq -r '.id')
  MANAGE_WORKSPACES=$(echo "${TEAM_JSON}" | jq -r '.attributes."organization-access"."manage-workspaces" // false')
  MANAGE_POLICIES=$(echo "${TEAM_JSON}" | jq -r '.attributes."organization-access"."manage-policies" // false')
  MANAGE_VCS=$(echo "${TEAM_JSON}" | jq -r '.attributes."organization-access"."manage-vcs-settings" // false')

  info "1.02 Team: ${TEAM_NAME} (ID: ${TEAM_ID})"

  # Flag overly permissive teams
  if [ "${MANAGE_WORKSPACES}" = "true" ] && [ "${MANAGE_POLICIES}" = "true" ] && [ "${MANAGE_VCS}" = "true" ]; then
    warn "1.02   ${TEAM_NAME} has full org-level access -- review for least privilege"
  fi

  # Check team membership count
  MEMBERS_RESPONSE=$(tfc_get "/teams/${TEAM_ID}/memberships") || {
    warn "1.02   Unable to retrieve members for team ${TEAM_NAME}"
    continue
  }
  MEMBER_COUNT=$(echo "${MEMBERS_RESPONSE}" | jq '.data | length')
  info "1.02   Members: ${MEMBER_COUNT}"

  # Flag empty teams
  if [ "${MEMBER_COUNT}" = "0" ]; then
    warn "1.02   ${TEAM_NAME} has no members -- consider removing"
  fi

  # Check workspace-level access grants
  ACCESS_RESPONSE=$(tfc_get "/teams/${TEAM_ID}/team-workspaces") || {
    warn "1.02   Unable to retrieve workspace access for team ${TEAM_NAME}"
    continue
  }
  WORKSPACE_COUNT=$(echo "${ACCESS_RESPONSE}" | jq '.data | length')
  info "1.02   Workspace grants: ${WORKSPACE_COUNT}"

  # Flag admin access on many workspaces
  ADMIN_COUNT=$(echo "${ACCESS_RESPONSE}" | jq '[.data[] | select(.attributes.access == "admin")] | length')
  if [ "${ADMIN_COUNT}" -gt 5 ]; then
    warn "1.02   ${TEAM_NAME} has admin access on ${ADMIN_COUNT} workspaces -- review for least privilege"
  fi
done

pass "1.02 Team access audit complete"
increment_applied

2. Workspace Security

2.1 Configure Workspace Restrictions

Profile Level: L1 (Baseline) NIST 800-53: CM-3

ClickOps Implementation

Step 1: Execution Mode

  1. Navigate to: Workspace → Settings → General
  2. Configure: Execution Mode: Remote
  3. Enable: Auto-apply: Disabled for production

Step 2: VCS Integration Security

  1. Configure branch protection
  2. Require PR review before apply
  3. Enable speculative plans

Code Pack: Terraform
hth-terraform-cloud-2.01-configure-workspace-restrictions.tf View source on GitHub ↗
resource "tfe_workspace" "hardened" {
  name                  = var.workspace_name
  organization          = var.tfc_organization
  execution_mode        = "remote"
  auto_apply            = false
  speculative_enabled   = true
  file_triggers_enabled = true
  queue_all_runs        = false
  assessments_enabled   = true

  # Require VCS-driven runs only -- block CLI/API applies in production
  dynamic "vcs_repo" {
    for_each = var.vcs_repo_identifier != "" ? [1] : []
    content {
      identifier     = var.vcs_repo_identifier
      oauth_token_id = var.vcs_oauth_token_id
      branch         = "main"
    }
  }
}
Code Pack: API Script
hth-terraform-cloud-2.01-configure-workspace-restrictions.sh View source on GitHub ↗
# Fetch all workspaces in the organization
PAGE=1
TOTAL_WORKSPACES=0
AUTO_APPLY_VIOLATIONS=0
EXEC_MODE_VIOLATIONS=0

while true; do
  WS_RESPONSE=$(tfc_get "/organizations/${TFC_ORG}/workspaces?page%5Bnumber%5D=${PAGE}&page%5Bsize%5D=20") || {
    fail "2.01 Unable to retrieve workspaces for org ${TFC_ORG} (page ${PAGE})"
    increment_failed
    summary
    exit 0
  }

  WS_COUNT=$(echo "${WS_RESPONSE}" | jq '.data | length')
  if [ "${WS_COUNT}" = "0" ]; then
    break
  fi

  echo "${WS_RESPONSE}" | jq -r '.data[] | @base64' | while read -r WS_B64; do
    WS_JSON=$(echo "${WS_B64}" | base64 -d)
    WS_NAME=$(echo "${WS_JSON}" | jq -r '.attributes.name')
    AUTO_APPLY=$(echo "${WS_JSON}" | jq -r '.attributes."auto-apply" // false')
    EXEC_MODE=$(echo "${WS_JSON}" | jq -r '.attributes."execution-mode" // "remote"')
    SPECULATIVE=$(echo "${WS_JSON}" | jq -r '.attributes."speculative-enabled" // false')

    # Check auto-apply -- should be disabled for production workspaces
    if [ "${AUTO_APPLY}" = "true" ]; then
      fail "2.01 ${WS_NAME}: auto-apply is ENABLED -- disable for production workspaces"
      AUTO_APPLY_VIOLATIONS=$((AUTO_APPLY_VIOLATIONS + 1))
    else
      pass "2.01 ${WS_NAME}: auto-apply is disabled"
    fi

    # Check execution mode -- should be remote
    if [ "${EXEC_MODE}" != "remote" ]; then
      warn "2.01 ${WS_NAME}: execution mode is '${EXEC_MODE}' (expected 'remote')"
      EXEC_MODE_VIOLATIONS=$((EXEC_MODE_VIOLATIONS + 1))
    else
      pass "2.01 ${WS_NAME}: execution mode is remote"
    fi

    # Check speculative plans
    if [ "${SPECULATIVE}" != "true" ]; then
      warn "2.01 ${WS_NAME}: speculative plans are disabled -- enable for PR previews"
    fi

    TOTAL_WORKSPACES=$((TOTAL_WORKSPACES + 1))
  done

  # Check for next page
  NEXT_PAGE=$(echo "${WS_RESPONSE}" | jq -r '.meta.pagination."next-page" // empty')
  if [ -z "${NEXT_PAGE}" ]; then
    break
  fi
  PAGE=$((PAGE + 1))
done

info "2.01 Audited ${TOTAL_WORKSPACES} workspace(s)"

if [ "${AUTO_APPLY_VIOLATIONS}" -gt 0 ] || [ "${EXEC_MODE_VIOLATIONS}" -gt 0 ]; then
  fail "2.01 Found ${AUTO_APPLY_VIOLATIONS} auto-apply and ${EXEC_MODE_VIOLATIONS} execution mode violation(s)"
  increment_failed
else
  pass "2.01 All workspaces meet hardening requirements"
  increment_applied
fi
Code Pack: Sigma Detection Rule
hth-terraform-cloud-2.01-configure-workspace-restrictions.yml View source on GitHub ↗
detection:
    selection:
        resource_type: 'workspace'
        action: 'update'
    filter_auto_apply:
        new_values|contains: '"auto-apply":true'
    condition: selection and filter_auto_apply
fields:
    - actor
    - action
    - resource_type
    - resource_id
    - timestamp

2.2 Sentinel Policy Enforcement

Profile Level: L2 (Hardened) NIST 800-53: CM-7

Implementation

Code Pack: Terraform
hth-terraform-cloud-2.02-sentinel-policy-enforcement.tf View source on GitHub ↗
# Sentinel policy: require encryption on S3 buckets
resource "tfe_sentinel_policy" "require_encryption" {
  name         = "require-s3-encryption"
  description  = "Require server-side encryption on all S3 buckets"
  organization = var.tfc_organization
  policy       = <<-SENTINEL
    import "tfplan/v2" as tfplan

    s3_buckets = filter tfplan.resource_changes as _, rc {
      rc.type is "aws_s3_bucket" and
      (rc.change.actions contains "create" or rc.change.actions contains "update")
    }

    encryption_enabled = rule {
      all s3_buckets as _, bucket {
        bucket.change.after.server_side_encryption_configuration is not null
      }
    }

    main = rule {
      encryption_enabled
    }
  SENTINEL
  enforce_mode = "hard-mandatory"
}

# Sentinel policy: deny public access
resource "tfe_sentinel_policy" "deny_public_access" {
  name         = "deny-public-access"
  description  = "Deny public access blocks being disabled on S3 buckets"
  organization = var.tfc_organization
  policy       = <<-SENTINEL
    import "tfplan/v2" as tfplan

    s3_public_access = filter tfplan.resource_changes as _, rc {
      rc.type is "aws_s3_bucket_public_access_block" and
      (rc.change.actions contains "create" or rc.change.actions contains "update")
    }

    all_blocked = rule {
      all s3_public_access as _, block {
        block.change.after.block_public_acls is true and
        block.change.after.block_public_policy is true and
        block.change.after.ignore_public_acls is true and
        block.change.after.restrict_public_buckets is true
      }
    }

    main = rule {
      all_blocked
    }
  SENTINEL
  enforce_mode = "hard-mandatory"
}

# Policy set: attach policies to workspaces via VCS or inline
resource "tfe_policy_set" "security_guardrails" {
  name          = "hth-security-guardrails"
  description   = "HTH security guardrails -- hard-mandatory enforcement"
  organization  = var.tfc_organization
  kind          = "sentinel"
  workspace_ids = var.workspace_ids

  # VCS-backed policy set (recommended for versioned policies)
  dynamic "vcs_repo" {
    for_each = var.sentinel_vcs_identifier != "" ? [1] : []
    content {
      identifier     = var.sentinel_vcs_identifier
      oauth_token_id = var.sentinel_oauth_token_id
      branch         = "main"
    }
  }
}

# Attach individual policies to the policy set
resource "tfe_policy_set_parameter" "encryption_policy" {
  policy_set_id = tfe_policy_set.security_guardrails.id
  key           = "require_encryption"
  value         = "true"
  category      = "sentinel"
}

3. State File Security

3.1 State File Protection

Profile Level: L1 (Baseline) NIST 800-53: SC-28

Rationale

Why This Matters:

  • State files contain plaintext secrets
  • Database passwords, API keys exposed
  • State file = infrastructure blueprint

Attack Scenario: State file exposure reveals database passwords and API keys; malicious provider backdoors infrastructure.

ClickOps Implementation

Step 1: Enable State Encryption

  • Terraform Cloud encrypts state at rest by default
  • Verify encryption settings

Step 2: Restrict State Access

  1. Navigate to: Workspace → Settings → General
  2. Configure: Terraform State: API access restricted
  3. Limit who can view/download state

3.2 Sensitive Variable Handling

Profile Level: L1 (Baseline) NIST 800-53: SC-28

Implementation

Code Pack: Terraform
hth-terraform-cloud-3.02-sensitive-variable-handling.tf View source on GitHub ↗
# Mark variables as sensitive
variable "db_password" {
  type      = string
  sensitive = true
}

# Output marking
output "connection_string" {
  value     = local.connection_string
  sensitive = true
}

4. Secrets Management

4.1 Dynamic Credentials (OIDC)

Profile Level: L2 (Hardened) NIST 800-53: IA-5

Description

Use OIDC workload identity instead of static credentials.

AWS Configuration

See the Terraform pack below for OIDC provider and workspace variable configuration.

Code Pack: Terraform
hth-terraform-cloud-4.01-dynamic-credentials-oidc.tf View source on GitHub ↗
# Configure OIDC provider in AWS
resource "aws_iam_openid_connect_provider" "tfc" {
  url             = "https://app.terraform.io"
  client_id_list  = ["aws.workload.identity"]
  thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
}

# Trust policy for TFC
data "aws_iam_policy_document" "tfc_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.tfc.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "app.terraform.io:aud"
      values   = ["aws.workload.identity"]
    }
    condition {
      test     = "StringLike"
      variable = "app.terraform.io:sub"
      values   = ["organization:myorg:project:*:workspace:*:run_phase:*"]
    }
  }
}

# workspace variables
# TFC_AWS_PROVIDER_AUTH = true
# TFC_AWS_RUN_ROLE_ARN  = arn:aws:iam::123456789:role/tfc-role

4.2 Vault Integration

Profile Level: L2 (Hardened)

Implementation

Code Pack: Terraform
hth-terraform-cloud-4.02-vault-integration.tf View source on GitHub ↗
# Use Vault provider for secrets
provider "vault" {
  address = "https://vault.company.com"
}

data "vault_generic_secret" "db" {
  path = "secret/production/database"
}

resource "aws_db_instance" "main" {
  password = data.vault_generic_secret.db.data["password"]
}

5. Monitoring & Detection

5.1 Audit Logging

Profile Level: L1 (Baseline) NIST 800-53: AU-2, AU-3

Detection Focus

See the DB pack below for audit detection queries.


Code Pack: Terraform
hth-terraform-cloud-5.01-audit-logging.tf View source on GitHub ↗
# Organization-level settings with audit trail URL
# Audit logs are available at:
#   https://app.terraform.io/api/v2/organization/audit-trail
# Enterprise customers can configure streaming to external SIEM.
resource "tfe_organization" "main" {
  name  = var.tfc_organization
  email = var.tfc_email

  # Require 2FA for all organization members
  collaborator_auth_policy = "two_factor_mandatory"
}

# Audit trail data source -- verify logging is accessible
data "http" "audit_trail_check" {
  url = "https://app.terraform.io/api/v2/organization/audit-trail?since=${formatdate("YYYY-MM-DD", timeadd(timestamp(), "-24h"))}"

  request_headers = {
    Authorization = "Bearer ${var.tfc_token}"
    Content-Type  = "application/vnd.api+json"
  }
}

variable "tfc_token" {
  description = "Terraform Cloud API token for audit trail verification"
  type        = string
  sensitive   = true
}

output "audit_trail_status" {
  description = "HTTP status of audit trail endpoint"
  value       = data.http.audit_trail_check.status_code
}
Code Pack: Sigma Detection Rule
hth-terraform-cloud-5.01-audit-logging.yml View source on GitHub ↗
detection:
    selection:
        resource_type: 'audit-trail-destination'
        action: 'delete'
    condition: selection
fields:
    - actor
    - action
    - resource_type
    - resource_id
    - timestamp

Appendix A: Edition Compatibility

Control Free Team Business Enterprise
SSO
Sentinel
Audit Logs
OIDC

Appendix B: References

Official HashiCorp Documentation:

API & Developer Tools:

Compliance Frameworks:

  • SOC 2 Type II, ISO 27001, ISO 27017, ISO 27018 – via HashiCorp Compliance Overview
  • Audit reports available to customers/prospects under NDA (contact customertrust@hashicorp.com)

Security Incidents:

  • (2021) HashiCorp’s GPG private key used for signing product download hashes was exposed in the Codecov supply-chain attack (January-April 2021). The key was revoked and replaced.
  • (2025) Terraform Enterprise access control vulnerability (HCSEC-2025-34) allowed users with insufficient permissions to create state versions. Fixed in versions 1.1.1 and 1.0.3. No data breach reported.

Changelog

Date Version Maturity Changes Author
2025-12-14 0.1.0 draft Initial Terraform Cloud hardening guide Claude Code (Opus 4.5)