Note: this blog was co-authored with the excellent David Alexander
Introduction
HashiCorp Vault can be used to centralize third-party service access via Secret Engines. To do that Vault needs a way to authenticate to the service which can involve storing static keys often known as a foothold credential. Although many of these static keys can be rotated on a schedule by Vault, some security teams do not allow any static keys, even as footholds.
Vault has a built-in OIDC identity provider. Any service that supports Workload Identity Federation (WIF) — AWS, GCP, Azure, GitHub Actions, and others — can be configured to trust JWTs that Vault mints, eliminating the need for long-lived static credentials entirely.
The pattern is always the same: Vault issues a short-lived JWT, the relying service validates it by fetching Vault's OIDC discovery document and JWKS, and if everything checks out, it grants access. No secrets stored. No rotation scripts. No credential leakage.
But there's a catch: This workflow requires the service to have a network route to Vault's OIDC endpoints... and... most users host Vault in private network space so Vault's OIDC endpoints are not publicly reachable for validation.
There are two fundamentally different ways to solve this:
- Expose Vault's endpoints — put something in front of Vault that makes its existing OIDC URLs reachable
- Host the documents yourself — copy the discovery document and JWKS out of Vault and serve them from a public location you control
This post explores both approaches, their trade-offs, and when to choose each. As a bonus we include example code for setting up the 2nd approach. The concepts apply regardless of which service is consuming the tokens (JWTs).
Background:
- Appendix: How WIF Token Validation Works for a detailed step-by-step breakdown.
- Appendix: Vault's OIDC URL Structure for more detail on namespace path construction.
Vault Secrets Engines That Supports WIF
Although this post highlights use with AWS, Vault's WIF supports many secrets engines or auth method. Any that present a JWT to an external service can participate in this pattern. Examples include:
-
AWS secrets engine — assumes an IAM role via
sts:AssumeRoleWithWebIdentity - GCP secrets engine — impersonates a service account via Workload Identity Federation
- Azure secrets engine
- Kubernetes auth — note this is the inverse direction — Kubernetes validates Vault's token
Approach 1: Expose Vault's Endpoints
The simplest conceptual model is to make Vault's own OIDC endpoints publicly reachable. Vault already serves the discovery document and JWKS — you just need to put a publicly accessible layer in front of them.
Example implementations exist in the wild, built to solve exactly this problem.
Trade-offs
| ✅ Always live | The relying service always sees current keys |
| ✅ No sync process | Nothing to schedule or maintain |
| ❌ Requires persistent infrastructure | Something must always be running and reachable |
| ❌ New ingress surface | Even read-only, it's another path into your network |
Approach 2: Host the Documents off Vault
Instead of routing traffic to Vault, you copy the discovery document and JWKS out of Vault and serve them from a static, publicly accessible location. Vault's OIDC endpoints never need to be reachable from the outside.
An example implementation can be found here
How it works
- Configure Vault's issuer to point at your static hosting URL
- Periodically fetch the discovery document and JWKS from Vault (from inside your network, where Vault is reachable) and publish them to the public location
- The relying service fetches from your static host, not from Vault directly
The sync schedule requirement
Vault manages signing keys per OIDC key configuration. The default key (identity/oidc/key/default) has two relevant settings:
rotation_period 24h # how often Vault generates a new signing key
verification_ttl 24h # how long an old key remains in the JWKS after rotation
Vault keeps both the current and the next signing key in the JWKS endpoint simultaneously. When a rotation occurs, the outgoing key stays in the JWKS for the full verification_ttl window so that tokens signed with it can still be validated. This means the JWKS endpoint always contains at least two keys during any rotation window.
For the static hosting approach, this overlap is what makes the sync manageable: you don't need to sync at the exact moment of rotation. You need to sync at least once per rotation_period, before the previous key ages out of the JWKS. With the default 24h/24h settings, syncing once a day is sufficient — but syncing more frequently (e.g. every few hours) gives you a safety margin if a sync job fails.
This introduces an operational dependency: a scheduled job that runs the sync. The sync itself is simple (two unauthenticated HTTP GETs and a file upload), but it must be reliable and its schedule must be aligned with the key's verification_ttl.
Hosting
The static host just needs to serve two JSON files over HTTPS at predictable paths. Any of the following work:
Object storage (S3, GCS, Azure Blob) - zero infrastructure, globally available, integrates naturally with the cloud you're already using
CDN or static site host (Cloudflare Pages, GitHub Pages, Netlify) - useful if you want a custom domain, edge caching, or already have a CDN in front of your infrastructure
Any HTTPS file server - for self-hosted environments — even a simple nginx serving static files works
See Appendix: Vault's OIDC File Contents for more detail security concerns related to file contents.
Trade-offs
| ✅ No persistent infrastructure | Static files need no running process |
| ✅ Vault stays fully private | No inbound network exposure whatsoever |
| ✅ Works across clouds | The static host can be in a different cloud than the relying service |
| ❌ Requires a sync process | Keys must be refreshed on a schedule |
| ❌ Stale keys break auth | A missed sync during a rotation window causes failures |
| ❌ Two-phase bootstrap | The static host must be populated before the relying service's trust config can be created |
Multi-Namespace Considerations
Vault namespaces are independent OIDC providers. Each has its own keys, its own issuer URL, and its own discovery document. If you're using multiple namespaces — for example, one per team or environment — both approaches need to account for this.
- Each namespace's discovery doc lives at
v1/<namespace>/identity/oidc/plugins/.well-known/ - With Approach 1, the proxy must correctly route per-namespace paths to Vault
- With Approach 2, the static host must store and serve per-namespace documents at matching paths
- A single host (one bucket, one proxy) can serve all namespaces — the namespace is encoded in the path, not the hostname
Conclusion
Eliminating static credentials from your infrastructure is a worthwhile goal, and Vault's built-in OIDC identity provider makes it achievable across AWS, GCP, Azure, and beyond. The challenge is almost never the WIF configuration itself — it's getting the discovery documents to a place the relying service can reach.
Both approaches described here solve the same network topology problem. Exposing Vault's endpoints is operationally simpler but introduces a persistent ingress surface and ties credential issuance to Vault's availability. Hosting the documents yourself keeps Vault fully private and decouples auth from Vault uptime, at the cost of a sync process you have to operate and monitor.
Neither approach is universally better. The right choice depends on your network constraints, your tolerance for operational overhead, and how strictly your security policy treats inbound access to Vault. What matters is that you make the choice deliberately — and that whichever path you take, the JWKS your relying service sees always reflects what Vault is actually signing with.
Appendix
How WIF Token Validation Works
At a high level:
- Vault mints a JWT with an
iss(issuer) claim pointing to its configured issuer URL - The application or platform presents that JWT to the relying service
- The relying service reads the
issclaim and fetches<issuer>/.well-known/openid-configuration - From the discovery document, it reads the
jwks_urifield and fetches the public keys - It verifies the JWT signature against those keys, and checks the
aud(audience) and other claims - If everything matches its trust configuration, it grants access
The critical constraint: steps 3 and 4 happen from the relying service's infrastructure. Whatever URL is in the iss claim must be reachable from there. For cloud services like AWS STS, GCP STS, or Azure AD, that means the public internet.
Vault's OIDC URL Structure
Vault serves its OIDC discovery documents at a predictable path:
<issuer>/.well-known/openid-configuration
<issuer>/.well-known/keys
The issuer itself is constructed from a base URL you configure in identity/oidc/config, with Vault appending the namespace path automatically:
https://<base>/<vault-path>/identity/oidc/plugins
Each Vault namespace has its own independent OIDC provider, its own keys, and its own issuer URL. This matters for both approaches described above.
Configuring New or Existing Keys
These values are configurable per key. Teams with stricter security requirements can shorten the rotation period; teams that need a longer sync window can extend verification_ttl. Both can be adjusted without recreating the key:
vault write identity/oidc/key/default \
rotation_period=6h \
verification_ttl=12h
File Content Security Concerns
The discovery document and JWKS contain only public information — metadata about the issuer and the public half of the signing key pairs. The private keys never leave Vault. Hosting these files publicly is not only safe, it is required by the OIDC specification: the relying service must be able to fetch them without authentication.
The practical implication is that anyone who can reach your static host can read your issuer URL and key IDs. This is low-risk — knowing a public key does not allow an attacker to forge tokens, only to verify them. It does reveal that you are using Vault as an identity provider, which may be relevant to your threat model, but is generally considered acceptable for the same reason that public TLS certificates are acceptable.
aws s3 cp s3://vault-oidc-discovery-123456789012/v1/admin/identity/oidc/plugins/.well-known/openid-configuration - | python3 -m json.tool
{
"issuer": "https://vault-oidc-discovery-123456789012.s3.amazonaws.com/v1/admin/identity/oidc/plugins",
"jwks_uri": "https://vault-oidc-discovery-123456789012.s3.amazonaws.com/v1/admin/identity/oidc/plugins/.well-known/keys",
"response_types_supported": [
"id_token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"EdDSA"
]
}

Top comments (0)