In Part 3 we enabled the OIDC auth method and the two Kubernetes secrets engines, but they were not yet connected to anything real. In this post we wire everything together using only the EC2 public DNS name for external references. We configure Vault OIDC to trust Keycloak, we tell the k8s engines how to reach the kind clusters, we create the policies that allow an OIDC identity to request tokens, and then we walk through the full CLI flow from your laptop:vault login -method=oidc role=human, generate short-lived Kubernetes tokens withvault write k8s-*/creds/..., and use them directly withkubectl.
Prerequisites
The full lab must be running on the EC2:
- Keycloak on port 8080, reachable at the public DNS
- Vault dev server listening on
0.0.0.0:8200 - Two kind clusters (
cluster-aon 6443,cluster-bon 6444) vault-demo-saServiceAccount + admin binding in both clusters- The
vaultOIDC client already exists in thesrerealm (from Part 3)
On the EC2 host:
source ./scripts/vault-k8s-lab/env.sh
PUBLIC_DNS=lab.onlysre.dev VAULT_ADDR=http://lab.onlysre.dev:8200 KEYCLOAK_URL=http://lab.onlysre.dev:8080
Verify reachability:
curl -sI ${KEYCLOAK_URL} | head -1
vault status
kind get clusters
kubectl config get-contexts
HTTP/1.1 302 Found
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
Total Shares 1
Threshold 1
Version 2.0.3
Build Date 2026-06-17T12:39:45Z
Storage Type inmem
Cluster Name vault-cluster-e525ecfb
Cluster ID c0b25d9a-8ec9-b535-dfb8-81c098c4e4c0
HA Enabled false
cluster-a
cluster-b
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* kind-cluster-a kind-cluster-a kind-cluster-a
kind-cluster-b kind-cluster-b kind-cluster-b
From your laptop (replace the hostname with the current instance):
export PUBLIC_DNS=lab.onlysre.dev
curl -k https://${PUBLIC_DNS}:6443/version
curl -k https://${PUBLIC_DNS}:6444/version
curl http://${PUBLIC_DNS}:8200/v1/sys/health
curl -I http://${PUBLIC_DNS}:8080
Keycloak Realm and Client (Public DNS Only)
We use the public DNS for everything except the single localhost:8250 callback used by the future kubectl plugin.
Log into the admin CLI using the public address:
docker exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
--server http://${PUBLIC_DNS}:8080 \
--realm master \
--user admin \
--password admin
Logging into http://lab.onlysre.dev:8080 as user admin of realm master
Ensure the sre realm exists and has SSL disabled (demo only):
docker exec keycloak /opt/keycloak/bin/kcadm.sh get realms/sre --fields realm,enabled,sslRequired 2>/dev/null || \
docker exec keycloak /opt/keycloak/bin/kcadm.sh create realms -s realm=sre -s enabled=true
docker exec keycloak /opt/keycloak/bin/kcadm.sh update realms/sre -s sslRequired=NONE
docker exec keycloak /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE
docker exec keycloak /opt/keycloak/bin/kcadm.sh get realms --fields realm,enabled,sslRequired
[
{
"realm" : "sre",
"enabled" : true,
"sslRequired" : "none"
},
{
"realm" : "master",
"enabled" : true,
"sslRequired" : "none"
}
]
Recreate the vault confidential client with the correct redirect URIs (public DNS + localhost for the plugin):
docker exec keycloak /opt/keycloak/bin/kcadm.sh delete clients/$(docker exec keycloak /opt/keycloak/bin/kcadm.sh get clients -r sre -q "clientId=vault" --fields id --format csv | tail -1 | tr -d \") -r sre 2>/dev/null || true
docker exec keycloak /opt/keycloak/bin/kcadm.sh create clients -r sre \
-s clientId=vault \
-s enabled=true \
-s clientAuthenticatorType=client-secret \
-s standardFlowEnabled=true \
-s "redirectUris=[\"http://localhost:8250/oidc/callback\",\"http://${PUBLIC_DNS}:8200/ui/vault/auth/oidc/oidc/callback\",\"http://${PUBLIC_DNS}:8200/oidc/callback\"]" \
-s "webOrigins=[\"*\"]"
Created new client with id 'f431fad9-f7d2-4d72-bfcc-9f6fee817f59'
Retrieve the client secret (this is the value you will use in the next step):
CLIENT_ID=$(docker exec keycloak /opt/keycloak/bin/kcadm.sh get clients -r sre -q "clientId=vault" --fields id --format csv | tail -1 | tr -d \")
docker exec keycloak /opt/keycloak/bin/kcadm.sh get clients/$CLIENT_ID/client-secret -r sre
{
"type" : "secret",
"value" : "TzmZBdO2yryQAqc1jIYtJMXBmzB8LQKk"
}
Create (or ensure) a non-admin demo user:
docker exec keycloak /opt/keycloak/bin/kcadm.sh create users -r sre -s username=demo -s enabled=true 2>/dev/null || true
docker exec keycloak /opt/keycloak/bin/kcadm.sh set-password -r sre --username demo --new-password demo123 --temporary=false
docker exec keycloak /opt/keycloak/bin/kcadm.sh get users -r sre --fields username,enabled
[
{
"username" : "demo",
"enabled" : true
}
]
Configure Vault OIDC Auth Method (Public DNS Only)
Start a fresh Vault dev server bound to all interfaces (if it is not already running):
pkill vault 2>/dev/null || true
nohup vault server -dev -dev-listen-address="0.0.0.0:8200" > /tmp/vault.log 2>&1 &
sleep 5
vault status
Key Value --- ----- Seal Type shamir Initialized true Sealed false Total Shares 1 Threshold 1 Version 2.0.3 Build Date 2026-06-17T12:39:45Z Storage Type inmem Cluster Name vault-cluster-e525ecfb Cluster ID c0b25d9a-8ec9-b535-dfb8-81c098c4e4c0 HA Enabled false
Enable the OIDC auth method:
vault auth enable oidc
vault auth list
Success! Enabled oidc auth method at: oidc/ Path Type Accessor Description Version ---- ---- -------- ----------- ------- oidc/ oidc auth_oidc_598738b5 n/a n/a token/ token auth_token_1fda26c6 token based credentials n/a
Configure the OIDC method using the public DNS discovery URL (no localhost here):
vault write auth/oidc/config \
oidc_discovery_url="http://${PUBLIC_DNS}:8080/realms/sre" \
oidc_client_id="vault" \
oidc_client_secret="TzmZBdO2yryQAqc1jIYtJMXBmzB8LQKk" \
default_role="human"
vault read auth/oidc/config
Key Value --- ----- bound_issuer n/a default_role human jwks_ca_pem n/a jwks_pairs [] jwks_url n/a jwt_supported_algs [] jwt_validation_pubkeys [] namespace_in_state true oidc_client_id vault oidc_discovery_ca_pem n/a oidc_discovery_url http://lab.onlysre.dev:8080/realms/sre oidc_response_mode n/a oidc_response_types [] provider_config map[] unsupported_critical_cert_extensions []
Create the human role. It allows the three redirect URIs we registered in Keycloak and will later carry the k8s-creds policy:
vault write auth/oidc/role/human \
user_claim="sub" \
allowed_redirect_uris="http://localhost:8250/oidc/callback,http://${PUBLIC_DNS}:8200/ui/vault/auth/oidc/oidc/callback,http://${PUBLIC_DNS}:8200/oidc/callback" \
policies="default"
Success! Data written to: auth/oidc/role/human
Dedicated Policy for Kubernetes Token Generation
Following the same pattern we used for KV in Part 1, we create a small policy that only allows update on the creds paths:
vault policy write k8s-creds - <<'EOF'
path "k8s-a/creds/*" {
capabilities = ["update"]
}
path "k8s-b/creds/*" {
capabilities = ["update"]
}
EOF
vault policy read k8s-creds
path "k8s-a/creds/*" {
capabilities = ["update"]
}
path "k8s-b/creds/*" {
capabilities = ["update"]
}
Attach both default and k8s-creds to the OIDC role:
vault write auth/oidc/role/human \
user_claim="sub" \
allowed_redirect_uris="http://localhost:8250/oidc/callback,http://${PUBLIC_DNS}:8200/ui/vault/auth/oidc/oidc/callback,http://${PUBLIC_DNS}:8200/oidc/callback" \
policies="default,k8s-creds"
vault read auth/oidc/role/human
Key Value --- ----- ... policies [default k8s-creds] ... user_claim sub
Configure the Kubernetes Secrets Engines
We use 127.0.0.1 (plus the mapped host ports) for kubernetes_host because that is what the kind CA certificates are valid for. The Vault process runs on the EC2 host and can reach the kind API servers on those addresses.
First extract the CA certificates from the kind kubeconfigs:
kubectl config view --raw --minify --context kind-cluster-a \
-o jsonpath="{.clusters[0].cluster.certificate-authority-data}" | base64 -d \
> ~/certs/cluster-a-ca.crt
kubectl config view --raw --minify --context kind-cluster-b \
-o jsonpath="{.clusters[0].cluster.certificate-authority-data}" | base64 -d \
> ~/certs/cluster-b-ca.crt
ls -l ~/certs/
-rw-rw-r-- 1 ubuntu ubuntu 1107 ... cluster-a-ca.crt -rw-rw-r-- 1 ubuntu ubuntu 1107 ... cluster-b-ca.crt
Configure k8s-a:
vault write k8s-a/config \
kubernetes_host="https://127.0.0.1:6443" \
kubernetes_ca_cert=@/home/ubuntu/certs/cluster-a-ca.crt
vault read k8s-a/config
Key Value --- ----- disable_local_ca_jwt false kubernetes_ca_cert -----BEGIN CERTIFICATE----- ... kubernetes_host https://127.0.0.1:6443
Create the role that maps to the existing ServiceAccount:
vault write k8s-a/roles/cluster-a-admin \
allowed_kubernetes_namespaces="*" \
service_account_name="vault-demo-sa" \
token_default_ttl="15m"
vault read k8s-a/roles/cluster-a-admin
Key Value --- ----- allowed_kubernetes_namespaces [*] name cluster-a-admin service_account_name vault-demo-sa token_default_ttl 15m
Repeat for k8s-b:
vault write k8s-b/config \
kubernetes_host="https://127.0.0.1:6444" \
kubernetes_ca_cert=@/home/ubuntu/certs/cluster-b-ca.crt
vault write k8s-b/roles/cluster-b-admin \
allowed_kubernetes_namespaces="*" \
service_account_name="vault-demo-sa" \
token_default_ttl="15m"
Test Token Generation From the CLI (on EC2)
Before moving to your laptop, prove that token generation works on the EC2 host itself:
vault write k8s-a/creds/cluster-a-admin kubernetes_namespace=default
Key Value --- ----- lease_id k8s-a/creds/cluster-a-admin/... lease_duration 15m lease_renewable false service_account_name vault-demo-sa service_account_namespace default service_account_token eyJhbGciOiJSUzI1NiIs...
vault write k8s-b/creds/cluster-b-admin kubernetes_namespace=default
Key Value --- ----- lease_id k8s-b/creds/cluster-b-admin/... lease_duration 15m lease_renewable false service_account_name vault-demo-sa service_account_namespace default service_account_token eyJhbGciOiJSUzI1NiIs...
At this point the EC2 side is fully wired.
End-to-End Flow From Your Laptop (Pure CLI)
From this point on, you do not need the Vault UI. Everything is done with the vault CLI on your laptop.
All steps below run on your laptop. The CLI flow is the recommended way once you have vault installed locally.
Set the address (use the public DNS):
export VAULT_ADDR="http://lab.onlysre.dev:8200"
Log in with OIDC (this will open your browser for the Keycloak login, then return a Vault token):
vault login -method=oidc role=human
You will be redirected to the Keycloak login page for the sre realm. Log in as demo / demo123.
After a successful login, your terminal will show a Vault token. This token carries the policies default and k8s-creds.
Generate a short-lived Kubernetes token for cluster-a:
vault write k8s-a/creds/cluster-a-admin kubernetes_namespace=default
Key Value --- ----- lease_id k8s-a/creds/cluster-a-admin/... lease_duration 15m lease_renewable false service_account_name vault-demo-sa service_account_namespace default service_account_token eyJhbGciOiJSUzI1NiIs...
Copy the service_account_token value.
Test it against cluster-a (public DNS, skip TLS verify because kind uses self-signed certs that do not contain the public name):
kubectl --server https://lab.onlysre.dev:6443 \
--token "<paste-the-service_account_token>" \
--insecure-skip-tls-verify \
get nodes
NAME STATUS ROLES AGE VERSION cluster-a-control-plane Ready control-plane ... v1.31.0
Also test namespace-scoped access:
kubectl --server https://lab.onlysre.dev:6443 \
--token "<paste-the-service_account_token>" \
--insecure-skip-tls-verify \
get pods
Repeat for cluster-b:
vault write k8s-b/creds/cluster-b-admin kubernetes_namespace=default
kubectl --server https://lab.onlysre.dev:6444 \
--token "<paste-the-service_account_token>" \
--insecure-skip-tls-verify \
get nodes
You can run the two vault write commands one after another to have fresh tokens for both clusters. The tokens are real Kubernetes JWTs bound to vault-demo-sa.
What Just Happened (End-to-End CLI Flow)
The CLI path you just completed is the same chain the browser UI and the Part 5 plugin will automate. Collapsed into one sequence:
laptop: vault login -method=oidc role=human
(VAULT_ADDR = http://lab.onlysre.dev:8200)
→ Vault redirects browser to Keycloak (sre realm)
→ You sign in as demo; Keycloak issues a signed ID token
→ Vault validates JWT via discovery, maps role=human
→ Vault token issued with policies: default + k8s-creds
→ vault write k8s-a/creds/cluster-a-admin kubernetes_namespace=default
→ Vault uses k8s-a/config (CA + host 127.0.0.1:6443 + SA JWT)
to call TokenRequest for ServiceAccount vault-demo-sa
→ Kubernetes returns a real SA JWT (15m TTL)
→ kubectl --server https://…:6443 --token <jwt> …
Cluster-b is the same path with the k8s-b mount and host port 6444.
In prose: OIDC turns a Keycloak login into a Vault token; the Kubernetes secrets engine turns that Vault token (plus path policy) into a short-lived cluster JWT. Nothing in that chain permanently stores a human password or a long-lived kubeconfig secret on the laptop—only leases and TTLs.
Summary of the Important Values (this lab run)
Handy reference for the values you used in this post. Treat secrets as lab-only; rotate them outside a throwaway environment.
| Item | Value |
|---|---|
| Public DNS | lab.onlysre.dev |
| Vault (from laptop) | export VAULT_ADDR=http://lab.onlysre.dev:8200 |
| Keycloak | http://lab.onlysre.dev:8080 |
| OIDC discovery (Vault) | http://lab.onlysre.dev:8080/realms/sre |
| OIDC CLI login | vault login -method=oidc role=human |
| OIDC client secret (lab) | TzmZBdO2yryQAqc1jIYtJMXBmzB8LQKk |
| Keycloak demo user | demo / demo123 |
| Mint token (cluster-a) | vault write k8s-a/creds/cluster-a-admin kubernetes_namespace=default |
| Mint token (cluster-b) | vault write k8s-b/creds/cluster-b-admin kubernetes_namespace=default |
| Engine hosts (on Vault host) | https://127.0.0.1:6443 and :6444 |
What's Next
In Part 5 the manual copy-paste of the ServiceAccount JWT goes away. A small kubectl credential plugin (bash) will:
1. vault token lookup → reuse a still-valid Vault token if present 2. else vault login -method=oidc role=human → browser SSO 3. vault write -format=json k8s-a/creds/cluster-a-admin … 4. print an ExecCredential object kubectl understands 5. cache so the next kubectl call skips the browser
After that, day-to-day use collapses to ordinary kubectl with a context:
kubectl get nodes --context cluster-a
# browser opens on first use, then cached
kubectl get pods --context cluster-b
Next: true browser-based SSO with no hand-copying of tokens.