Philipp Lehmann @PhilippTheServer

External Secrets with Offline JWT Validation When Vault Cannot Reach the Cluster

Verifying a service-account token's signature instead of asking the API server about it.

The problem

Vault’s kubernetes auth method is the obvious choice for letting workloads authenticate with a service-account token instead of a static credential. Configure it once:

vault auth enable kubernetes

vault write auth/kubernetes/config \
  kubernetes_host="https://10.0.0.0:6443" \
  kubernetes_ca_cert=@ca.crt

and it works, in most environments, without further thought. That is precisely what makes the failure mode hard to see coming: this method does not just check a token’s signature against something it already holds. On every login it calls back into the Kubernetes API server’s TokenReview endpoint, over the network, to ask “is this token valid, and who does it belong to”. Vault is not verifying the token by itself; it is delegating the question to the cluster.

That works fine as long as Vault has a route to kubernetes_host. It stops working the moment someone puts Vault and the cluster into networks that cannot reach each other by design — a segmented topology where the secrets manager and the workload cluster are deliberately kept apart, so that compromising one does not hand an attacker a path to the other. That is a reasonable, often recommended, security posture. It is also exactly the posture that breaks kubernetes auth, because the method has a hard dependency that has nothing to do with configuration and everything to do with network topology.

The failure does not announce itself as an incompatibility. It shows up as a login that times out or refuses to connect, which looks like a Vault problem, or a certificate problem, or a firewall rule someone forgot:

Error writing data to auth/kubernetes/login: Error making API request.
Url: PUT https://vault.example.internal/v1/auth/kubernetes/login
Code: 500. Errors:
* failed to validate token: Post "https://10.0.0.0:6443/apis/authentication.k8s.io/v1/tokenreviews": dial tcp 10.0.0.0:6443: i/o timeout

Nothing in that message says “this auth method cannot work here”. It says “connection timed out”, and a connection timeout sends most people towards routing tables and security groups, not towards the realisation that the auth method itself is the wrong shape for the topology. The environments where this is discovered are usually the ones where isolating Vault from the cluster was a deliberate, correct decision — so the person who did the secure thing is the one who gets the confusing failure.

Working through it

Why one method calls home and the other does not

A Kubernetes service-account token is a JWT. Kubernetes signs it with a private key the API server holds, and the corresponding public key is all that is needed to check that a given token was genuinely issued by that cluster — no live conversation with the API server required, only arithmetic against a key you already have.

Vault’s kubernetes auth method does not use that fact. It treats the token as an opaque credential and asks the cluster to vouch for it via TokenReview, on every single login. That gives you one advantage — Kubernetes can revoke a token centrally and Vault finds out immediately — at the cost of a hard network dependency that never goes away.

Vault’s jwt auth method takes the other path: it verifies the signature itself. Point it at the same cluster’s signing key once, and every subsequent login is pure local cryptography. No callback, no dependency on Vault being able to reach anything at login time.

Why jwt_validation_pubkeys and not jwks_url

The jwt auth method supports two ways of supplying the signing key material. jwks_url tells Vault to fetch a JWKS document over HTTP and refresh it periodically. That still requires network reachability to the cluster — just less frequently than per-login. For an isolated topology this does not solve the problem, it only shrinks how often it bites, and a cluster’s own OIDC discovery endpoint is usually served by the API server itself, so pointing jwks_url at it reintroduces the exact dependency you are trying to remove.

jwt_validation_pubkeys is different in kind, not degree: you hand Vault one or more static PEM public keys, once, at configuration time. After that, Vault never needs a route to the cluster again, for configuration or for login. This is what makes validation genuinely offline rather than just infrequent.

Where the public key comes from

On a cluster built with kubeadm — which is what kind does under the hood — the service-account signing key pair lives as plain files on the control-plane node: /etc/kubernetes/pki/sa.key (private, stays on the node) and /etc/kubernetes/pki/sa.pub (public, already PEM-encoded, safe to copy out). That file is exactly the input jwt_validation_pubkeys wants, with no format conversion.

This generalises to any kubeadm-based cluster, including most self-managed on-premises installs. It does not generalise to every cluster: managed offerings on the large public clouds typically expose the equivalent key material through a public OIDC discovery endpoint returning JSON Web Key Sets rather than a bare PEM file on a node’s disk, and using it with jwt_validation_pubkeys means fetching that document once, out of band, and converting the JWK to PEM before handing it to Vault. The mechanism is the same; the file you start from is not.

Why the audience binding is not optional

A token that only proves “this identity belongs to the cluster” is dangerous to accept anywhere else it might be presented. Kubernetes lets a token be minted bound to a specific audience — a claim saying which consumer it is valid for — and Vault’s jwt role can require a matching bound_audiences. Skip this and a token minted for some unrelated purpose, if it also carries the right subject, would authenticate to Vault too. Binding the audience is what keeps offline verification safe: the signature alone proves the token is genuine, the audience proves it was actually meant for this login.

The solution

Everything below runs on a laptop with Docker, kind, kubectl, and the vault CLI installed. No cloud account and no access to any private cluster is needed.

Create the cluster and extract the signing key

