HCP Terraform (formerly Terraform Cloud) Hardening Guide
IaC platform security for HCP Terraform (formerly Terraform Cloud): workspace variables, team access, audit trails, and run triggers
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
- 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 (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
- 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 (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
- Navigate to: Workspace → Team Access
- Grant minimum permissions per team
Code Implementation
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 (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
- 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
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.
- Treat VCS webhook secrets as rotatable credentials: inventory every VCS connection (OAuth client) and record where its webhook secret is used
- 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
- Monitor audit trail events for unexpected
oauth_clientchanges (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
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: '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
# 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
- 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 (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
# 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
# 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
# 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-serverversions 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
- Identify every
terraform-mcp-serverdeployment (developer laptops, shared gateways, CI agents) - Upgrade all instances to version 1.1.0 or later per HCSEC-2026-23
Step 2: Isolate
- Run one MCP server instance per user or per trust boundary — do not share a stateful instance across users
- Network-restrict the server so only intended AI clients can reach it
- Scope the HCP Terraform token the server holds to least privilege (team token with minimum workspace permissions, with expiry)
Validation & Testing
- Confirm every deployed instance reports a version ≥ 1.1.0
- 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
# 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-atis 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
- Create audit trail tokens only via the documented endpoint, always setting
expired-at— never accept the never-expires default - Store the one-time secret directly into your secrets manager
Step 2: Restrict and Rotate
- Limit creation to the owners team; alert on any
authentication_tokencreate event in the audit trail - Rotate on a fixed schedule (quarterly) and whenever the consuming SIEM integration changes
- Prefer the dedicated audit trail token over the organization token for SIEM pullers — it limits blast radius to audit reads
Validation & Testing
- List issued tokens and confirm none have
expired-at: null - 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):
- HCP Terraform Documentation
- HCP Terraform Overview and Editions
- Recommended Practices
- Single Sign-On
- Audit Trails API
- Audit Trail Tokens
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?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide