courses

OpenBao

Giving secrets one home with an audit trail

OpenBao is an open-source secrets management system. It stores secrets encrypted at rest, enforces access policies, issues time-limited dynamic credentials, and logs every access with a full audit trail.

OpenBao is the Linux Foundation fork of HashiCorp Vault, created in 2023 after Vault moved from the open-source MPL to the Business Source License (BSL). It is API- and CLI-compatible with Vault — the commands below are the same ones you would run against Vault, with bao in place of vault — so the concepts transfer directly. This course standardizes on OpenBao for the same reason it uses OpenTofu instead of Terraform: both are the OSI-licensed forks of tools HashiCorp relicensed.

The core problem OpenBao solves: secrets have to live somewhere. .env files get committed. Environment variables leak through process listings and crash logs. Shared credentials mean no accountability. OpenBao centralizes secrets with controlled access and an audit log that answers “who read the database password and when.”

ℹ️ OpenBao is only as strong as how you handle the unseal keys and root token. bao operator init prints them once. In this lab a text file is acceptable; in production the key shares belong in separate custody (HSMs, distinct people) and the root token is revoked after initial setup. Anyone holding a threshold of unseal keys can decrypt everything.

OpenBao also shows up in the pipeline context — see CI/CD, secret management, and GitOps for how services pull secrets during a deploy.

How OpenBao differs from HashiCorp Vault

“API-compatible fork” undersells what has happened since 2023 — the projects are actively diverging, in both directions. Knowing the differences matters when you read Vault documentation (most of it still applies), interview at a shop running one or the other, or advise on a migration.

Origin and governance

OpenBao forked from Vault 1.14, the last MPL-2.0 release, after HashiCorp relicensed Vault under the Business Source License (BSL 1.1) in August 2023. The BSL is source-available but not open source: its “Additional Use Grant” forbids offering Vault as a competing hosted service. The fork now develops under the Linux Foundation (currently the OpenSSF, after starting in LF Edge). Meanwhile IBM closed its acquisition of HashiCorp in February 2025, and Vault 2.0 (April 2026) moved the product onto IBM’s versioning and support lifecycle — so today the comparison is really community-governed fork vs. IBM enterprise product.

What’s the same

Where they diverge