kind create cluster --name jwt-lab --image kindest/node:v1.29.2

docker exec jwt-lab-control-plane cat /etc/kubernetes/pki/sa.pub > sa.pub

Run Vault in dev mode

docker run -d --name vault-dev \
  --cap-add=IPC_LOCK \
  -p 8200:8200 \
  -e VAULT_DEV_ROOT_TOKEN_ID=root-token-for-lab-only \
  -e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
  hashicorp/vault:1.15.6

export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=root-token-for-lab-only

Dev mode is unsealed and stores nothing to disk — it is here to make the auth mechanism reproducible, not to model how you would run Vault in production.

Configure the JWT auth method

vault auth enable jwt

vault write auth/jwt/config \
  jwt_validation_pubkeys=@sa.pub
# myapp-policy.hcl
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
vault policy write myapp-policy myapp-policy.hcl

vault write auth/jwt/role/myapp-role \
  role_type="jwt" \
  bound_audiences="vault-internal" \
  user_claim="sub" \
  bound_subject="system:serviceaccount:default:myapp" \
  policies="myapp-policy" \
  ttl="15m"

vault kv put secret/myapp/config api_key="example-not-a-real-secret"

Dev mode already mounts a kv-v2 secrets engine at secret/ on startup, which is why there is no vault secrets enable step here — running one against a fresh dev server fails with “path is already in use”. A non-dev Vault needs that step; this lab does not.

Prove it works offline, before ESO enters the picture

kubectl create serviceaccount myapp

TOKEN=$(kubectl create token myapp --audience=vault-internal --duration=10m)

vault write auth/jwt/login role=myapp-role jwt="$TOKEN"

This succeeds and returns a Vault token scoped to myapp-policy — and it does so despite Vault, running here as a plain Docker container with no route into the kind network at all, never once contacting the Kubernetes API server. The verification is entirely local signature checking against sa.pub, plus the audience and subject checks on the role. That is the same thing that happens when Vault genuinely cannot reach the cluster, which is the whole point.

Wire up External Secrets Operator

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets --create-namespace \
  --version 0.10.4
# service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp
  namespace: default

Find the address a pod inside kind can actually reach the Vault container at. On Docker Desktop, host.docker.internal resolves to the host and would work directly; on native Linux, it resolves to nothing — pods reach the host through the kind Docker network’s own gateway address instead, since Vault’s published port is bound to every interface on the host, gateway included:

VAULT_FROM_POD=$(docker network inspect kind --format '{{json .IPAM.Config}}' \
  | python3 -c "import json,sys; print([c['Gateway'] for c in json.load(sys.stdin) if '.' in c['Gateway']][0])")
echo "$VAULT_FROM_POD"   # e.g. 172.22.0.1 — substitute this into secret-store.yaml below
# secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-jwt
  namespace: default
spec:
  provider:
    vault:
      server: "http://172.22.0.1:8200"   # the address printed above, not host.docker.internal
      path: secret
      version: v2
      auth:
        jwt:
          kubernetesServiceAccountToken:
            serviceAccountRef:
              name: myapp
            audiences:
              - vault-internal
          role: myapp-role
          path: jwt
# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-config
  namespace: default
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-jwt
    kind: SecretStore
  target:
    name: myapp-config
  data:
    - secretKey: api_key
      remoteRef:
        key: myapp/config
        property: api_key

server above is the lab’s stand-in for “wherever Vault is reachable from the cluster’s egress path, if anywhere at all” — in a real isolated topology this would be whatever narrow, JWT-login-only path exists, not a full network route. The technique works either way, because it never depends on Vault being able to reach back.

kubectl apply -f service-account.yaml -f secret-store.yaml -f external-secret.yaml

kubectl get externalsecret myapp-config
# NAME           STORE       REFRESH INTERVAL   STATUS         READY
# myapp-config   vault-jwt   1h                 SecretSynced   True

kubectl get secret myapp-config -o jsonpath='{.data.api_key}' | base64 -d
# example-not-a-real-secret

SecretSynced and the decoded value confirm ESO minted an audience-bound token for the myapp service account, Vault verified it purely against sa.pub, and the secret landed in the cluster — with no network path from Vault back to the API server used or required anywhere in that chain.

Conclusion

Token-based authentication does not have to mean callback-based authentication. A JWT carries everything needed to prove its own authenticity in its signature; asking the issuer to re-confirm it on every use is a design choice, not a requirement, and Vault offers both options for exactly this reason.

A mechanism that defers a network dependency is not the same as one that removes it. jwks_url looks like an offline option until you notice it still needs a route to fetch the key material, on a schedule. The distinction only matters once you have actually isolated the two systems, which is precisely when it matters most.

Audience binding is what makes offline validation safe rather than merely convenient. Signature verification proves a token is genuine; it says nothing about what the token was for. Binding the login to a specific audience is the check that stops a token minted for one purpose from being valid everywhere.

The pattern outlives Vault. Any system that needs to trust a Kubernetes service-account token but cannot itself reach the API server — a CI runner in another network, a partner service consuming workload identity, a policy engine evaluating tokens at the edge — can use the same static-public-key verification instead of assuming a TokenReview call is always available.