May 28, 2026
Why your private IdP needs a public discovery endpoint
To federate a self-hosted OIDC IdP with AWS, GCP, or Vault, your discovery endpoint must be reachable from the public internet. Here's why — and how to expose only what's needed.
You've probably hit this wall. The IdP works inside your network. The cloud provider rejects the token the moment you try to federate.
The reason is almost always the same: the verifier can't reach your OIDC discovery endpoint.
This post explains why public discovery is non-negotiable, why the obvious workarounds fail, and the pattern that holds up: exposing just enough of an IdP to federate, without exposing the IdP itself.
How OIDC verifiers actually trust a token
When a workload presents a JWT, the verifier doesn't trust it on its face. It runs a short, well-defined sequence (OpenID Connect Discovery 1.0):
- Read the
iss(issuer) claim from the token. - Fetch
<iss>/.well-known/openid-configurationover HTTPS. - Read the
jwks_urifield from that document — wherever it points — and fetch the JWKS. - Pick the right public key using the JWT header's
kid, and verify the signature. - Verify the standard claims:
iss,aud,exp,iat(plusnbfif present).
Concretely, the verifier makes two HTTP requests. The first:
GET /.well-known/openid-configuration HTTP/1.1
Host: auth.example.com
returns something like:
{
"issuer": "https://auth.example.com",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"],
"subject_types_supported": ["public"]
}
The verifier then GETs the jwks_uri and gets a JWKS:
{
"keys": [
{
"kty": "RSA",
"kid": "2026-05-key-1",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQS...",
"e": "AQAB"
}
]
}
That's it. Two GETs, to whatever host iss points at. The verifier, not you, initiates them. That's the part people easily miss.
Why the verifier is never on your network
The whole point of OIDC federation is that the verifier is somewhere else. AWS STS. GCP IAM. A partner's API gateway. Vault in another team's account. A CI provider verifying a workload identity for deployment.
None of those can route into your VPC, your office network, your tailnet, or your air-gapped cluster.
So when iss is something like https://gitlab.internal.acme.corp/, the verifier's request fails at step 2. NXDOMAIN. Connect timeout. Sometimes a TLS error against a self-signed cert. Whatever the underlying cause, the verifier rejects the token — and the surface error (AWS's generic InvalidIdentityToken, Vault's unable to fetch jwks) looks the same every time.
This isn't a bug in the verifier. It's the spec (§4): the issuer URL is the discovery base URL. Discovery has to resolve over the public internet for the verifier to obtain a key it can verify the signature against.
Workarounds that look right and don't hold up
Teams reach for three patterns first. Each breaks in its own way.
| Workaround | What it does | Why it breaks |
|---|---|---|
| Reverse-proxy the whole IdP | Put GitLab/Vault/Keycloak behind a public hostname | Exposes login, admin, every API. Pins federation to the IdP's hostname — swap IdPs later and every verifier breaks. |
| Pre-share static keys with each verifier | Skip discovery; load JWKS into each verifier out-of-band | Most cloud verifiers don't allow it (AWS IAM, GCP WIF, Azure all require a discovery URL). Worse: kills automatic key rotation. Every rotation becomes a config push across every verifier. |
| Tunnel discovery over PrivateLink / VPC peering | Route the verifier's HTTPS request into your private network | Operationally expensive, region-locked, supported by a handful of cloud verifiers and nothing else. Third-party verifiers won't touch it. You end up with two trust paths that drift. |
The middle option is seductive because it almost works. Vault's JWT auth method, for instance, takes jwt_validation_pubkeys instead of oidc_discovery_url. Some library-level verifiers do the same. Fine on a closed system you fully control. The moment you add an AWS IAM trust policy or a GCP workload pool, you're back to needing a public URL — and now you have two key-distribution mechanisms to keep in sync.
What works: publish a thin discovery surface
The pattern that holds up: publish only the two documents OIDC discovery requires, at a stable public URL you control, and serve the rest of your IdP exactly as it is today. It looks like the reverse-proxy idea, with two crucial constraints added.
First, only the two discovery documents are public. Not the login UI. Not admin APIs. Not session endpoints.
Second, the public hostname is independent of the IdP. Pick auth.example.com, not gitlab.example.com. That way you can swap the IdP underneath later without breaking every verifier that pinned the issuer URL.
Concretely:
https://auth.example.com/serves/.well-known/openid-configurationand the JWKS it references.- The
issuerfield in that document is the same public hostname. - Only public keys are published. Private keys never leave the IdP.
- The IdP stays internal. Workloads talk to it over your private network, get JWTs back with
iss = https://auth.example.com/, and present those tokens to external verifiers.
The piece that lives on the IdP side is the iss claim itself: the IdP has to be configured to mint tokens with the public hostname, not its internal one. Most self-hosted IdPs expose this as a single setting — GitLab CI has ci_id_tokens_issuer_url, Kubernetes has --service-account-issuer, Vault and Keycloak each have their own — so it's a config change, not a code change. Our GitLab CI guide walks through it end to end.
The verifier fetches discovery from your public hostname, gets the JWKS, verifies the signature, accepts the token. Your IdP never sees an inbound request from the verifier.
This works because OIDC verification is cryptographic, not session-based. The verifier doesn't need to call back to the IdP. It just needs the public key. Once the right contents are at the public URL, what's left is a static-file problem.
Details that look small and bite
A handful of things look minor in design and ruin you in production.
Trailing-slash consistency. The Discovery spec requires the issuer field, the URL prefix used to fetch openid-configuration, and the iss claim in every token to match exactly. A stray / causes verifiers to reject tokens with no useful error. Pick one form and enforce it everywhere.
Rotation timing. When you rotate signing keys on the IdP, the public JWKS must be updated before the new kid appears on a token. The old key must stay published until the last token signed with it has expired. Most IdPs already publish JWKS internally; the public copy has to track it without lag.
Cache headers. Verifiers cache discovery and JWKS aggressively — often for hours, sometimes longer, and some only refresh when they encounter a kid they don't recognize. Pick Cache-Control: max-age short enough that rotation propagates, long enough that you don't get hammered, and plan against the strictest verifier you have to support.
TLS. Discovery must be HTTPS with a public-CA-issued certificate. Self-signed will not work; verifiers reject the chain.
Issuer URL stability. Once a verifier is configured with iss = https://auth.example.com/, you cannot change that URL without re-configuring every verifier. Pick a hostname you're willing to own forever.
One subdomain per IdP. If you have multiple IdPs — Vault, GitLab, Kubernetes — most verifiers will tolerate distinct paths under a shared hostname (AWS IAM, for example, registers OIDC providers by the full issuer URL, which is how EKS runs many clusters under oidc.eks.<region>.amazonaws.com/id/<cluster> without collisions). Distinct subdomains are still the cleaner default: each IdP gets its own TLS scope, its own cache TTL, and can be rotated, swapped, or retired without touching the others.
Where oidc.pub fits
oidc.pub is a managed version of exactly this pattern. You publish your IdP's openid-configuration and JWKS to a subdomain served from Cloudflare's edge. Your IdP stays on your private network. You sync from your IdP once, or continuously via the sync worker. External verifiers fetch from the public URL.
You can build it yourself. An S3 bucket, a CloudFront distribution, and a Lambda that syncs from your IdP will get you most of the way. Teams hand it off for two reasons.
Getting it right the first time. Discovery-document format, JWKS key encoding, issuer-URL trailing slashes, cache headers, TLS — none of it is individually hard, all of it is easy to bodge, and the result is hard to feel confident in. Teams want a setup they trust by construction, not one they trust because nothing's gone wrong yet.
The long tail. TLS renewals (wildcard certs and SNI once you go subdomain-per-IdP), rotation timing, observability when discovery breaks, and nobody wanting to debug a half-remembered Lambda at 3am, a year after the engineer who set it up has moved teams.
The architecture is the architecture, whether you build it or buy it: public discovery, private IdP, cryptographic trust between them. That's the bridge that makes self-hosted identity federate.
If you'd rather not maintain the public side yourself, that's what we built. Or email hello@oidc.pub if you want to compare notes on a setup.