DEV Community

drewmullen
drewmullen

Posted on

Writing Terraform Resources with Write-Only Parameters

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:

  • 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:

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?

  • 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:

  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!

Top comments (0)