v0.2.0-draft AI Drafted

HCP Terraform (formerly Terraform Cloud) Hardening Guide

IaC Last updated: 2026-08-08

IaC platform security for HCP Terraform (formerly Terraform Cloud): workspace variables, team access, audit trails, and run triggers

View:

Overview

HCP Terraform is the product HashiCorp renamed from Terraform Cloud in 2024 (HCP Terraform overview); documentation still lives under /terraform/cloud-docs/ paths. HCP Terraform 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 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers HCP Terraform (formerly Terraform Cloud) security configurations including authentication, access controls, audit trails, and integration security. Terraform Enterprise (self-hosted) differences are called out where they matter.


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 (Crawl) NIST 800-53: IA-2(1)

Description

Require SAML single sign-on with MFA through your corporate IdP for all HCP Terraform organization access, and scope team API tokens to least privilege with expiration and regular rotation.

Rationale

Why This Matters:

  • Centralizes HCP Terraform authentication in your IdP so MFA and conditional access apply to every user login
  • Local logins and long-lived personal tokens bypass IdP controls and are prime targets for credential stuffing and phishing
  • Enforcing SSO with SCIM provisioning deprovisions departed users automatically, eliminating orphaned accounts that retain infrastructure access
  • HCP Terraform can plan and apply changes to production cloud accounts, so a single compromised login can rewrite or destroy infrastructure

Attack Prevented: Credential theft, phishing, MFA bypass, orphaned-account access, token abuse

ClickOps Implementation

Changed availability: Single sign-on is now available to all HCP Terraform organizations — it is no longer gated to a paid edition. Source: HCP Terraform single sign-on.

Step 1: Configure SSO

  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 (Crawl) NIST 800-53: AC-3, AC-6

Description

Define teams that map to job functions and grant each team only the minimum workspace permissions it needs (for example, plan-only for developers, view-only for auditors) rather than broad organization-wide access.

Rationale

Why This Matters:

  • Least-privilege team permissions ensure a compromised account or token can only affect the workspaces it legitimately needs
  • Separating plan from apply prevents developers from pushing unreviewed changes directly to production infrastructure
  • Role-mapped teams make access reviews and audits straightforward and reduce standing privilege
  • Over-broad “owners” membership turns any single account compromise into full control of every workspace and its cloud credentials

Attack Prevented: Privilege escalation, lateral movement, unauthorized infrastructure changes, insider misuse

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 Implementation

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 (Crawl) NIST 800-53: CM-3

Description

Harden workspace execution settings by using remote execution, disabling auto-apply for production, and requiring pull-request review with branch protection before any VCS-triggered apply.

Rationale

Why This Matters:

  • Disabling auto-apply forces human review of every plan before it mutates production infrastructure
  • Requiring PR review and branch protection ensures changes are peer-reviewed and traceable to an approved commit
  • Speculative plans surface the impact of a change before it is merged, catching destructive or misconfigured edits early
  • Without these gates, a single malicious or accidental commit to the connected VCS branch can be applied to production automatically

Attack Prevented: Unauthorized and unreviewed infrastructure changes, poisoned-pipeline execution, accidental destruction, malicious commits

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

Step 3: Webhook Secret Hygiene

Security advisory (HCSEC-2026-09, 2026-04-20): GitHub inadvertently included webhook secrets in the HTTP headers of outbound webhook deliveries between September 2025 and January 2026. HashiCorp fully automated rotation of all potentially affected GitHub webhook secrets for HCP Terraform SaaS; Terraform Enterprise customers must follow the manual remediation paths in the advisory. Source: HCSEC-2026-09.

  1. Treat VCS webhook secrets as rotatable credentials: inventory every VCS connection (OAuth client) and record where its webhook secret is used
  2. On Terraform Enterprise, follow the HCSEC-2026-09 manual remediation steps to rotate GitHub webhook secrets; on HCP Terraform SaaS, confirm the automated rotation covered your organization
  3. Monitor audit trail events for unexpected oauth_client changes (see the Sigma rule in this guide’s pack) — removal or re-creation of a VCS OAuth client outside a change window is a tampering signal

