Initializing Enclave...

How to Fix 'Error: invalid JSON' in Terraform Provisioner Blocks

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins


TL;DR

  • What broke: A provisioner block in your Terraform resource is passing a JSON payload to a shell command or API call. The JSON is malformed — typically due to unescaped double-quotes inside HCL string interpolation, a trailing comma, or a raw newline inside a single-line string.
  • How to fix it: Escape inner quotes with \", use jsonencode() to construct JSON programmatically, or use a heredoc with proper EOT fencing. Never hand-craft JSON inside an HCL string literal.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your failing provisioner block and get corrected HCL instantly without sending your config to any external server.

The Incident (What Does the Error Mean?)

You ran terraform apply and it died with:

Error: invalid JSON

  on main.tf line 34, in resource "aws_instance" "app":
  34:   inline = ["curl -X POST https://internal-api/config -d '{\"key\": \"value\"}'"]

Cannot parse JSON: invalid character '\'' looking for beginning of value

Or a variant:

Error: invalid JSON
json: cannot unmarshal string into Go value of type map[string]interface {}

Immediate consequence: terraform apply halts. If this provisioner was on a resource that was already created (e.g., the EC2 instance is UP), Terraform marks it as tainted. Next apply will destroy and recreate that resource. In production, that means downtime.


The Attack Vector / Blast Radius

This isn't just a cosmetic syntax error. The blast radius is real:

  1. Tainted resource cascade. Terraform taints the resource on provisioner failure by default. A tainted aws_db_instance or aws_elasticache_cluster getting destroyed on next apply is a data-loss event, not a deployment inconvenience.

  2. Partial state corruption. If the provisioner fires mid-apply after dependent resources are already created (security groups, IAM roles, ENIs), you now have orphaned cloud resources that Terraform's state no longer tracks cleanly.

  3. Silent misconfiguration. The most dangerous variant: the JSON is structurally valid but semantically wrong because HCL interpolation silently inserted a null or wrong value. The API accepts it, returns 200, and your app runs with a broken config for hours before anyone notices.

  4. CI/CD pipeline blast. In a GitOps pipeline, this error fails the apply stage, but the plan stage likely passed — meaning your PR checks gave a false green. Every engineer on the team assumes the config is valid.


How to Fix It (The Solution)

Root Cause Taxonomy

Cause Symptom
Unescaped " inside HCL string invalid character error
Trailing comma in last JSON key invalid character ','
Raw newline in single-line string invalid character at newline
${var.x} resolving to null or object Type mismatch at parse time
Single quotes used instead of \" in shell Shell strips the JSON before the API sees it

Basic Fix — Escape Quotes Correctly

 resource "aws_instance" "app" {
   ami           = "ami-0abcdef1234567890"
   instance_type = "t3.medium"
 
   provisioner "remote-exec" {
     inline = [
-      "curl -X POST https://internal-api/config -d '{"key": "value"}'",
+      "curl -X POST https://internal-api/config -d '{\"key\": \"value\"}'",
     ]
   }
 }

Why: HCL uses " to delimit strings. Any " inside the string must be escaped as \". Single quotes ' are not JSON — they work in bash to wrap the payload but break if the JSON itself contains apostrophes or if you're on a system where the remote shell interprets them differently.


Enterprise Best Practice — Use jsonencode() and Never Hand-Craft JSON in Strings

This is the only production-safe pattern. jsonencode() is a Terraform built-in that guarantees valid JSON output regardless of what your variables contain.

 locals {
-  config_payload = "{\"environment\": \"${var.env}\", \"region\": \"${var.region}\"}"
+  config_payload = jsonencode({
+    environment = var.env
+    region      = var.region
+  })
 }
 
 resource "aws_instance" "app" {
   ami           = "ami-0abcdef1234567890"
   instance_type = "t3.medium"
 
   provisioner "remote-exec" {
     inline = [
-      "curl -X POST https://internal-api/config -d '${local.config_payload}'",
+      "curl -X POST https://internal-api/config -H 'Content-Type: application/json' -d '${local.config_payload}'",
     ]
   }
 }

