Writing Terraform Resources with Write-Only Parameters

Source: Dev.to | Jan 22, 2026 (UTC)

When building Terraform providers that handle sensitive data like passwords, API tokens, or secret keys, you’ll eventually encounter the need for write-only parameters. These are values that should be sent to an API but never stored in Terraform state files.

In this post, I’ll walk through implementing write-only parameters in a Terraform provider, the challenges involved, and design options to consider.

What Are Write-Only Parameters?

Write-only parameters (introduced in Terraform 1.11) are resource arguments that:

  • Are sent to the API during creation and updates
  • Never appear in the Terraform state file

This feature is crucial for security. Without write-only parameters, sensitive values like passwords would be stored in plaintext in your state files.

Here’s an example from my fork of the Event Drive Ansible (EDA) provider:

resource "aap_eda_credential" "example" { name = "my-api-credential" credential_type_id = 1 # Write-only: sent to API but NEVER stored in state inputs_wo = jsonencode({ username = "service-account" api_token = var.api_token }) }

Enter fullscreen mode

Exit fullscreen mode

Design Considerations

The _version Argument

Since Terraform’s statefile does not manage the value of the secret, Terraform requires way to know if the secret must be updated. The standard solution to detecting changes in write-only parameters is to add a companion _version field that users must manually increment:

resource "aap_eda_credential" "manual" { name = "my-credential" inputs_wo = jsonencode({ password = var.new_password }) # User must increment this to trigger an update inputs_wo_version = 1 }

Enter fullscreen mode

Exit fullscreen mode

How updates work:

  1. User updates the secret in inputs_wo
  2. User manually increments inputs_wo_version
  3. Terraform detects the version change and triggers an update
  4. The new secret is sent to the API

This works, but it’s not user-friendly. Users have to remember to:

Automatic Change Detection

Ideally, users shouldn’t need to manage version numbers. They should just update the secret value and Terraform should detect the change automatically. But here’s the problem:

If inputs_wo is write-only and not in state, how do we detect when it changes?

Lucikly, there is a solution: store a deterministic hash in private state.

Using Private State and Hashing

Terraform’s plugin framework provides private state – a place to store provider-managed data that’s:

Here’s the implementation approach:

// Create operation func (r *EDACredentialResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data EDACredentialResourceModel req.Plan.Get(ctx, &data) // Calculate SHA-256 hash of the inputs inputsHash := calculateInputsHash(data.InputsWO.ValueString()) // Store hash in private state (JSON-wrapped for validity) hashJSON := fmt.Sprintf(`{"hash":"%s"}`, inputsHash) resp.Private.SetKey(ctx, "inputs_wo_hash", []byte(hashJSON)) // Set auto-managed version data.InputsWOVersion = tftypes.Int64Value(1) // ... send to API and save state ... } func calculateInputsHash(inputs string) string { h := sha256.New() h.Write([]byte(inputs)) return hex.EncodeToString(h.Sum(nil)) }

Enter fullscreen mode

Exit fullscreen mode

In the Update operation, we compare hashes:

func (r *EDACredentialResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data, state EDACredentialResourceModel req.Plan.Get(ctx, &data) req.State.Get(ctx, &state) // Get stored hash from private state oldHashBytes, _ := req.Private.GetKey(ctx, "inputs_wo_hash") var hashWrapper struct { Hash string `json:"hash"` } json.Unmarshal(oldHashBytes, &hashWrapper) oldHash := hashWrapper.Hash // Calculate new hash newHash := calculateInputsHash(data.InputsWO.ValueString()) if newHash != oldHash { // Inputs changed! Auto-increment version data.InputsWOVersion = tftypes.Int64Value(state.InputsWOVersion.ValueInt64() + 1) // Update stored hash hashJSON := fmt.Sprintf(`{"hash":"%s"}`, newHash) resp.Private.SetKey(ctx, "inputs_wo_hash", []byte(hashJSON)) } else { // No change, keep version as-is data.InputsWOVersion = state.InputsWOVersion } // ... send to API and save state ... }

Enter fullscreen mode

Exit fullscreen mode

Now users can just update the secret and Terraform automatically detects it:

resource "aap_eda_credential" "auto" { name = "my-credential" inputs_wo = jsonencode({ password = var.new_password # Just change this! }) # inputs_wo_version auto-increments (computed field) }

Enter fullscreen mode

Exit fullscreen mode

Best of both worlds

While automatic detection is convenient, some users may not want a hash of their secrets stored in state – even if it’s SHA-256 and in private state. The hash is still derived from the secret value.

Solution: Support both modes

func (r *EDACredentialResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data EDACredentialResourceModel req.Plan.Get(ctx, &data) var versionToSet tftypes.Int64 if data.InputsWOVersion.IsNull() || data.InputsWOVersion.IsUnknown() { // Auto-managed mode: store hash inputsHash := calculateInputsHash(data.InputsWO.ValueString()) hashJSON := fmt.Sprintf(`{"hash":"%s"}`, inputsHash) resp.Private.SetKey(ctx, "inputs_wo_hash", []byte(hashJSON)) versionToSet = tftypes.Int64Value(1) } else { // Manual mode: user provided version, don't store hash versionToSet = data.InputsWOVersion } // ... rest of create logic ... data.InputsWOVersion = versionToSet }

Enter fullscreen mode

Exit fullscreen mode

Schema definition:

"inputs_wo_version": schema.Int64Attribute{ Optional: true, Computed: true, PlanModifiers: []planmodifier.Int64{ int64planmodifier.UseStateForUnknown(), }, Description: "Version number for managing credential updates. " + "If not set, the provider will automatically detect changes to inputs_wo " + "using a SHA-256 hash stored in private state. If set manually, you " + "control when the credential is updated by incrementing this value yourself.", }

Enter fullscreen mode

Exit fullscreen mode

Users can choose their preferred mode:

# Auto-managed (default) resource "aap_eda_credential" "auto" { name = "auto-credential" inputs_wo = jsonencode({ password = var.pwd }) # version auto-increments } # Manual control resource "aap_eda_credential" "manual" { name = "manual-credential" inputs_wo = jsonencode({ password = var.pwd }) inputs_wo_version = 1 # I control this }

Enter fullscreen mode

Exit fullscreen mode

Handling Mode Switching

Unfortunately, a weakness of this design is that its not possible to “switch modes”. Why?

Even if we could find a way to handle this, the logic in your resource becomes complex and error-prone.

Better approach: Prevent mode switching

func (r *EDACredentialResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // Determine current mode from private state oldHashBytes, _ := req.Private.GetKey(ctx, "inputs_wo_hash") wasAutoManaged := oldHashBytes != nil // Determine desired mode from config var configModel EDACredentialResourceModel req.Config.Get(ctx, &configModel) isNowManual := !configModel.InputsWOVersion.IsNull() && !configModel.InputsWOVersion.IsUnknown() var state EDACredentialResourceModel req.State.Get(ctx, &state) wasManual := !wasAutoManaged && !state.InputsWOVersion.IsNull() // Prevent mode switching if wasAutoManaged && isNowManual { resp.Diagnostics.AddError( "Cannot switch from auto-managed to manual version management", "The inputs_wo_version field was previously auto-managed. Once auto-managed, "+ "it cannot be switched to manual mode. If you need to manually control the version, "+ "you must recreate the resource with inputs_wo_version set from the start.", ) return } if wasManual && !isNowManual { resp.Diagnostics.AddError( "Cannot switch from manual to auto-managed version management", "The inputs_wo_version field was previously manually managed. Once manually managed, "+ "it cannot be switched to auto mode. If you need auto-managed version control, "+ "you must recreate the resource without setting inputs_wo_version.", ) return } // ... rest of update logic for the chosen mode ... }

Enter fullscreen mode

Exit fullscreen mode

This gives users a clear error message:

Error: Cannot switch from auto-managed to manual version management The inputs_wo_version field was previously auto-managed. Once auto-managed, it cannot be switched to manual mode. If you need to manually control the version, you must recreate the resource with inputs_wo_version set from the start.

Enter fullscreen mode

Exit fullscreen mode

Key Takeaways

When implementing write-only parameters in Terraform providers:

  1. Use private state for hashes – It’s stored but not visible to users
  2. Support both auto and manual modes – Give users choice for their security preferences

The complete implementation provides a great user experience while respecting privacy concerns:

# Most users: auto-managed, no version to track resource "aap_eda_credential" "api" { name = "api-credential" inputs_wo = jsonencode({ token = var.token }) } # Privacy-conscious users: manual control, no hash stored resource "aap_eda_credential" "secure" { name = "secure-credential" inputs_wo = jsonencode({ token = var.token }) inputs_wo_version = 1 }

Enter fullscreen mode

Exit fullscreen mode

Both approaches are valid. Both are clear. And switching between them requires a resource recreation, which is easy to communicate and reason about.

Have you implemented write-only parameters in your Terraform provider? What challenges did you face? Let me know in the comments!

For further actions, you may consider blocking this person and/or reporting abuse

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Читайте також