Code Implementation

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: 'oauth_client'
        resource.action: 'destroy'
    condition: selection
fields:
    - id
    - timestamp
    - auth.accessor_id
    - auth.description
    - auth.organization_id
    - resource.id
    - resource.type
    - resource.action

2.2 Sentinel Policy Enforcement

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

Description

Use Sentinel policy-as-code to define and enforce guardrails (such as required tags, allowed regions, instance limits, and prohibited public access) that every run must satisfy before apply.

Rationale

Why This Matters:

  • Policy-as-code enforces security and compliance guardrails automatically on every run, independent of reviewer vigilance
  • Hard-mandatory policies block non-compliant infrastructure such as public buckets, unencrypted volumes, and open security groups before it is provisioned
  • Codified policies provide consistent, auditable evidence that controls are applied uniformly across all workspaces
  • Without policy enforcement, drift and misconfiguration depend entirely on manual review, which is error-prone at scale

Attack Prevented: Misconfiguration, compliance drift, public exposure of resources, unencrypted data stores

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 (Crawl) NIST 800-53: SC-28

Description

Ensure Terraform state is encrypted at rest and restrict who can read or download it, since state holds the full record of provisioned infrastructure along with any secrets it captured.

Rationale

Why This Matters:

  • State files record every resource attribute Terraform manages, including values providers mark sensitive — database passwords, API keys, and connection strings persist in state as plaintext
  • Anyone who can read or download state effectively holds the credentials to the infrastructure it describes, without ever touching the cloud console
  • State is also a complete infrastructure blueprint, giving an attacker the reconnaissance map needed to target the highest-value resources first
  • Restricting state access to the smallest possible set of teams limits the blast radius of any single compromised account or token

Attack Prevented: State file exfiltration, credential harvesting from state, infrastructure reconnaissance via the state blueprint.

ClickOps Implementation

Step 1: Enable State Encryption

  • HCP Terraform 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 (Crawl) NIST 800-53: SC-28

Description

Mark workspace and variable-set values that contain secrets as sensitive so HCP Terraform masks them in the UI, run logs, and API responses and never exposes them in plan output.

Rationale

Why This Matters:

  • Marking variables sensitive prevents credentials and keys from appearing in plan output, run logs, and the UI
  • Unmarked secrets leak into CI/CD logs and audit trails that are visible to far more users than the secret itself
  • Sensitive variable sets let you centrally manage and rotate shared credentials instead of duplicating them per workspace
  • Leaked variable values can grant attackers direct access to the cloud accounts and services Terraform manages

Attack Prevented: Secret exposure in logs, credential leakage, downstream cloud-account compromise

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 (Walk) NIST 800-53: IA-5

Description

Use OIDC workload identity instead of static credentials.

Rationale

Why This Matters:

  • OIDC workload identity issues short-lived, automatically expiring credentials per run instead of long-lived stored secrets
  • Eliminating static cloud keys removes the highest-value secret an attacker could exfiltrate from a workspace or state file
  • Federated trust is scoped to specific workspaces and run phases, so credentials cannot be replayed outside the intended context
  • Long-lived access keys in workspace variables never expire, are hard to rotate, and grant standing access if leaked

Attack Prevented: Credential theft, key exfiltration, replay attacks, standing-access abuse

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 (Walk)

Description

Integrate HashiCorp Vault so Terraform pulls secrets and dynamically generated, short-lived credentials at run time rather than storing them as static workspace variables.

Rationale

Why This Matters:

  • Vault-generated dynamic secrets are short-lived and scoped, drastically shrinking the window an exposed credential is usable
  • Sourcing secrets from Vault keeps them out of workspace variables and state files where they would otherwise persist in plaintext
  • Centralized secret management provides unified rotation, leasing, and audit logging across all Terraform runs
  • Static long-lived credentials stored in the platform are a single point of failure that an attacker can harvest and reuse

Attack Prevented: Secret sprawl, credential theft, static-credential reuse, plaintext secret exposure

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"]
}

4.3 Patch and Isolate the Terraform MCP Server

Profile Level: L2 (Walk) NIST 800-53: SI-2, SC-7

Description

