Gauge Hartwell
← all write-ups

documented limitation, not a bug

The Map That Couldn't Be a String: A Documented Vault CLI Limitation Disguised as a Quoting Bug

TL;DR: Writing a Vault JWT role with a bound_claims field failed with expected a map, got 'string' — an error message that was completely accurate on the very first attempt. Two rounds of reasonable troubleshooting fixed the wrong layer anyway, because the same accurate error is consistent with several different plausible causes, and the two most human explanations both turned out to be wrong. The actual answer was sitting in HashiCorp's own documentation: the Vault CLI's per-field syntax simply cannot represent a map value at all, regardless of how carefully it's quoted.

The symptom

vault write auth/jwt/role/website \
  role_type="jwt" \
  bound_audiences="http://10.0.1.80:8200" \
  bound_claims_type="glob" \
  bound_claims={"project_path":"website/website"} \
  user_claim="project_path" \
  policies="ci-website-reader" \
  ttl="15m"

Error writing data to auth/jwt/role/website: Error making API request.
Code: 400. Errors:
* error converting input for field "bound_claims": '' expected a map, got 'string'

Every other field on this same command — role_type, bound_audiences, policies, ttl — wrote correctly. Only bound_claims failed, and the error named the exact problem: Vault received a string where it expected a map.

First attempt: the shell quoting theory

The immediate, reasonable read: bound_claims={"project_path":"website/website"} has unquoted {} and internal double quotes sitting directly in a shell command — bash could easily be stripping or mangling those characters before Vault ever saw them. The fix was to wrap the entire JSON value in single quotes, so the shell would pass it through as one literal string:

bound_claims='{"project_path":"website/website"}'

Same error, byte-for-byte identical to before. Reasonable theory, cleanly falsified.

Second attempt: the smart-quote theory

The next plausible explanation: something in the terminal or clipboard — autocorrect, a rich-text paste — had silently swapped straight double quotes for curly "smart" quotes, which render identically in a terminal but are completely different bytes to a JSON parser. This is a real, common failure mode, and worth ruling out properly rather than eyeballing it. The value was written to a file instead of typed inline, specifically to remove any path where invisible substitution could happen:

cat > bound_claims.json << 'EOF'
{"project_path":"website/website"}
EOF
cat -A bound_claims.json   # confirms straight quotes, byte for byte

cat -A confirmed the file held exactly the right bytes — plain, straight double quotes throughout. The command was retried using Vault CLI's @file convention, the same mechanism already proven to work earlier in this build for [email protected]:

bound_claims=@bound_claims.json

Identical error, for the third time. At this point the file's contents had been independently verified correct at the byte level — which meant the content was no longer a credible suspect at all. The right move wasn't a third theory about the string's formatting. It was recognizing that two independently-verified-correct inputs producing the identical failure meant the problem was never about the string — it was about the mechanism trying to carry it.

The actual root cause

HashiCorp's own documentation for the JWT/OIDC auth method says this directly, and unambiguously:

If a role parameter (e.g. bound_claims) requires a map value, it can't be set individually using the Vault CLI. In these cases the best approach is to write the entire configuration as a single JSON object.

And the vault write command reference explains why:

Some API fields require more advanced structures such as maps. These cannot directly be represented on the command line. However, direct control of the request parameters can be achieved by using - as the only data argument. This causes vault write to read a JSON blob containing all request parameters from stdin.

key=@file substitutes a file's raw text as the value for one field, on the assumption that field accepts a plain string. It never parses that text as JSON — so for a field genuinely typed as a map, there is no per-field CLI syntax that can represent it correctly, no matter how the string is quoted, escaped, or sourced. Both fix attempts were solving a problem that didn't exist; the real one was one level up, in how the CLI assembles the request at all.

The fix

Pass the entire role definition as one JSON object via stdin, using - as the sole argument rather than any key=value or key=@file pair:

vault write auth/jwt/role/website - << 'EOF'
{
  "role_type": "jwt",
  "bound_audiences": "http://10.0.1.80:8200",
  "bound_claims_type": "glob",
  "bound_claims": {"project_path": "website/website"},
  "user_claim": "project_path",
  "policies": ["ci-website-reader"],
  "ttl": "15m"
}
EOF

This bypasses the CLI's per-field parser entirely — the whole stdin blob becomes the literal request body, the same way a direct API call would send it.

The connection worth keeping in mind going forward

This build already has a working example of a different transport that never hits this bug in the first place: the vault_k8s_app Ansible role writes its Vault policies and roles through the uri module with a proper YAML body: block and body_format: json — which constructs a real JSON request directly, with no CLI field-parsing layer in between at all. Any future Vault role written through Ansible's uri module sidesteps this entire class of problem for free; it's only the raw vault write key=value CLI form that has this limitation.

What this demonstrates

This is close to the opposite failure mode of the KV v2 nesting bug from earlier in this build. There, the error message ("VARIABLE IS NOT DEFINED!") was a generic wrapper actively hiding the real, specific cause underneath it. Here, the error message was specific and accurate from the very first attempt — it said exactly what was wrong, every single time. What took three attempts wasn't finding the honest error under a misleading one; it was recognizing that an accurate, specific error can still be misread, because "got a string" is consistent with several different root causes at different layers — bad quoting, corrupted bytes, or a transport that can't carry structured data at all — and the two most intuitively human explanations happened to both be wrong. The actual signal to change direction wasn't a new clue appearing. It was two independently-verified-correct inputs producing the identical failure — which is exactly the point at which the content stops being a credible suspect, and the mechanism becomes the only thing left to question.