Terraform Cloud Hardening Guide
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
- Authentication & Access Controls
- Workspace Security
- State File Security
- Secrets Management
- 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)
- Navigate to: Organization → Settings → SSO
- Configure SAML with your IdP
- Enforce SSO for all users
Step 2: Configure Team Tokens
- Create team tokens with minimum permissions
- Set expiration
- 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
- Navigate to: Workspace → Team Access
- Grant minimum permissions per team
Code Pack: Terraform
# --- 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
# 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
- Navigate to: Workspace → Settings → General
- Configure: Execution Mode: Remote
- Enable: Auto-apply: Disabled for production
Step 2: VCS Integration Security
- Configure branch protection
- Require PR review before apply
- Enable speculative plans
Code Pack: Terraform
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
# 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
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
# 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
- Navigate to: Workspace → Settings → General
- Configure: Terraform State: API access restricted
- Limit who can view/download state
3.2 Sensitive Variable Handling
Profile Level: L1 (Baseline) NIST 800-53: SC-28
Implementation
Code Pack: Terraform
# 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
# 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
# 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
# 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
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) |