AI Tools Compared

AI Tools for Automated Vault Policy Generation 2026

HashiCorp Vault policies are written in HCL with path-based access control. Getting the paths right — especially for KV v2 (which adds a data/ prefix) vs KV v1 — is a common source of errors. AI tools handle Vault policy generation well with the right context.

Setting Up Claude for Vault Work

Always specify the Vault version and KV engine version:

You are generating HashiCorp Vault policies for Vault 1.15+.
We use KV secrets engine v2 (kv-v2), mounted at "secret/".
KV v2 paths require "data/" prefix for reading (secret/data/...) and
"metadata/" for listing (secret/metadata/...).
Our auth methods: AppRole for services, Kubernetes auth for pods.

This prevents the most common mistake: writing secret/myapp/* when you need secret/data/myapp/* for KV v2.

Basic Application Policy

Prompt:

Write a Vault policy for an application called "orders-service".
It needs to:
- Read secrets at secret/orders-service/* (KV v2)
- List available secrets at secret/orders-service/
- Read its own dynamic database credentials at database/creds/orders-service-role
- NOT be able to write or delete any secrets

Claude output:

# Policy: orders-service
# Grants read-only access to orders-service secrets and database credentials

# KV v2: read secrets
path "secret/data/orders-service/*" {
  capabilities = ["read"]
}

# KV v2: list secret names (metadata path for listing)
path "secret/metadata/orders-service/*" {
  capabilities = ["list"]
}

# KV v2: list the top-level directory
path "secret/metadata/orders-service" {
  capabilities = ["list"]
}

# Dynamic database credentials (read-only, Vault generates these)
path "database/creds/orders-service-role" {
  capabilities = ["read"]
}

# Allow the service to renew its own token
path "auth/token/renew-self" {
  capabilities = ["update"]
}

# Allow the service to look up its own token info
path "auth/token/lookup-self" {
  capabilities = ["read"]
}

Claude correctly separates the data/ path (for reading secret values) from the metadata/ path (required for listing). It also adds auth/token/renew-self and auth/token/lookup-self — standard additions that most generated policies miss, causing token renewal failures.

ChatGPT often writes secret/orders-service/* without the data/ prefix, which works for KV v1 but silently fails in KV v2.

AppRole Authentication

Prompt:

Write Vault configuration to set up AppRole auth for orders-service.
The role should: use the orders-service policy, have a 24-hour token TTL,
allow token renewal up to 7 days, require secret ID to not be renewable,
and bind to CIDR 10.0.0.0/8.
Include the Terraform configuration and the initial credential bootstrap commands.

Claude output:

# Terraform: enable AppRole auth and configure role

resource "vault_auth_backend" "approle" {
  type = "approle"
  path = "approle"
}

resource "vault_approle_auth_backend_role" "orders_service" {
  backend   = vault_auth_backend.approle.path
  role_name = "orders-service"

  token_policies         = ["orders-service"]
  token_ttl              = 86400    # 24 hours in seconds
  token_max_ttl          = 604800   # 7 days

  secret_id_ttl          = 86400   # Secret IDs expire after 24 hours
  secret_id_num_uses     = 0       # Unlimited uses (use 1 for one-time bootstrap)

  token_bound_cidrs      = ["10.0.0.0/8"]
  secret_id_bound_cidrs  = ["10.0.0.0/8"]
}

output "role_id" {
  value = vault_approle_auth_backend_role.orders_service.role_id
}
# Bootstrap: retrieve Role ID (not secret, can be stored in config)
ROLE_ID=$(vault read -field=role_id auth/approle/role/orders-service/role-id)

# Generate a Secret ID (keep secret, rotate regularly)
SECRET_ID=$(vault write -field=secret_id -f auth/approle/role/orders-service/secret-id)

# Login and get a token
vault write auth/approle/login \
  role_id="${ROLE_ID}" \
  secret_id="${SECRET_ID}"

Kubernetes Auth Integration

Prompt:

Write Vault Kubernetes auth configuration for a pod in namespace "production",
service account "orders-service-sa". The pod should receive the orders-service policy.
Include both the Vault configuration and the Kubernetes service account manifest.

Claude output:

# Terraform: Kubernetes auth backend role
resource "vault_kubernetes_auth_backend_role" "orders_service" {
  backend                          = "kubernetes"
  role_name                        = "orders-service"

  bound_service_account_names      = ["orders-service-sa"]
  bound_service_account_namespaces = ["production"]

  token_policies = ["orders-service"]
  token_ttl      = 3600    # 1 hour (short-lived; pods re-authenticate)
  token_max_ttl  = 86400
}
# Kubernetes: ServiceAccount and RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-service-sa
  namespace: production
  annotations:
    vault.hashicorp.com/role: "orders-service"  # used by Vault Agent Injector

---
# Pod spec snippet with Vault Agent Injector annotations
metadata:
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "orders-service"
    vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/orders-service-role"
    vault.hashicorp.com/agent-inject-template-db-creds: |
      {{- with secret "database/creds/orders-service-role" -}}
      DATABASE_URL=postgresql://{{ .Data.username }}:{{ .Data.password }}@db:5432/orders
      {{- end }}

Claude includes the Vault Agent Injector annotation template — the HCL-like templating that transforms Vault secret values into the format your application expects.

Policy Linting

Claude can also review existing policies for overly broad permissions:

Prompt:

Review this Vault policy for security issues:
path "secret/*" { capabilities = ["read", "list", "create", "update"] }
path "sys/*" { capabilities = ["read"] }

Claude’s response:

Dynamic Secrets for Databases

One of Vault’s most powerful features is generating short-lived database credentials on demand. AI tools can generate both the Vault configuration and the application integration code.

Prompt:

Write Vault Terraform configuration to set up dynamic PostgreSQL credentials.
Database: postgres.internal:5432, dbname: orders.
Create a role called "orders-service-role" that generates credentials with
SELECT, INSERT, UPDATE on all tables in the public schema.
Credentials should expire after 1 hour.
resource "vault_database_secret_backend_connection" "postgres" {
  backend       = "database"
  name          = "orders-postgres"
  allowed_roles = ["orders-service-role"]

  postgresql {
    connection_url = "postgresql://{{username}}:{{password}}@postgres.internal:5432/orders"
    max_open_connections = 5
    max_connection_lifetime = "300s"
  }

  root_rotation_statements = [
    "ALTER USER '{{name}}' WITH PASSWORD '{{password}}';"
  ]
}

resource "vault_database_secret_backend_role" "orders_service" {
  backend = "database"
  name    = "orders-service-role"
  db_name = vault_database_secret_backend_connection.postgres.name

  default_ttl = "3600"   # 1 hour
  max_ttl     = "86400"  # 24 hours max renewal

  creation_statements = [
    "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
    "GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";",
    "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE ON TABLES TO \"{{name}}\";"
  ]

  revocation_statements = [
    "REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM \"{{name}}\";",
    "DROP ROLE IF EXISTS \"{{name}}\";"
  ]
}

The ALTER DEFAULT PRIVILEGES statement ensures the dynamic user can access tables created after the role was configured — a detail that Claude adds proactively. Without it, dynamic users lose access to new tables without a policy change.

Secret Versioning and Rollback

KV v2 stores multiple versions of each secret, and AI can help generate the management scripts:

Prompt:

Write a bash script to:
1. Write a new version of secret/data/orders-service/config
2. Verify the write succeeded and show the version number
3. Roll back to the previous version if the application health check fails
4. Archive (soft-delete) old versions beyond the last 5
#!/usr/bin/env bash
set -euo pipefail

SECRET_PATH="secret/data/orders-service/config"
METADATA_PATH="secret/metadata/orders-service/config"
HEALTH_URL="http://orders-service:8080/health"

# Write new version
echo "Writing new secret version..."
NEW_VERSION=$(vault kv put -mount=secret orders-service/config \
  DB_HOST="postgres.internal" \
  DB_PORT="5432" \
  FEATURE_FLAG="enabled" \
  -format=json | jq -r '.data.version')

echo "Wrote version: ${NEW_VERSION}"

# Wait for application to pick up new secret
sleep 5

# Health check
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${HEALTH_URL}")
if [ "${HTTP_CODE}" != "200" ]; then
  echo "Health check failed (HTTP ${HTTP_CODE}). Rolling back to version $((NEW_VERSION - 1))..."
  vault kv rollback -mount=secret -version=$((NEW_VERSION - 1)) orders-service/config
  echo "Rollback complete."
  exit 1
fi

echo "Health check passed. Deployment successful."

# Clean up old versions — keep last 5
CURRENT_VERSION=$(vault kv metadata get -mount=secret -format=json orders-service/config \
  | jq -r '.data.current_version')

if [ "${CURRENT_VERSION}" -gt 5 ]; then
  VERSIONS_TO_DELETE=$(seq 1 $((CURRENT_VERSION - 5)) | tr '\n' ',' | sed 's/,$//')
  echo "Archiving old versions: ${VERSIONS_TO_DELETE}"
  vault kv delete -mount=secret -versions="${VERSIONS_TO_DELETE}" orders-service/config
fi

Claude uses vault kv rollback (available in Vault 1.9+) rather than a manual version re-write, which is the correct approach — rollback promotes an existing version as the new HEAD without creating ambiguous state.

Audit Log Analysis

Prompt:

Write a jq command to analyze Vault audit logs and find the top 10 secrets
being accessed most frequently, showing path and request count.
# Vault audit log is JSONL — one JSON object per line
cat /var/log/vault/audit.log | \
  jq -r 'select(.type == "request") | .request.path' | \
  sort | uniq -c | sort -rn | head -10

# Filter to only successful reads in the last hour
cat /var/log/vault/audit.log | \
  jq -r --arg cutoff "$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)" \
  'select(.type == "response" and .time >= $cutoff and .response.auth != null)
   | .request.path' | \
  sort | uniq -c | sort -rn | head -10

AI is particularly useful for Vault audit log queries because the JSONL format and nested structure make ad-hoc jq expressions tedious to write correctly. Claude handles the nested field selection and date filtering without needing multiple iterations.


Database Role Provisioning

Prompt:

Write Vault configuration for dynamic database credentials.
- Postgres database at db.prod.internal:5432
- Create read-only role: "orders-service-ro", grants SELECT
- Create write role: "orders-service-rw", grants SELECT, INSERT, UPDATE, DELETE
- Role TTL: 1 hour (credentials rotate hourly)
- Max TTL: 24 hours
Include the Terraform configuration and policy.

Claude output:

# Enable database secrets engine
resource "vault_mount" "database" {
  path = "database"
  type = "database"
}

# Configure Postgres connection
resource "vault_database_connection" "postgres" {
  backend       = vault_mount.database.path
  name          = "postgres"
  allowed_roles = ["orders-service-ro", "orders-service-rw"]

  postgresql {
    connection_url       = "postgresql://{{username}}:{{password}}@db.prod.internal:5432/orders"
    username             = "vault_admin"
    password             = var.postgres_admin_password
    verify_connection    = true
    disable_verify_connection = false
  }
}

# Read-only role
resource "vault_database_secret_backend_role" "orders_ro" {
  backend             = vault_mount.database.path
  name                = "orders-service-ro"
  db_name             = vault_database_connection.postgres.name
  creation_statements = [
    "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
    "GRANT CONNECT ON DATABASE orders TO \"{{name}}\";",
    "GRANT USAGE ON SCHEMA public TO \"{{name}}\";",
    "GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"
  ]
  default_ttl = 3600  # 1 hour
  max_ttl     = 86400 # 24 hours
}

# Read-write role
resource "vault_database_secret_backend_role" "orders_rw" {
  backend             = vault_mount.database.path
  name                = "orders-service-rw"
  db_name             = vault_database_connection.postgres.name
  creation_statements = [
    "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
    "GRANT CONNECT ON DATABASE orders TO \"{{name}}\";",
    "GRANT USAGE ON SCHEMA public TO \"{{name}}\";",
    "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"
  ]
  default_ttl = 3600
  max_ttl     = 86400
}

Policy for services to use database credentials:

# Policy: orders-service-db-access
path "database/creds/orders-service-ro" {
  capabilities = ["read"]
}

path "database/creds/orders-service-rw" {
  capabilities = ["read"]
}

path "auth/token/renew-self" {
  capabilities = ["update"]
}

Claude correctly uses {{username}}, {{password}}, and {{expiration}} placeholders — Vault substitutes these when creating roles.

SSH Certificate Signing (Host Access)

Prompt:

Generate Vault SSH CA configuration for signing user certificates.
- SSH CA keypair stored in Vault
- Sign certificates valid for 8 hours
- Allow access to specific hosts via CIDR
- Policy for developers to request signed certificates

Claude output:

# Enable SSH secrets engine
resource "vault_mount" "ssh" {
  path = "ssh"
  type = "ssh"
}

# Configure SSH CA
resource "vault_ssh_secret_backend_ca" "ca" {
  backend = vault_mount.ssh.path
  # Vault generates the CA keypair; store pub key in authorized_keys on servers
  generate_signing_key = true
}

# SSH role for user certificates (8-hour TTL)
resource "vault_ssh_secret_backend_role" "user_cert" {
  backend = vault_mount.ssh.path
  name    = "user-cert"

  ttl                      = 28800  # 8 hours
  max_ttl                  = 28800
  allowed_user_key_config  = "dictated"
  allow_user_certificates  = true

  # Signed certificates can access these principals
  allowed_users = "ubuntu,ec2-user,ubuntu"

  # Optional: restrict to specific hosts by CIDR or hostname pattern
  allowed_domains = "prod-*.internal,staging-*.internal"
}

# Output CA public key to distribute to servers
output "ssh_ca_public_key" {
  value     = vault_ssh_secret_backend_ca.ca.public_key
  sensitive = true
}

Policy for developers:

path "ssh/sign/user-cert" {
  capabilities = ["create", "update"]
}

path "auth/token/lookup-self" {
  capabilities = ["read"]
}

Usage:

# 1. Request signed certificate from Vault
vault write -field=signed_certificate ssh/sign/user-cert \
  username=ubuntu \
  public_key=@~/.ssh/id_rsa.pub > ~/.ssh/id_rsa-cert.pub

# 2. SSH using certificate (no password)
ssh -i ~/.ssh/id_rsa -i ~/.ssh/id_rsa-cert.pub ubuntu@prod-web-01.internal

# 3. SSH servers validate certificate against CA public key
# (installed in /etc/ssh/trusted-user-ca-keys.pub)

Tool Comparison

Feature Claude ChatGPT
KV v2 path prefixes Always correct (data/, metadata/) Often forgets data/
AppRole config Includes CIDR binding, TTLs Minimal, missing options
Kubernetes auth Includes Agent Injector templates Omits Injector pattern
Database dynamic roles Correct placeholder syntax Sometimes wrong
SSH CA setup Complete (CA key, roles, policy) Missing steps
Token renewal paths Includes auth/token/* automatically Omitted
Policy review feedback Identifies overly broad paths Generic feedback
Terraform + HCL Both correct HCL formatting issues

Built by theluckystrike — More at zovo.one