TL;DR: AI agents like Claude Code should not hold static credentials. This guide builds a single OpenBao server (Shamir-sealed) that issues short-lived, scoped secrets on demand: a signed SSH cert for a target VM, and a KV-stored API token for Home Assistant. Both the Windows and Ubuntu client authenticate with a TLS client cert, then pull exactly what the current task needs. Two wrapper scripts (
connect-target,get-ha-token) are what Claude Code actually calls. Nothing long-lived sits on disk.
AI coding agents like Claude Code are agentic: they read the environment, plan an approach, and run commands on your behalf. That is precisely why they must not hold the credentials themselves. A static SSH key or a Home Assistant token sitting in a config file is a standing liability the moment something autonomous can read and reuse it. If the agent's session is compromised, a standing credential is something to steal. A short-lived one is not. I work professionally as a Product & Service Manager and IT Security Architect for privileged access and secrets management, CyberArk/Idira and OpenBao, at 400,000+ employee scale. What follows is the same pattern at home-lab scale.
This walkthrough builds the alternative: a single OpenBao server that hands an agent short-lived, narrowly scoped credentials on demand, for two kinds of access, SSH into a target VM, and API access to Home Assistant. Nothing outlives the task it was issued for. The agent authenticates once with a client cert, and the policy attached to that cert defines what it can pull: SSH signing for one host, read access to one KV path, nothing more. Splitting capabilities across separate certs, instead of one cert with everything, is the next step once you need to reason about blast radius per capability rather than per agent. Compromise a client cert under this model, and the attacker (or a misbehaving agent) gets a short-lived credential scoped to exactly one thing, not a standing key to everything.
The server stays deliberately simple at the start: Shamir-sealed, unsealed manually after a restart. That is the appropriate level of complexity for home-lab risk. Auto-unseal is a real upgrade for an agent that must survive unattended reboots, but it is an optional extension, covered later, not a default.
The pattern is not home-lab-only theory. The SSH CA, KV-backed static credentials, and PKI-issued TLS client certs here are the same mechanisms used to hand credentials to production AI agent deployments at larger scale, just smaller. A home lab is a good place to learn it because the blast radius of a mistake is a VM and a smart light, not a company.
We use OpenBao, a fork of HashiCorp Vault. Mitchell Hashimoto and Armon Dadgar built Vault at HashiCorp starting in 2015, establishing the principle that credentials should be short-lived and centrally issued rather than static and copy-pasted forever. When HashiCorp moved Vault to a Business Source License in 2023, the community forked the last open-source release and continued development as OpenBao, now under the Linux Foundation.
The home lab is the setting because it is a safe, low-stakes place to learn a pattern that scales directly to production agent deployments: authenticate the agent's identity once with a TLS client cert, then let it pull exactly the credential a given task needs, nothing else.
Architecture at a glance
Why this shape: one OpenBao server handles everything. It is Shamir-sealed, unsealed manually after a restart with key shares split across custodians, and hosts two capabilities: an SSH CA for reaching the target, and a KV store for static credentials like the Home Assistant token. Both sit behind the same client-cert identity. One login, then one of two kinds of short-lived secret depending on the task.
The build has two clients authenticating the same way, TLS client cert, then a short-lived token, then whichever secret the task needs: a Windows box running Claude Code, and an Ubuntu box doing the identical sequence from a shell script.
Installing OpenBao
1. Create the service user, data directory, and TLS material location first, so the download and verification steps below can run as that user:
sudo useradd --system --home /etc/openbao --shell /bin/false openbao
sudo mkdir -p /opt/openbao/data /opt/openbao/downloads /etc/openbao/tls
sudo chown -R openbao:openbao /opt/openbao /etc/openbao
2. Download and verify the release, before installing anything. There is no first-party apt repository; packages are distributed as direct downloads and via EPEL for RHEL-family distros. This is the verified method for Ubuntu/Debian:
sudo apt update && sudo apt install -y curl jq gnupg dpkg
# Pinned for repeatable installs across nodes, check
# https://github.com/openbao/openbao/releases for newer versions when you revisit this
VERSION="2.6.1"
ARCH=$(dpkg --print-architecture) # amd64 or arm64
sudo -u openbao -H bash <<EOF
set -euo pipefail
cd /opt/openbao/downloads
VERSION="${VERSION}"
ARCH="${ARCH}"
curl -LO "https://github.com/openbao/openbao/releases/download/v\${VERSION}/openbao_\${VERSION}_linux_\${ARCH}.deb"
curl -LO "https://github.com/openbao/openbao/releases/download/v\${VERSION}/checksums.txt"
curl -LO "https://github.com/openbao/openbao/releases/download/v\${VERSION}/checksums.txt.gpgsig"
curl -LO https://openbao.org/assets/openbao-gpg-pub-20240618.asc
EOF
Verification is required here, not optional, before the package is installed. Clean source is the entire point of pinning a version at all; skipping this step makes the pinned version number meaningless. OpenBao publishes checksums.txt, a GPG signature (checksums.txt.gpgsig), and a Sigstore/cosign bundle with each release.
sudo -u openbao -H bash <<'EOF'
set -euo pipefail
cd /opt/openbao/downloads
# Verify the .deb matches the published checksum
sha256sum --ignore-missing --check checksums.txt
# Verify the checksums file itself was signed by the OpenBao project,
# confirming the checksum file was not tampered with
gpg2 --import openbao-gpg-pub-20240618.asc
gpg2 --verify checksums.txt.gpgsig checksums.txt
EOF
Expect gpg: Good signature from "OpenBao <openbao@lists.lfedge.org>" [unknown]. GPG will also warn WARNING: This key is not certified with a trusted signature! There is no indication that the signature belongs to the owner. That is expected unless you have separately verified and locally signed the key; it does not mean the check failed. If either the checksum or the signature check fails, stop here, do not proceed to install a .deb that failed verification.
3. Only now, install the verified package. This is the one step that genuinely requires root:
sudo dpkg -i "/opt/openbao/downloads/openbao_${VERSION}_linux_${ARCH}.deb"
bao -h # confirms installation and PATH availability
On RHEL/Fedora, the equivalent is EPEL, which handles verification through the distribution's own package signing instead of the manual steps above:
sudo dnf install -y epel-release
sudo dnf install -y openbao
Place your TLS cert/key in /etc/openbao/tls/. A self-signed cert is sufficient for a home lab. If you do not already have an internal CA, generate one covering the server's hostname/IP:
# Generate a CA key + self-signed CA cert (reuse this same CA for every client cert later too)
openssl genrsa -out ca-key.pem 4096
openssl req -x509 -new -nodes -key ca-key.pem -sha256 -days 3650 \
-subj "/CN=homelab-internal-ca" \
-out ca.pem
# Generate the server's key + a CSR
openssl genrsa -out server-key.pem 4096
openssl req -new -key server-key.pem \
-subj "/CN=openbao.homelab.local" \
-out server.csr
# Sign the server cert with your CA, including SANs for hostname + IP
cat > server-ext.cnf <<EOF
subjectAltName = DNS:openbao.homelab.local,IP:<server-ip>
EOF
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -days 825 -sha256 -extfile server-ext.cnf
Copy the results into place and lock down the private key:
sudo cp cert.pem server-key.pem ca.pem /etc/openbao/tls/
sudo mv /etc/openbao/tls/server-key.pem /etc/openbao/tls/key.pem
sudo chown -R openbao:openbao /etc/openbao/tls
sudo chmod 640 /etc/openbao/tls/key.pem
sudo chmod 644 /etc/openbao/tls/cert.pem /etc/openbao/tls/ca.pem
This matches the paths referenced in the bao.hcl listener block below (tls_cert_file/tls_key_file) and the BAO_CACERT used later. Keep ca-key.pem off the server, in secure storage. It signs the Windows and Ubuntu client certs later; if it leaks, every certificate it ever signed is suspect.
Export ca.pem to every other machine that needs to talk to this server. ca.pem is the CA's public certificate, the public half only, it contains no secret material and is safe to copy anywhere. It is what lets a client verify "was this server's TLS cert actually signed by my CA," the same role a public key plays in any signing scheme. ca-key.pem, by contrast, is the private half, that one never leaves secure storage.
To confirm what's actually inside it before trusting it anywhere, ca.pem is a certificate, not a bare key, so it's human-readable with openssl:
openssl x509 -in /etc/openbao/tls/ca.pem -text -noout
Look for Subject: CN = homelab-internal-ca and X509v3 Basic Constraints: CA:TRUE, that confirms it's the CA certificate, not a leaf/server certificate.
4. Write the config file (/etc/openbao/bao.hcl):
# Client API address
api_addr = "https://<server-ip>:8200"
# Node-to-node Raft communication address, only relevant once you add more nodes
cluster_addr = "https://<server-ip>:8201"
storage "raft" {
path = "/opt/openbao/data"
node_id = "openbao-primary"
}
listener "tcp" {
address = "0.0.0.0:8200"
cluster_address = "0.0.0.0:8201"
tls_cert_file = "/etc/openbao/tls/cert.pem"
tls_key_file = "/etc/openbao/tls/key.pem"
tls_disable = 0 # (Set to 0 if you are using TLS certificates)
}
5. Enable and start via systemd. The package installs a unit file; if you used the raw binary, create one:
# /etc/systemd/system/openbao.service
[Unit]
Description=OpenBao
Requires=network-online.target
After=network-online.target
[Service]
User=openbao
Group=openbao
ExecStart=/usr/bin/bao server -config=/etc/openbao/bao.hcl
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
Capabilities=CAP_IPC_LOCK+ep
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now openbao
sudo systemctl status openbao
6. Download the bao CLI to a client of your choice. This can be your admin workstation or laptop; it does not need to be the server. Same GitHub-releases approach as the server install, the CLI-only binary for your OS/architecture:
# Linux example, adjust ARCH/OS for macOS or Windows as needed
VERSION="2.6.1" # keep this matched to the server version above
ARCH=$(dpkg --print-architecture) # amd64 or arm64
curl -LO "https://github.com/openbao/openbao/releases/download/v${VERSION}/openbao_${VERSION}_linux_${ARCH}.deb"
sudo dpkg -i "openbao_${VERSION}_linux_${ARCH}.deb"
bao -h # confirms installation and PATH availability
On Windows, download the .zip release from the same GitHub releases page, extract bao.exe, and add it to PATH. On macOS, brew install openbao works with Homebrew.
7. Set the CLI's target and confirm it is reachable:
export BAO_ADDR="https://<server-ip>:8200"
export BAO_CACERT="/home/<user>/ca.pem"
bao status
You should see Sealed: true immediately after first start. That is expected; init and unseal follow next.
Initialize and unseal
bao operator init -key-shares=5 -key-threshold=3
This returns 5 unseal key shares and a root token. Both require real custody, not a text file on the desktop.
Storing the Shamir key shares: put each share into a password manager entry. KeePassXC is a solid home-lab choice, ideally with the database unlocked via a hardware key (YubiKey, via challenge-response or as a second factor) rather than a master password alone. Distribute the five entries across separate databases, custodians, or locations, a 3-of-5 threshold means no single person or compromised vault can unseal alone. Do not store all five shares in one place; that defeats the purpose of splitting them.
The root token is the single most important secret this command produces. It bypasses every policy and can read and write anything. It is also what bootstraps everything else in this playbook: enabling secrets engines, writing policies, registering cert auth. Until those scoped policies exist, the root token is the only credential capable of that setup work. Treat the following sections as "root token required" and store it with at least as much care as the key shares. Once the policies in this guide exist, stop using the root token for day-to-day operations and consider revoking it (see "Going further" below).
Unseal, repeating with three different key shares:
bao operator unseal
bao operator unseal
bao operator unseal
Operational note: every restart, a patch, a Proxmox host reboot, a power event, brings the server back up sealed. Someone must run bao operator unseal three times with different shares before anything downstream, the agent's SSH or KV access, works again. For a home lab this is a reasonable trade: a simple, single-node setup, at the cost of a few minutes of manual attention after a restart. A short runbook and an alert on sealed=true are worth setting up.
Enable the secrets engines: SSH, PKI, and KV
Now we're going to enable the secret engines. You can find all available on https://openbao.org/docs/secrets/
Before running anything below, authenticate the CLI with the root token, and keep it out of shell history while you're at it. The root token is sensitive enough that it shouldn't sit in ~/.bash_history in plain text:
# Prevent lines containing the token (or any bao/BAO command) from being written to history
echo 'export HISTIGNORE="&:*[Bb][Aa][Oo]*"' >> ~/.bashrc
source ~/.bashrc
export BAO_TOKEN="<root-token>"
Every bao command in this section and the next two run with this token in scope. It stays required until the scoped policies further down exist, at which point "Retiring the root token" (in "Going further") covers moving off it.
SSH secrets engine (CA mode) for the target host
bao secrets enable ssh
bao write ssh/config/ca generate_signing_key=true
Copy the returned public key; it gets installed on the target machine shortly.
bao write ssh/roles/target-access -<<EOF
{
"allow_user_certificates": true,
"allowed_users": "opuser",
"default_extensions": [{"permit-pty": ""}],
"key_type": "ca",
"default_user": "opuser",
"ttl": "15m",
"max_ttl": "1h"
}
EOF
PKI secrets engine for issuing client certs
This engine is what issues the Windows and Ubuntu client certificates later, on demand, through OpenBao's API instead of by hand with openssl each time. It is unrelated to the cert auth method itself, which only needs a CA certificate to verify against; the PKI engine is a convenience for producing certs signed by that same CA.
Reuse the internal CA created earlier for the server's own TLS listener, rather than standing up a second one. Run this from wherever ca-key.pem actually lives, the secure storage location from the earlier step, not the server itself, ca-key.pem was deliberately never copied there. The bao CLI talks to the server over the network, so it does not need to run locally on the server:
bao secrets enable pki
Before importing, confirm the key actually matches the cert, a mismatched pair is the most common cause of the import silently failing to attach a usable key:
openssl x509 -noout -modulus -in ca.pem | openssl md5
openssl rsa -noout -modulus -in ca-key.pem | openssl md5
Both hashes must match. If they don't, this is not the CA's actual private key, track down the real ca-key.pem from the original generation step before continuing.
bao write pki/config/ca \
pem_bundle="$(cat /home/<user>/ca-key.pem /home/<user>/ca.pem)"
Importing a CA creates an issuer, but does not automatically make it the mount's default. Skipping this step is the most common cause of no default issuer currently configured errors on the first pki/issue/... call:
ISSUER_ID=$(bao list -format=json pki/issuers | jq -r '.[0]')
bao write pki/config/issuers default="$ISSUER_ID"
Verify it took, and that the issuer actually has a key attached, pki/issue will fail with "unable to fetch corresponding key for issuer" if not:
bao read pki/config/issuers
bao read pki/issuer/$ISSUER_ID
KV v2 secrets engine for static credentials (Home Assistant example)
1. Create the Home Assistant long-lived access token. This is Home Assistant's equivalent of an API key. It authenticates REST API calls the same way a password would, but is scoped to API access and revocable independently of your login:
- Log into Home Assistant and click your profile icon/name in the bottom left.
- Scroll down to the Security tab.
- Under Long-Lived Access Tokens, click Create Token.
- Give it a descriptive name, for example
openbao-claude-code, to distinguish it from other tokens and revoke it individually if needed. - Home Assistant shows the token exactly once. Copy it immediately; it cannot be viewed again after closing the dialog, only revoked and reissued.
Home Assistant's long-lived tokens do not expire on their own. Treat this like any other static credential: it does not belong in a config file, which is the reason to store it in OpenBao's KV engine rather than secrets.yaml on the client.
2. Store it in OpenBao:
bao secrets enable -path=homeassistant kv-v2
bao kv put homeassistant/api \
token="<the-token-you-just-copied>" \
base_url="http://homeassistant.local:8123"
3. Verify it landed correctly:
bao kv get homeassistant/api
This is the general pattern for any static credential an agent needs, an API key, a webhook secret, a database password: a KV v2 mount, one path per credential, read-only policies scoped per path. Home Assistant is this document's concrete example.
Policies
All capabilities get their own policy, attached to each client identity from the start, so an authenticated agent can reach either one depending on the task.
1. KV read and SSH signing policy
Now we are creating a KV read and SSH signing policy:
cat > ssh-target-sign-policy.hcl <<'EOF'
path "ssh/sign/target-access" {
capabilities = ["create", "update"]
}
path "ssh/roles/target-access" {
capabilities = ["read"]
}
EOF
cat > homeassistant-read-policy.hcl <<'EOF'
path "homeassistant/data/api" {
capabilities = ["read"]
}
EOF
Apply them:
bao policy write ssh-target-sign ssh-target-sign-policy.hcl
bao policy write homeassistant-read homeassistant-read-policy.hcl
2. PKI issuance policy
Admin-side, for issuing client certs once per client during setup, not used by the clients themselves:
cat > pki-issue-admin-policy.hcl <<'EOF'
path "pki/issue/windows-client" {
capabilities = ["create", "update"]
}
path "pki/issue/ubuntu-client" {
capabilities = ["create", "update"]
}
path "pki/roles/windows-client" {
capabilities = ["read"]
}
path "pki/roles/ubuntu-client" {
capabilities = ["read"]
}
EOF
bao policy write pki-issue-admin pki-issue-admin-policy.hcl
Both client identities get their own PKI role, issued the same way:
bao write pki/roles/windows-client allow_any_name=true max_ttl=24h
bao write pki/roles/ubuntu-client allow_any_name=true max_ttl=24h
3. Cert-auth registration, both policies attached from the start
The cert auth method maps each client cert to both policies via its own auth/cert/certs/<name> entry. The agent's identity gets both capabilities in one step here, not extended later.
bao auth enable cert
bao write auth/cert/certs/windows-client \
display_name="claude-cli-windows" \
policies="ssh-target-sign,homeassistant-read" \
certificate=@/home/<user>/ca.pem \
ttl=1h
bao write auth/cert/certs/ubuntu-client \
display_name="claude-cli-ubuntu" \
policies="ssh-target-sign,homeassistant-read" \
certificate=@/home/<user>/ca.pem \
ttl=1h
Both clients land on the identical pair of policies: same blast radius, same TTLs. To keep SSH and Home Assistant access separated, so a compromised cert cannot reach both, split them across two certs per client instead: one with only ssh-target-sign, one with only homeassistant-read. This is a real tradeoff between convenience and blast radius, and one the agent's threat model should drive.
Configuring target to trust the server's SSH CA
This is what lets both clients log in without any pre-provisioned key: the target does not trust keys, it trusts the CA. Once configured, any cert OpenBao signs off that CA, Windows or Ubuntu client, is accepted.
1. Confirm OpenSSH server is installed and running:
sudo apt update && sudo apt install -y openssh-server
sudo systemctl enable --now ssh
2. Create the target login account (matches default_user/allowed_users in the target-access role):
sudo useradd -m -s /bin/bash opuser
# Give it sudo only if the role's use case actually needs it, don't default to this
# sudo usermod -aG sudo opuser
3. Fetch the CA public key from the server and install it as a trusted signer:
# On the OpenBao server (or copy the value from the earlier `bao write ssh/config/ca` output)
bao read -field=public_key ssh/config/ca > /tmp/trusted-user-ca-keys.pem
# On target
sudo mkdir -p /etc/ssh
sudo mv /tmp/trusted-user-ca-keys.pem /etc/ssh/trusted-user-ca-keys.pem
sudo chown root:root /etc/ssh/trusted-user-ca-keys.pem
sudo chmod 644 /etc/ssh/trusted-user-ca-keys.pem
4. Point sshd at it:
# /etc/ssh/sshd_config.d/openbao-ca.conf
sudo tee /etc/ssh/sshd_config.d/openbao-ca.conf <<'EOF'
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
EOF
This single line is sufficient for the CA trust; allowed_users/default_user on the OpenBao role already restrict who a signed cert can log in as.
5. Validate the config and restart sshd:
sudo sshd -t # syntax check before you restart and lock yourself out
sudo systemctl restart ssh
6. Test from a client. This step generates the test files itself, id_ephemeral and id_ephemeral-cert.pub do not exist yet at this point, they are created here. Run this from any machine with the bao CLI configured and network access to the target's SSH port, the target itself works fine for this.
A note on the tool: use ssh-keygen, not raw openssl, to generate the keypair. ssh-keygen uses OpenSSL's crypto underneath, but it writes keys in OpenSSH's own format, which is what OpenBao's ssh/sign endpoint expects as input and what sshd expects on the wire. A key generated with plain openssl genpkey/openssl req is a different format and would need converting with ssh-keygen -i before it could be signed; simplest to generate it in the right format from the start. ssh-keygen is already present wherever openssh-client is installed, which openssh-server pulls in as a dependency, so no extra install is needed on the target.
# Generate a throwaway keypair for this test
ssh-keygen -t ed25519 -f id_ephemeral -N "" -q
# Have OpenBao sign the public half, this is what actually grants access,
# the signed cert, not the keypair itself
bao write -field=signed_key ssh/sign/target-access public_key=@id_ephemeral.pub > id_ephemeral-cert.pub
# Connect using the private key plus its signed certificate
ssh -i id_ephemeral -i id_ephemeral-cert.pub opuser@<ubuntu-target>
Clean up the throwaway files once the test passes, they are only for this manual check, the wrapper scripts later generate and discard their own on every connection:
rm -f id_ephemeral id_ephemeral.pub id_ephemeral-cert.pub
A successful login with no password/key prompt confirms the target trusts the CA correctly. If it falls back to a password or key prompt, check sudo journalctl -u ssh -f on the target while connecting. Common causes: a mismatched allowed_users/default_user on the OpenBao role, an expired cert TTL, or TrustedUserCAKeys pointing at the wrong file.
Ubuntu client, shell-native
The Ubuntu client holds a TLS client cert, authenticates to OpenBao, gets a short-lived token, then either signs an ephemeral SSH key or reads the KV secret depending on the task. Linux has no Certificate Store, so the cert/key live on disk with tight permissions instead.
1. Issue the cert (admin side, using the pki-issue-admin policy)
Run the following from the client which already has the BAO_TOKEN in the cli
bao write -format=json pki/issue/ubuntu-client \
common_name="claude-cli-ubuntu" \
ttl="24h" > ubuntu-client-cert.json
jq -r '.data.certificate' ubuntu-client-cert.json > client.pem
jq -r '.data.private_key' ubuntu-client-cert.json > client-key.pem
jq -r '.data.issuing_ca' ubuntu-client-cert.json > issuing-ca.pem
2. Place the material on the Ubuntu client with tight permissions
sudo mkdir -p /etc/openbao-client
sudo mv client.pem client-key.pem issuing-ca.pem /etc/openbao-client/
# Lock the private key down to the service account that runs Claude Code / the wrapper scripts
sudo chown <service-account> /etc/openbao-client/client-key.pem
sudo chmod 640 /etc/openbao-client/client-key.pem
sudo chmod 644 /etc/openbao-client/client.pem /etc/openbao-client/issuing-ca.pem
For higher assurance than a file on disk, consider the pass/gpg-agent route or a TPM-backed key via openssl engine/pkcs11. The file-based approach above is the reasonable home-lab default.
3. connect-target.sh, SSH access
#!/usr/bin/env bash
#
# Authenticates to OpenBao via TLS client cert, signs an ephemeral
# SSH key for target access, and connects.
#
# Usage:
# ./connect-target.sh # interactive shell
# ./connect-target.sh "systemctl status nginx" # one-off command
set -euo pipefail
BAO_ADDR="https://openbao.homelab.local:8200"
CLIENT_CERT="/etc/openbao-client/client.pem"
CLIENT_KEY="/etc/openbao-client/client-key.pem"
CA_CERT="/etc/openbao-client/issuing-ca.pem"
SSH_ROLE="target-access"
SSH_USER="opuser"
TARGET_HOST="target.homelab.local"
COMMAND="${1:-}"
KEY_DIR="$(mktemp -d /tmp/bao-ssh-XXXXXX)"
trap 'rm -rf "$KEY_DIR"' EXIT
# --- 1. Authenticate to OpenBao via client cert ---
TOKEN="$(curl -s --cert "$CLIENT_CERT" --key "$CLIENT_KEY" --cacert "$CA_CERT" \
-X POST "$BAO_ADDR/v1/auth/cert/login" | jq -r '.auth.client_token')"
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
echo "OpenBao login did not return a token." >&2
exit 1
fi
# --- 2. Generate an ephemeral local SSH keypair ---
ssh-keygen -t ed25519 -f "$KEY_DIR/id_ephemeral" -N "" -q
# --- 3. Request a signed cert from OpenBao's ssh secrets engine ---
PUB_KEY="$(cat "$KEY_DIR/id_ephemeral.pub")"
curl -s --cacert "$CA_CERT" \
-H "X-Vault-Token: $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"public_key\": \"$PUB_KEY\"}" \
-X POST "$BAO_ADDR/v1/ssh/sign/$SSH_ROLE" \
| jq -r '.data.signed_key' > "$KEY_DIR/id_ephemeral-cert.pub"
# --- 4. Connect ---
if [[ -n "$COMMAND" ]]; then
ssh -i "$KEY_DIR/id_ephemeral" -i "$KEY_DIR/id_ephemeral-cert.pub" "$SSH_USER@$TARGET_HOST" "$COMMAND"
else
ssh -i "$KEY_DIR/id_ephemeral" -i "$KEY_DIR/id_ephemeral-cert.pub" "$SSH_USER@$TARGET_HOST"
fi
# --- 5. Cleanup happens automatically via the trap above ---
chmod +x connect-target.sh
Verifying the connection. Run this from the same directory as connect-target.sh, it calls the script directly, so it needs to be run alongside it, or with a path to it if you've placed it elsewhere. Run it as a cheap, unambiguous check.
./connect-target.sh
./connect-target.sh "whoami"
./connect-target.sh "systemctl status sshd"
4. get-ha-token.sh, Home Assistant API access
#!/usr/bin/env bash
#
# Fetches the Home Assistant API token from OpenBao and prints
# it as shell-sourceable export statements.
#
# Usage:
# source ./get-ha-token.sh
# curl -s -H "Authorization: Bearer $HA_TOKEN" "$HA_BASE_URL/api/states"
set -euo pipefail
BAO_ADDR="https://openbao.homelab.local:8200"
CLIENT_CERT="/etc/openbao-client/client.pem"
CLIENT_KEY="/etc/openbao-client/client-key.pem"
CA_CERT="/etc/openbao-client/issuing-ca.pem"
TOKEN="$(curl -s --cert "$CLIENT_CERT" --key "$CLIENT_KEY" --cacert "$CA_CERT" \
-X POST "$BAO_ADDR/v1/auth/cert/login" | jq -r '.auth.client_token')"
SECRET="$(curl -s --cacert "$CA_CERT" \
-H "X-Vault-Token: $TOKEN" \
"$BAO_ADDR/v1/homeassistant/data/api")"
export HA_TOKEN="$(echo "$SECRET" | jq -r '.data.data.token')"
export HA_BASE_URL="$(echo "$SECRET" | jq -r '.data.data.base_url')"
echo "HA_TOKEN and HA_BASE_URL set for this shell."
Make it executable:
chmod +x get-ha-token.sh
Verifying the connection. Home Assistant's REST API has a lightweight root endpoint (/api/) that confirms the API is up and the token is valid. Of course you need to have any api available in your HA setup. I've chosen the ecotracker, swap this for any entity you actually have.
source ./get-ha-token.sh
curl -s -H "Authorization: Bearer $HA_TOKEN" "$HA_BASE_URL/api/states/sensor.ecotracker_total_grid_export"
You can also do an additional check:
source ./get-ha-token.sh
CHECK=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $HA_TOKEN" "$HA_BASE_URL/api/")
if [ "$CHECK" = "200" ]; then
echo "Home Assistant reachable, token valid."
else
echo "Home Assistant check failed with HTTP $CHECK"
fi
A 401 means the token from OpenBao is wrong or was revoked in Home Assistant. A connection failure, curl returning no code or 000, means HA_BASE_URL or the network path is wrong. Distinguish the two before assuming a later, more specific API call failed for some other reason.
Wiring into Claude Code on Linux: point CLAUDE.md at both scripts, for example: "To run commands on target, always use ./connect-target.sh '<cmd>'. To call the Home Assistant API, first run source ./get-ha-token.sh to populate HA_TOKEN. Never hardcode credentials or ask the user for either directly." This keeps the agent from reaching for a raw ssh call or a hardcoded token.
My Ubuntu CLAUDE.md:
## Ubuntu / Bash Operations
When executing commands on the target machine or interacting with the Home Assistant API in this project, **never use raw `ssh` or hardcode API tokens**. Always use the provided Bash wrapper scripts to handle OpenBao authentication and routing automatically. Never ask the user for credentials.
### 1. Target Execution & Verification
To execute commands on the remote target, you must use `connect-target.sh`.
* **Rule:** Wrap all remote executions in `./connect-target.sh "<command>"`
* **Verification:** Before running complex operations, you must verify the connection and principal by running this exact check:
```
bash
RESULT=$(./connect-target.sh "echo CONNECTED_OK && whoami && hostname")
if echo "$RESULT" \vert{} grep -q "CONNECTED_OK" && echo "$RESULT" | grep -q "opuser"; then
echo "Target reachable, authenticated as opuser."
else
echo "Unexpected result, check the output above."
exit 1
fi
```
*Do not proceed with further remote commands unless this check passes.*
### 2. Home Assistant API Access
When interacting with the Home Assistant API, you must retrieve ephemeral credentials via OpenBao.
* **Rule:** Always source the token via `get-ha-token.sh` to populate the environment variables before making `curl` requests.
* **Execution pattern:**
```
bash
source ./get-ha-token.sh
curl -s -H "Authorization: Bearer $HA_TOKEN" "$HA_BASE_URL/api/<endpoint>"
```
* **Verification:** Before executing API changes, verify the token validity and network path by checking the root endpoint for a `200` response:
```
bash
source ./get-ha-token.sh
CHECK=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $HA_TOKEN" "$HA_BASE_URL/api/")
if [ "$CHECK" = "200" ]; then
echo "Home Assistant reachable, token valid."
else
echo "Home Assistant check failed with HTTP $CHECK"
exit 1
fi
```
*Do not proceed with Home Assistant API calls unless this check returns HTTP 200. A 401 indicates an OpenBao token issue; a 000 indicates a network/URL issue.*
My prompt:
connect to home assistant to check total grid import; then connect to ubuntu target and check which ubuntu version is running
Aaaand it works like a charm:
Claude Code was able to access the targets via OpenBao from the Ubuntu client:
Same pattern on Windows
Everything above works identically on a Windows machine: same client cert, same OpenBao login, same signed SSH cert or KV read. The one real difference is where the cert lives. Windows has no direct filesystem-permission equivalent, so it goes into the Windows Certificate Store instead of /etc/openbao-client/.
Issue and import the cert:
# Admin side, same as the Ubuntu issuance, different role/CN
bao write -format=json pki/issue/windows-client \
common_name="claude-cli-windows" \
ttl="8h" > windows-client-cert.json
jq -r '.data.certificate' windows-client-cert.json > client.pem
jq -r '.data.private_key' windows-client-cert.json > client-key.pem
jq -r '.data.issuing_ca' windows-client-cert.json > issuing-ca.pem
openssl pkcs12 -export -out claude-cli-windows.pfx \
-inkey client-key.pem -in client.pem -certfile issuing-ca.pem \
-passout pass:CHANGE_THIS_TEMP_PASSWORD
# On the Windows client, PowerShell, not elevated (lands in CurrentUser)
$pfxPassword = Read-Host -Prompt "PFX password" -AsSecureString
Import-PfxCertificate -FilePath "C:\Staging\claude-cli-windows.pfx" `
-CertStoreLocation Cert:\CurrentUser\My -Password $pfxPassword
# Note the thumbprint for the wrapper scripts below
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -match "claude-cli-windows" }
Import-Certificate -FilePath "C:\Staging\issuing-ca.pem" -CertStoreLocation Cert:\CurrentUser\Root
Remove-Item C:\Staging\claude-cli-windows.pfx, C:\Staging\client-key.pem -Force
connect-target.ps1:
param([string]$Command = "")
$ErrorActionPreference = "Stop"
$BaoAddr = "https://openbao.homelab.local:8200"
$ClientCertThumbprint = "PUT_THUMBPRINT_HERE"
$SshRole = "target-access"
$SshUser = "opuser"
$TargetHost = "target.homelab.local"
$KeyDir = "$env:TEMP\bao-ssh-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $KeyDir | Out-Null
try {
# 1. Authenticate
$token = (Invoke-RestMethod -Uri "$BaoAddr/v1/auth/cert/login" -Method Post `
-CertificateThumbprint $ClientCertThumbprint).auth.client_token
if (-not $token) { throw "OpenBao login did not return a token." }
# 2. Generate Key
$privKeyPath = Join-Path $KeyDir "id_ephemeral"
ssh-keygen -t ed25519 -f $privKeyPath -N '""' -q
# 3. Read the public key, explicitly cast to string, and trim whitespace/newlines
$pubKey = [string](Get-Content "$privKeyPath.pub" -Raw).Trim()
# 4. Create the JSON body outside of the Invoke-RestMethod call
$jsonBody = @{ public_key = $pubKey } | ConvertTo-Json -Compress
# 5. Request Signed Cert
$signedCert = (Invoke-RestMethod -Uri "$BaoAddr/v1/ssh/sign/$SshRole" -Method Post `
-Headers @{ "X-Vault-Token" = $token } `
-Body $jsonBody -ContentType "application/json").data.signed_key
$certPath = Join-Path $KeyDir "id_ephemeral-cert.pub"
Set-Content -Path $certPath -Value $signedCert -NoNewline
# 6. Connect
if ($Command -ne "") { ssh -i $privKeyPath -i $certPath "$SshUser@$TargetHost" $Command }
else { ssh -i $privKeyPath -i $certPath "$SshUser@$TargetHost" }
}
finally {
Remove-Item -Recurse -Force $KeyDir -ErrorAction SilentlyContinue
}
Get-HaToken.ps1:
$BaoAddr = "https://openbao.homelab.local:8200"
$ClientCertThumbprint = "PUT_THUMBPRINT_HERE"
$token = (Invoke-RestMethod -Uri "$BaoAddr/v1/auth/cert/login" -Method Post `
-CertificateThumbprint $ClientCertThumbprint).auth.client_token
$secret = Invoke-RestMethod -Uri "$BaoAddr/v1/homeassistant/data/api" -Method Get `
-Headers @{ "X-Vault-Token" = $token }
$env:HA_TOKEN = $secret.data.data.token
$env:HA_BASE_URL = $secret.data.data.base_url
Write-Host "HA_TOKEN and HA_BASE_URL set for this session."
Usage, same verification logic as the Ubuntu scripts:
.\connect-target.ps1 -Command "echo CONNECTED_OK && whoami && hostname"
.\Get-HaToken.ps1
Invoke-RestMethod -Uri "$env:HA_BASE_URL/api/" -Headers @{ Authorization = "Bearer $env:HA_TOKEN" }
The verification approach and the CLAUDE.md wiring match the Ubuntu section above: check for the expected marker/output, point Claude Code at the wrapper scripts instead of raw ssh/curl calls with stored credentials.
My CLAUDE.md
## Windows / PowerShell Operations
When executing commands on the target machine or interacting with the Home Assistant API in this project, **never use raw `ssh` or `curl` commands**. Always use the provided PowerShell wrapper scripts to handle authentication and routing automatically.
### 1. Target Execution & Verification
To execute commands on the remote target, you must use `connect-target.ps1`.
* **Rule:** Wrap all remote executions in `.\connect-target.ps1 -Command "..."`
* **Verification:** Before running complex operations, verify the connection by checking for the `CONNECTED_OK` marker:
```
powershell
.\connect-target.ps1 -Command "echo CONNECTED_OK && whoami && hostname"
```
*Do not proceed with further remote commands unless you see `CONNECTED_OK` in the output.*
### 2. Home Assistant API Access
When interacting with the Home Assistant API, do not attempt to find or use hardcoded credentials.
* **Rule:** Always source the token via `Get-HaToken.ps1` to populate the environment variables, then use `Invoke-RestMethod`.
* **Execution pattern:**
```
powershell
.\Get-HaToken.ps1
Invoke-RestMethod -Uri "$env:HA_BASE_URL/api/" -Headers @{ Authorization = "Bearer $env:HA_TOKEN" }
```
Claude Code was able to access the targets via OpenBao from the Windows client as well:

As my teacher liked to state: quod erat demonstrandum
Going further: transit auto-unseal and root token hygiene
Everything above gives a fully working setup with one deliberate manual step: unsealing after a restart. That is sufficient for most home labs. If the server needs to survive an unattended reboot without you physically present, for example an agent depends on it continuously and a stalled task should not wait on you to notice, remove that manual step by adding a second OpenBao instance whose only job is holding an unseal key via the transit secrets engine. This is additive: nothing above changes, this inserts a helper node upstream of the server already built.
How it works: instead of Shamir shares, the primary server's bao.hcl gets a seal "transit" stanza pointing at a second, separate OpenBao instance, the "unseal node". On every start, the primary calls the unseal node's transit engine to decrypt its master key automatically, no bao operator unseal required. The trade-off: the unseal node itself still needs something to unseal it, typically Shamir, kept manual since it restarts far less often than the primary, and it becomes a hard availability dependency. If it is down or unreachable, the primary will not come up after a restart even though it is otherwise healthy.
There are plenty of guides out there how to set this up.
Proxmox snapshots and Raft state
If the OpenBao server runs as a Proxmox VM, as this guide assumes, do not roll back a live Proxmox snapshot of that VM while OpenBao is running. Raft storage keeps a consistency log on disk, and Proxmox's snapshot mechanism has no awareness of that log or the need to keep it consistent with OpenBao's own state. Reverting to an older snapshot can leave Raft's log pointing at a state that no longer matches reality, which shows up as anything from OpenBao refusing to start to confusing data inconsistencies, the kind of failure that is hard to debug because it looks like OpenBao is broken rather than the storage underneath it.
For a safe rollback point, stop the openbao service first, before taking or restoring a Proxmox snapshot. Better: do not rely on hypervisor snapshots for OpenBao backups at all. Use Raft's own snapshot mechanism, which stays consistent with what Raft actually knows about its own state:
bao operator raft snapshot save backup-$(date +%Y%m%d).snap
Restoring from one, only for troubleshooting, since it replaces current state:
bao operator raft snapshot restore backup-20260101.snap
Use Proxmox snapshots for what they are good at: disaster recovery of the whole VM if something below the OS breaks. Use bao operator raft snapshot save for OpenBao state backups. The same logic applies when moving from a single node to a multi-node Raft cluster: an inconsistent snapshot restore on one node while the others keep running is the standard way to end up with split-brain, where nodes disagree about the current state.
Retiring the root token
Once the policies in this guide exist (ssh-target-sign, homeassistant-read, pki-issue-admin, and cert-auth registrations), the root token has done its job and should not remain a standing credential. Revoke it:
bao token revoke -self
For root-equivalent access again later, adding a new secrets engine, changing a policy structure, or anything else the guide's scoped policies do not cover, generate a new one on demand instead of keeping one permanently:
bao operator generate-root -init
# follow the prompts, this walks you through combining key shares
# (the same ones from the KeePassXC entries above) to mint a fresh root token










Top comments (0)