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
provisionerblock 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
\", usejsonencode()to construct JSON programmatically, or use aheredocwith properEOTfencing. 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:
Tainted resource cascade. Terraform taints the resource on provisioner failure by default. A tainted
aws_db_instanceoraws_elasticache_clustergetting destroyed on next apply is a data-loss event, not a deployment inconvenience.Partial state corruption. If the provisioner fires mid-
applyafter dependent resources are already created (security groups, IAM roles, ENIs), you now have orphaned cloud resources that Terraform's state no longer tracks cleanly.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.
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.envcontains 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.