Area OpenBao (MPL-2.0, Linux Foundation) HashiCorp/IBM Vault (BSL 1.1)
Namespaces (multi-tenancy) Free — GA since 2.3.1 (June 2025) Enterprise-only
HSM auto-unseal (PKCS#11) Free — since 2.2 Enterprise-only
Standby read scaling Free — HA standbys serve reads since 2.5 Enterprise (“performance standbys”)
Cross-cluster replication (DR/performance) Not available Enterprise — the biggest gap
Advanced policy language CEL (Common Expression Language) roles Sentinel (Enterprise)
FIPS-validated builds, KMIP, Secrets Sync Not available Enterprise/HCP
Storage backends Integrated Raft or PostgreSQL only — Consul, DynamoDB, S3, etcd, and the other legacy backends were deliberately removed Long list retained
Cloud plugins (AWS/Azure/GCP auth & secrets) External plugins, installed separately Bundled
Managed service None — self-host HCP Vault Dedicated

Two patterns worth noticing in that table. First, OpenBao’s headline additions — namespaces, HSM unseal, read scaling — are precisely features Vault reserves for Enterprise; the fork competes by open-sourcing the paywall. Second, OpenBao’s removals (legacy storage backends, the SSPL-licensed MongoDB plugin) are a security posture: fewer code paths, only OSI-licensed dependencies, Raft or Postgres so every deployment looks alike. Smaller attack surface is a feature.

⚠️ Migration is only supported one way and from one place: OpenBao documents in-place migration from Vault 1.14.x specifically. A Vault cluster that has been upgraded past that (or uses Enterprise features like replication) has no clean path across, and OpenBao data does not migrate back to Vault. Treat the two as compatible dialects that drift further apart every release — check both changelogs before assuming a Vault feature exists in OpenBao or vice versa.

For this course none of the gaps matter: everything in the labs (KV, policies, AppRole, audit devices) sits in the shared core. The skills transfer to a Vault shop unchanged.

Concepts

Term Definition
Secrets Engine A plugin that stores or generates secrets (KV, database, PKI, AWS, etc.)
Auth Method How clients authenticate to OpenBao (token, AppRole, TLS cert, etc.)
Policy HCL rules that grant or deny capabilities on specific paths
Token A bearer credential issued after authentication; scoped to policies
Lease A time-to-live attached to dynamic secrets; must be renewed or the credential expires
Audit Device A log sink (file, syslog) that records every OpenBao request

Installation and Setup as a systemd Service

Install

OpenBao ships as a .deb release asset. The Ansible playbook installs it for you; to do it by hand:

# Download and install the OpenBao .deb (already done by Ansible)
VER=2.5.5   # current release — see github.com/openbao/openbao/releases
curl -fsSLO "https://github.com/openbao/openbao/releases/download/v${VER}/bao_${VER}_linux_amd64.deb"
sudo apt install "./bao_${VER}_linux_amd64.deb"

bao version

Configuration file

# /etc/openbao/openbao.hcl

ui = true

storage "file" {
  path = "/opt/openbao/data"
}

listener "tcp" {
  address     = "127.0.0.1:8200"
  tls_disable = true    # TLS disabled for lab; enable in production
}

api_addr = "http://127.0.0.1:8200"
# Create storage directory
sudo mkdir -p /opt/openbao/data
sudo chown openbao:openbao /opt/openbao/data

# Enable and start the systemd unit (installed with the package)
sudo systemctl enable --now openbao

systemctl status openbao

Initialize and unseal

OpenBao starts in a sealed state — it holds encrypted data but cannot decrypt it until enough unseal keys are provided. Initialization generates the master key and splits it using Shamir’s Secret Sharing.

# The bao CLI reads BAO_ADDR (and still accepts the legacy VAULT_ADDR).
export BAO_ADDR='http://127.0.0.1:8200'

# Initialize: generates 5 key shares, requires 3 to unseal
bao operator init -key-shares=5 -key-threshold=3

# Output contains:
#   Unseal Key 1: abc...
#   Unseal Key 2: def...
#   ...
#   Initial Root Token: <root-token>
#
# Store these somewhere safe. In a lab, a text file is fine.
# In production, these go into separate HSMs or key management systems.

# Unseal with 3 of the 5 keys
bao operator unseal <key-1>
bao operator unseal <key-2>
bao operator unseal <key-3>

# Check status — Sealed: false means ready
bao status

Authenticate

export BAO_TOKEN='<root-token>'   # root token from init output

# Verify
bao token lookup

KV Secrets Engine

The Key-Value (KV) engine stores arbitrary secrets as key-value pairs. Version 2 (KV-v2) keeps a history of every version.

# Enable KV-v2 at the path "secret/"
bao secrets enable -path=secret kv-v2

# Write a secret
bao kv put secret/dvwa/db \
    username=dvwauser \
    password=ChangeMeNow

# Read the secret
bao kv get secret/dvwa/db

# Read just one field
bao kv get -field=password secret/dvwa/db

# List all secrets at a path
bao kv list secret/dvwa

# Update a field without overwriting others
bao kv patch secret/dvwa/db password=NewPassword

# View version history
bao kv metadata get secret/dvwa/db

# Read a specific version
bao kv get -version=1 secret/dvwa/db

Policies

Policies grant the minimum capabilities needed — nothing more. A policy for a service that only reads its own secrets should not be able to list all secrets or write to other paths.

Capability reference

Capability What it allows
create Create new secrets at a path
read Read secret values
update Overwrite existing secrets
delete Delete secrets
list List secret names (not values)
patch Update individual fields

Write a read-only policy

# policy/dvwa-read.hcl — allows reading DVWA's own secrets only
path "secret/data/dvwa/*" {
  capabilities = ["read"]
}

path "secret/metadata/dvwa/*" {
  capabilities = ["list"]
}
bao policy write dvwa-read policy/dvwa-read.hcl

# Verify
bao policy read dvwa-read
bao policy list

Create a token scoped to a policy

# Create a token valid for 24 hours, tied to dvwa-read policy
bao token create \
    -policy=dvwa-read \
    -ttl=24h \
    -display-name=dvwa-app

# Output:
#   token           <scoped-token>
#   token_policies  dvwa-read
#   token_ttl       24h

# Test the token — this should work
BAO_TOKEN=<scoped-token> bao kv get secret/dvwa/db

# This should fail (no write capability)
BAO_TOKEN=<scoped-token> bao kv put secret/dvwa/db password=hacked

AppRole Authentication

Token-based auth works for humans. Services that need to authenticate programmatically use AppRole: a role with a Role ID (like a username) and a Secret ID (like a one-time password).

# Enable AppRole auth method
bao auth enable approle

# Create a role tied to the dvwa-read policy
bao write auth/approle/role/dvwa-role \
    token_policies=dvwa-read \
    token_ttl=1h \
    token_max_ttl=4h \
    secret_id_ttl=24h

# Get the Role ID (static, safe to store in config)
bao read auth/approle/role/dvwa-role/role-id

# Generate a Secret ID (single-use, short-lived)
bao write -f auth/approle/role/dvwa-role/secret-id

# Authenticate using both
bao write auth/approle/login \
    role_id=<role-id> \
    secret_id=<secret-id>
# Returns a token scoped to dvwa-read

The “secret zero” problem — where does the Secret ID come from?

AppRole splits the credential in two on purpose: the Role ID is static and non-sensitive (bake it into the config, image, or an environment variable — alone it is useless), while the Secret ID is the sensitive half. That raises the obvious question: if the app needs a Secret ID to fetch its secrets, how does the Secret ID itself reach the app securely? Storing it in a file next to the code just moves the problem — that file is now the secret you have to protect. This is the secret zero (or secure introduction) problem: the first credential in the chain, the one that unlocks all the others.

The answer is that the Secret ID is delivered at deploy time by something the workload already trusts, and held only in memory long enough to log in — never committed, never parked at rest. Two standard patterns:

⚠️ Never generate the Secret ID inside the service and store it long-term, and never commit one — that recreates the very problem AppRole exists to solve. The design goal running through the whole chain is that no long-lived, directly-usable credential sits at rest on the app host: the Role ID is useless alone, the Secret ID is single-use and short-lived, the resulting token expires in an hour, and (with the database engine) even the fetched credential can be per-session.

Python Integration with hvac

hvac is the Vault HTTP API client for Python. Because OpenBao speaks the same API, hvac works against it unchanged — just point it at the OpenBao address. Installed by the Ansible playbook.

Read a static secret

import hvac
import os

client = hvac.Client(
    url=os.environ.get('BAO_ADDR', 'http://127.0.0.1:8200'),
    token=os.environ['BAO_TOKEN'],
)

assert client.is_authenticated(), "OpenBao authentication failed"

secret = client.secrets.kv.v2.read_secret_version(
    path='dvwa/db',
    mount_point='secret',
)

db_user     = secret['data']['data']['username']
db_password = secret['data']['data']['password']

print(f"Connecting as {db_user}")

Authenticate with AppRole

import hvac
import os

client = hvac.Client(url=os.environ.get('BAO_ADDR', 'http://127.0.0.1:8200'))

resp = client.auth.approle.login(
    role_id=os.environ['BAO_ROLE_ID'],
    secret_id=os.environ['BAO_SECRET_ID'],
)

# Token is now set automatically
assert client.is_authenticated()

secret = client.secrets.kv.v2.read_secret_version(
    path='dvwa/db',
    mount_point='secret',
)
db_password = secret['data']['data']['password']

Complete get-secret script

#!/usr/bin/env python3
# scripts/get-secret.py
# Usage: BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN=<token> \
#        python3 get-secret.py secret/dvwa/db password

import hvac
import os
import sys

def get_secret_field(path: str, field: str) -> str:
    client = hvac.Client(
        url=os.environ.get('BAO_ADDR', 'http://127.0.0.1:8200'),
        token=os.environ['BAO_TOKEN'],
    )
    if not client.is_authenticated():
        raise RuntimeError("OpenBao authentication failed")

    # path is e.g. "secret/dvwa/db" — split mount from secret path
    parts = path.split('/', 1)
    mount, secret_path = parts[0], parts[1]

    response = client.secrets.kv.v2.read_secret_version(
        path=secret_path,
        mount_point=mount,
    )
    return response['data']['data'][field]

if __name__ == '__main__':
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} <path> <field>", file=sys.stderr)
        sys.exit(1)

    value = get_secret_field(sys.argv[1], sys.argv[2])
    print(value)

Audit Logging

OpenBao’s audit device logs every request and response. Enable it early — the audit log is what you query during an incident to answer “did anything read this secret?”

# Enable file audit (logs to /var/log/openbao/audit.log)
sudo mkdir -p /var/log/openbao
sudo chown openbao:openbao /var/log/openbao

bao audit enable file file_path=/var/log/openbao/audit.log

# Verify
bao audit list

# View recent entries (values are HMAC'd — not stored in plaintext)
tail -f /var/log/openbao/audit.log | jq .

Eliminating Plaintext Credentials

After setting up OpenBao, verify no credentials exist anywhere in plaintext on the system:

# Search for common patterns in config files
grep -rn --include="*.conf" --include="*.yml" --include="*.yaml" \
    -E '(password|passwd|secret|api_key)\s*[:=]\s*\S+' /etc /opt 2>/dev/null \
    | grep -v '^Binary' \
    | grep -v '# '   # skip comments

# Search for credentials in environment variables
cat /proc/*/environ 2>/dev/null \
    | tr '\0' '\n' \
    | grep -iE '(password|secret|token|key)='

# Search git history for committed secrets
git log -p --all | grep -iE '(password|secret|token|api.key)\s*[:=]'

Any credential that appears in these searches needs to be rotated and removed.

Key takeaways

References


Related course pages: CI/CD & DevOps · Container security · DevSecOps fundamentals

🛠️ Maintenance note: OpenBao is the OpenBao Project (Linux Foundation) fork of HashiCorp Vault, adopted here because Vault moved to the BSL license in 2023 (the same reason this course uses OpenTofu over Terraform). The install pins a release tag (openbao_version in the Ansible group_vars, currently 2.5.5) — bump it from the releases page each term. The CLI is bao; it reads BAO_ADDR/BAO_TOKEN but still honors the legacy VAULT_ADDR/VAULT_TOKEN. Because OpenBao tracks Vault’s API, hvac works unchanged — re-check its read_secret_version argument names against the installed version.