Intro
Since Part 5, the only way into OpenBao was the root token: a credential with unlimited power and no expiry, copied from a file on my laptop whenever I needed to read a secret. That’s fine for bootstrapping and bad for daily use.
I already pay for Cloudflare, and Cloudflare Access can act as an OIDC provider for any application. So the plan was: OpenBao trusts Cloudflare Access, Cloudflare Access trusts Google, and only my Gmail address gets in.
TL;DR: Cloudflare Access “SaaS application” (OIDC) in front, Google as the login method, and OpenBao’s
oidcauth method with one role bound to my email. Logins give a 1-houradmintoken (8 hours max), in the web UI and the CLI. The root token is now break-glass only. The one surprise was my own setup script talking plain HTTP to a TLS-only OpenBao.
The Situation
- OpenBao 2.7.1: three pods with Raft, TLS from my lab CA, UI and API
reachable only over Tailscale (
https://openbao.<tailnet>.ts.net) - Auth methods before:
kubernetes(for External Secrets) and the root token - Cloudflare Zero Trust team
<team>.cloudflareaccess.com - Tetragon watches outbound connections from the
openbaonamespace (Part 11) and alerts on anything outside the cluster and LAN (Part 17)
Setup
The chain of trust
browser or bao CLI
-> OpenBao (auth/oidc, role admin)
-> Cloudflare Access (SaaS app "OpenBao", OIDC)
-> Google (identity provider, my Google account's 2FA)
OpenBao never talks to Google. It only trusts Cloudflare’s ID token and checks
one claim: email.
Google as the login method
In Google Cloud I created an OAuth client of type Web application, with the consent screen in Testing mode and my Gmail as the only test user. Its redirect URI is Cloudflare’s callback:
https://<team>.cloudflareaccess.com/cdn-cgi/access/callback
Cloudflare’s menus have moved since most guides were written. Identity providers are now under Zero Trust > Integrations > Identity providers, not under Settings. The Test button showed exactly the identity OpenBao would see:
{
"name": "Frans Caisar Ramadhan",
"email": "<my gmail>"
}
The OpenBao app in Cloudflare Access
Under Access controls > Applications > SaaS application, with protocol OIDC:
| Setting | Value |
|---|---|
| Redirect URLs | https://openbao.<tailnet>.ts.net/ui/vault/auth/oidc/oidc/callback and http://localhost:8250/oidc/callback |
| Scopes | openid, email, profile |
| PKCE | off (OpenBao uses the client secret) |
| Identity providers | Google only |
| Policy | Allow, include one email address |
The first redirect URL is for the web UI; the second is where the bao CLI
listens during bao login.
The OpenBao side, as a script
I wanted this reproducible, so it’s a script in the repo, oidc.sh. It writes
an admin policy, enables auth/oidc, and creates the role. The root token
and the client secret come in on stdin, so neither ends up in shell history or
process arguments:
{ jq -r .root_token ~/.config/openbao/init-lab.json; pbpaste; } \
| kubectl -n openbao exec -i openbao-0 -c openbao -- sh -c "$(cat kubernetes/apps/openbao/oidc.sh)" oidc \
"https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client-id>" "<client-id>" \
"<my gmail>" "openbao.<tailnet>.ts.net"
The role is written as a JSON body. I didn’t trust the CLI to parse a map
like bound_claims from a plain key=value string:
{
"role_type": "oidc",
"user_claim": "email",
"bound_claims": {"email": ["<my gmail>"]},
"oidc_scopes": ["openid", "email", "profile"],
"allowed_redirect_uris": [
"https://openbao.<tailnet>.ts.net/ui/vault/auth/oidc/oidc/callback",
"http://localhost:8250/oidc/callback"
],
"token_policies": ["admin"],
"token_ttl": "1h",
"token_max_ttl": "8h"
}
Before the script read the clipboard, I checked what was on it without printing it: 64 characters, no whitespace, and not equal to the client ID. The Access app and Google each have a client secret, and pasting the wrong one is an easy mistake.
Letting OpenBao reach Cloudflare
OpenBao fetches Cloudflare’s discovery document and signing keys, and
exchanges the login code for a token. Those are outbound connections from
openbao to the internet, which is exactly what Tetragon’s
unexpected-egress policy reports. I added Cloudflare’s published IPv4 ranges
(from api.cloudflare.com/client/v4/ips) to that namespace’s allow list, and
nowhere else. After setup, Tetragon had exported zero events from openbao.
Issue 1: My own script spoke HTTP to a TLS-only server
Symptom
The first run stopped at its first command:
Error uploading policy: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/sys/policies/acl/admin
Code: 400. Raw Message:
Client sent an HTTP request to an HTTPS server.
Nothing had changed yet, since the policy write was the first step.
Cause
I had copied the script’s header from the older bootstrap.sh, which sets
BAO_ADDR=http://127.0.0.1:8200. That was true before OpenBao got TLS. The
pods already carry the right values:
BAO_ADDR=https://127.0.0.1:8200
BAO_CACERT=/openbao/userconfig/openbao-tls/ca.crt
Fix
Remove the override from both scripts and use the pod’s environment. The second run went through:
Success! Uploaded policy: admin
Success! Enabled oidc auth method at: oidc/
oidc ready: role admin for <my gmail>
OpenBao validates the discovery URL when the config is saved, so this also proved it could reach Cloudflare.
Result
Web UI: Method OIDC, a popup to Cloudflare Access, Google sign-in,
and back into OpenBao. OpenBao recorded an identity alias for my email on the
oidc mount.
CLI: bao 2.7.1 (the server’s version) now comes from the repo’s
mise.toml. With -no-store, nothing touches disk, and I printed only the
token’s metadata:
BAO_ADDR=https://openbao.<tailnet>.ts.net bao login -method=oidc -no-store -format=json role=admin
# policies: ['admin', 'default'] | ttl: 3600 s | renewable: True
| Before | After | |
|---|---|---|
| Daily login | root token from a file | Google, through Cloudflare Access |
| Token lifetime | no expiry | 1 hour, 8 hours max |
| Who can log in | whoever has the file | one email address, checked by Cloudflare and again by OpenBao |
| Second factor | none | my Google account’s 2FA |
| Root token | daily use | break-glass |
Lessons
- An identity proxy can be an OIDC provider. Cloudflare Access sat in front of Google and gave OpenBao a standard OIDC issuer, with its own policy as a second check.
- Bind the role to a claim, not just to a login. Even if the Access
policy were loosened, OpenBao’s
bound_claimsstill accepts only one email. - Keep secrets out of arguments and chat. Clipboard to stdin, checked by length and shape, never printed.
- Copied boilerplate carries old assumptions. One stale
BAO_ADDRline from before TLS broke a new script and had been wrong in the old one too. - A new outbound dependency needs a runtime-policy change. Without the Cloudflare ranges, every login would have triggered a security alert.
What’s left: moving the root token and recovery keys off my laptop into a password manager.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google (this post)