If you expose HCP Terraform to AI agents or IDE assistants through HashiCorp’s terraform-mcp-server, treat the MCP server as a credential-bearing integration path: keep it patched to at least version 1.1.0, restrict which clients can reach it, and never share one server instance across trust boundaries.

Rationale

Why This Matters:

  • HCSEC-2026-23 (2026-07-28) disclosed three vulnerabilities in terraform-mcp-server versions 0.2.1 through 1.0.0, fixed in 1.1.0: an SSRF letting an unauthenticated client redirect the server’s bearer token to an attacker-controlled endpoint (CVE-2026-14869), a stateful-mode authorization bypass exposing another user’s cached Terraform credentials (CVE-2026-16496), and stateless-mode cross-tenant credential reuse (CVE-2026-16498)
  • The MCP server holds HCP Terraform API tokens on behalf of its clients — a compromised or shared server is a direct path to the same credentials this guide’s token controls protect
  • MCP is a new integration surface that typically bypasses the review gates applied to VCS and CI/CD integrations

Attack Prevented: Bearer-token exfiltration via SSRF, cross-user and cross-tenant credential exposure through a shared MCP server.

ClickOps Implementation

Step 1: Inventory and Patch

  1. Identify every terraform-mcp-server deployment (developer laptops, shared gateways, CI agents)
  2. Upgrade all instances to version 1.1.0 or later per HCSEC-2026-23

Step 2: Isolate

  1. Run one MCP server instance per user or per trust boundary — do not share a stateful instance across users
  2. Network-restrict the server so only intended AI clients can reach it
  3. Scope the HCP Terraform token the server holds to least privilege (team token with minimum workspace permissions, with expiry)

Validation & Testing

  1. Confirm every deployed instance reports a version ≥ 1.1.0
  2. Confirm the token used by each MCP server appears in your token inventory with an owner and an expiry

5. Monitoring & Detection

5.1 Audit Logging

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

Description

Continuously pull HCP Terraform audit trail events into your SIEM so authentication, authorization, workspace, run, and variable changes are recorded and monitored for anomalies. The audit trail is pull-only: GET /organization/audit-trail with since (ISO 8601 UTC), page[number], and page[size] (default 1,000 events per page). Events are retained for 14 days, and there is no streaming, no log drain, and no push destination — a scheduled pull job is the only way to preserve the trail. Availability: Standard and Premium editions only; the audit trails API is not available for Terraform Enterprise. Source: Audit trails API.

Rationale

Why This Matters:

  • Comprehensive audit logs are required to detect unauthorized access, privilege changes, and suspicious run activity in time to respond
  • The 14-day platform retention makes a continuous scheduled pull mandatory, not optional — events older than 14 days are unrecoverable
  • Forwarding logs to a SIEM enables alerting and correlation that the platform console alone cannot provide; HashiCorp names the HCP Terraform for Splunk app as the reference SIEM integration (audit trail tokens doc)
  • Without centralized logging, attacker actions such as rogue token creation or state tampering can go undetected until damage is done

Attack Prevented: Undetected intrusion, privilege abuse, repudiation, delayed breach detection

Detection Focus

Audit trail events use a nested payload — filter on resource.type and resource.action (for example oauth_client + destroy for VCS integration tampering; see the Sigma rule in the pack below). The Terraform pack verifies audit-trail API reachability and enforces organization-level 2FA.

Code Implementation

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
# The audit trail is pull-only (14-day retention; Standard/Premium editions).
# There is no streaming or log-drain option -- schedule a pull job to your 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
}

5.2 Govern Audit Trail Tokens

Profile Level: L2 (Walk) NIST 800-53: IA-5, AC-6

Description

Audit trail data cannot be read with user or team tokens — it requires an organization token or a dedicated audit trail token, created via POST /organizations/:organization_name/authentication-token?token=audit-trails. Treat audit trail tokens as a distinct credential class with their own issuance, expiry, vaulting, and rotation policy. Source: Audit trail tokens.

Rationale