Why this is the only correct pattern at scale:

  • jsonencode() handles nested objects, lists, null values, and special characters automatically.
  • If var.env contains a " or a backslash (e.g., a Windows path), hand-crafted interpolation breaks silently. jsonencode() escapes it correctly.
  • Reviewers can read the HCL object natively — no mental JSON-in-string parsing required.

Heredoc Pattern for Multi-Line Payloads

If your JSON is large, use a templatefile() instead of inline strings entirely:

- provisioner "local-exec" {
-   command = "curl -X POST https://api/endpoint -d '{\"key1\": \"val1\", \"key2\": \"val2\", \"key3\": \"val3\"}'"
- }
+ provisioner "local-exec" {
+   command = "curl -X POST https://api/endpoint -d @/tmp/payload.json"
+ }
+ 
+ resource "local_file" "api_payload" {
+   content  = jsonencode({
+     key1 = var.val1
+     key2 = var.val2
+     key3 = var.val3
+   })
+   filename = "/tmp/payload.json"
+ }

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

Fix this class of error permanently in your pipeline. One-time setup, zero recurring incidents.

1. terraform validate in Pre-Commit

# .pre-commit-config.yaml
- repo: https://github.com/antonbabenko/pre-commit-terraform
  rev: v1.83.0
  hooks:
    - id: terraform_validate
    - id: terraform_fmt

terraform validate catches most interpolation-induced JSON errors before they hit your pipeline.

2. TFLint with Deep Variable Checking

tflint --enable-plugin=aws
tflint --var-file=terraform.tfvars

TFLint resolves variables during linting and catches cases where a null variable would produce invalid JSON at apply time.

3. Checkov Policy — Ban Raw JSON Strings in Provisioners

# checkov custom check: no raw JSON strings in provisioner inline commands
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

class NoRawJsonInProvisioner(BaseResourceCheck):
    def __init__(self):
        name = "Ensure provisioner inline commands do not contain raw JSON strings"
        id = "CKV_CUSTOM_TF_001"
        supported_resources = ["*"]
        super().__init__(name=name, id=id, categories=[], supported_resources=supported_resources)

    def scan_resource_conf(self, conf):
        provisioners = conf.get("provisioner", [])
        for p in provisioners:
            inline = p.get("remote-exec", [{}])[0].get("inline", [])
            for cmd in inline:
                if isinstance(cmd, list):
                    cmd = cmd[0]
                if '{"' in str(cmd) or "{'" in str(cmd):
                    return CheckResult.FAILED
        return CheckResult.PASSED

4. OPA/Conftest Policy — Enforce jsonencode() Usage

# policy/provisioner_json.rego
package terraform.provisioner

deny[msg] {
  resource := input.resource_changes[_]
  provisioner := resource.change.after.provisioner[_]
  inline_cmd := provisioner["remote-exec"].inline[_]
  contains(inline_cmd, "-d '{") # raw JSON in curl -d flag
  msg := sprintf(
    "Resource '%s' uses raw JSON in provisioner. Use jsonencode() and a local_file resource instead.",
    [resource.address]
  )
}

Run in CI:

terraform show -json plan.tfplan | conftest test -p policy/ -

5. Pipeline Gate Summary

# .github/workflows/terraform.yml (relevant steps)
- name: Terraform Format Check
  run: terraform fmt -check -recursive

- name: Terraform Validate
  run: terraform validate

- name: TFLint
  run: tflint --var-file=terraform.tfvars

- name: Checkov Scan
  run: checkov -d . --framework terraform --check CKV_CUSTOM_TF_001

- name: Conftest OPA Policy
  run: terraform show -json tfplan.binary | conftest test -p policy/ -

The rule: if jsonencode() isn't used for any dynamic JSON construction in a provisioner, the pipeline fails. No exceptions.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →