Skip to main content

HCP Terraform Workload Identity

An HCP Terraform run can exchange its own workload identity token for a short-lived KnoxCall access token — no KnoxCall API key stored as a workspace variable, and nothing to rotate. HCP Terraform is one of the three issuers KnoxCall knows by name, so a binding here is matched on sub rather than falling back to a generic rule. sub carries the organisation, project, workspace and run phase, which is what makes the strongest recommendation on this page possible: a plan can be given different access from an apply.
Unlike GitHub Actions, Google Cloud or AWS, the KnoxCall SDKs do not auto-detect an HCP Terraform run — the token is handed to you in an environment variable, so you pass it to the exchange yourself. Everything on this page uses that variable directly.

1. Turn on workload identity in the workspace

Set a workspace variable — Workspace → Variables → Environment variable: Once it is set, every plan and apply gets a TFC_WORKLOAD_IDENTITY_TOKEN variable in its run environment containing a signed OIDC token for that audience. If the workspace already uses workload identity for a cloud provider, do not overwrite the existing audience — add a second one instead. TFC_WORKLOAD_IDENTITY_AUDIENCE_KNOXCALL produces TFC_WORKLOAD_IDENTITY_TOKEN_KNOXCALL alongside it, and the tag may contain only letters, numbers and underscores.

2. Register the binding in KnoxCall

Dashboard → Settings → API → Workload Identity, or call the admin API. The /admin/* routes are served on the admin host and authenticated by your logged-in admin/owner session — a session JWT plus the X-Tenant-ID header — not an API key against api.knoxcall.com.
Creating a binding requires a recent step-up verification (passkey, TOTP or emailed code) within the last 5 minutes: a binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A curl carrying only a session JWT answers 403 {"requires_step_up": true} — verify in the Dashboard, then replay inside the window. Each verification is single-use. Listing and revoking bindings need no verification; containment is never gated.
attribute_conditions is required, and for this issuer it must pin sub. Without it the binding would describe every workspace in every organisation on app.terraform.io, not yours. allowed_scopes is required and may not be empty. An empty allow-list is not “no access” — a token with no scopes is treated as unnarrowed, so it would reach every endpoint your tenant has. Creating one is refused with 400 invalid_allowed_scopes.

3. Exchange the token inside the run

…with the token passed in as a variable:
Or, from a local-exec / CI wrapper around the run:
The response is a standard OAuth token response; access_token is a kc_ token valid for the binding’s TTL, capped at twice the remaining life of the Terraform token.

What’s in the token

A module test run carries a different sub shape — organization:{ORG}:module:{MODULE}:operation:test_run — and always reports terraform_run_phase: plan.

Recommendations

  • Separate the phases. Pin run_phase:apply on the binding that carries write scopes, and register a second, read-only binding for run_phase:plan if plans need KnoxCall access at all. A speculative plan can be triggered by a pull request from someone who cannot merge it; without the phase in the rule, that plan mints the same token your apply does.
  • One binding per workspace, not one per organisation. sub names the workspace, so a per-workspace rule costs nothing and makes the exchange history per-workspace too.
  • Keep access_token_ttl_seconds near the length of a real apply. KnoxCall already caps the minted token at twice the remaining life of the Terraform token, so a long TTL mostly does nothing.

Troubleshooting

  • invalid_grant: no binding matched the subject_token's claims — the sub in the rule is not the sub in the token. Decode the token in a run to compare:
    The usual causes are a renamed workspace, a project the workspace was moved into, and a rule written for apply while the failure is on plan.
  • invalid_request: Unsupported audienceTFC_WORKLOAD_IDENTITY_AUDIENCE is not knoxcall:api. The audience is baked into the token at issue time, so it must match before the run starts.
  • invalid_grant: subject_token has already been exchanged — each token is single-use. A retried apply needs the token from its own run; re-using a captured one is exactly what that refusal exists to stop.
  • 403 {"requires_step_up": true} when creating the binding — see the note in step 2.