Writing Terraform Resources with Write-Only Parameters
Jenkins
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.
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:
User updates the secret in inputs_wo
User manually increments inputs_wo_version
Terraform detects the version change and triggers an update
The new secret is sent to the API
This works, but it’s not user-friendly. Users have to remember to:
Increment the version every time they change secrets
Track version numbers manually
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:
Stored in the state file (so it persists)
Not visible to users (doesn’t appear in terraform show)
Perfect for storing things like hashes
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:
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?
Auto → Manual: If a user suddenly sets inputs_wo_version, do we remove the hash? What if they’re just trying to force an update?
Manual → Auto: We’d need to create a hash, but we can’t compare it to the old inputs (they’re write-only and not in state), so we can’t tell if inputs changed during the transition
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:
Use private state for hashes – It’s stored but not visible to users
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
Залишити відповідь