Why This Matters:

  • The token secret is shown once and unrecoverable — it must be vaulted at creation or it will end up in scripts and chat logs
  • expired-at is optional and null means the token never expires; an unexpiring credential that reads your full security telemetry is exactly what an attacker wants for silent reconnaissance
  • Only owners-team members, owners-team API tokens, and organization API tokens may create or delete audit trail tokens (all others receive 404) — so creation events are a small, auditable set worth alerting on
  • A leaked audit trail token exposes 14 days of organization-wide activity, including who changed what and when

Attack Prevented: Silent long-lived access to security telemetry, unaccounted-for credential sprawl, reconnaissance via stolen audit data.

ClickOps Implementation

Step 1: Issue with Expiry

  1. Create audit trail tokens only via the documented endpoint, always setting expired-at — never accept the never-expires default
  2. Store the one-time secret directly into your secrets manager

Step 2: Restrict and Rotate

  1. Limit creation to the owners team; alert on any authentication_token create event in the audit trail
  2. Rotate on a fixed schedule (quarterly) and whenever the consuming SIEM integration changes
  3. Prefer the dedicated audit trail token over the organization token for SIEM pullers — it limits blast radius to audit reads

Validation & Testing

  1. List issued tokens and confirm none have expired-at: null
  2. Confirm the SIEM puller works with the dedicated audit trail token and that the organization token is not embedded in any pipeline

Appendix A: Edition Compatibility

HCP Terraform’s current editions are Free, Essentials, Standard, and Premium — HashiCorp states “each higher paid upgrade plan is a strict superset of any lower plans” (HCP Terraform overview). The former Free / Team / Business tiers no longer exist.

Control Free Essentials Standard Premium
SSO
Team management
Audit trails API
Dynamic credentials (OIDC)

Notes:

  • SSO is “available to all HCP Terraform organizations” (single sign-on doc); the team management feature set starts at Essentials.
  • The audit trails API is Standard and Premium only, and is not available for Terraform Enterprise (audit trails API).
  • Policy enforcement (Sentinel/OPA) entitlements vary by edition — confirm your organization’s entitlement against the current HCP Terraform overview before depending on Control 2.2.

Appendix B: References

Official HashiCorp Documentation (HCP Terraform):

API & Developer Tools:

Compliance Frameworks:

  • SOC 2 Type II, ISO 27001, ISO 27017, ISO 27018 — 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.
  • (2026) HCSEC-2026-09 — GitHub inadvertently included webhook secrets in outbound webhook delivery headers (September 2025 – January 2026). HashiCorp automated rotation of affected GitHub webhook secrets for HCP Terraform SaaS; Terraform Enterprise requires manual remediation. See Control 2.1.
  • (2026) HCSEC-2026-17 — Terraform Enterprise arbitrary file read (CVE-2026-14468): VCS ingestion of registry modules did not correctly enforce the boundary on packaged module content, letting an authenticated user include and download files from outside the intended repository. Affects TFE v202506-1, v202507-1, and 1.0.0–2.0.3; fixed in 2.0.4 and 1.2.4.
  • (2026) HCSEC-2026-23 — three vulnerabilities in terraform-mcp-server (CVE-2026-14869 SSRF token redirect, CVE-2026-16496 stateful-mode authorization bypass, CVE-2026-16498 stateless-mode cross-tenant credential reuse). Affects 0.2.1–1.0.0; fixed in 1.1.0. See Control 4.3.

Changelog

Date Version Maturity Changes Author
2026-08-08 0.2.0 draft Currency pass: renamed guide to HCP Terraform (formerly Terraform Cloud); rebuilt Appendix A on current Free/Essentials/Standard/Premium editions; added audit-trail specifics to 5.1 (pull-only endpoint, 14-day retention, Standard+Premium availability, Splunk app); new controls 4.3 (terraform-mcp-server, HCSEC-2026-23) and 5.2 (audit trail token governance); webhook-secret hygiene in 2.1 (HCSEC-2026-09); fixed 3.1 cheat-parser miss; added HCSEC-2026-09/-17/-23 incidents; retired a Sigma rule matching a nonexistent log-drain event and rewrote the workspace rule against the documented audit payload; purged trust-center references Claude Code (Fable 5)
2025-12-14 0.1.0 draft Initial Terraform Cloud hardening guide Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?