envoyproxy / envoyproxy/gateway
feat: LocalJWKS from a Kubernetes TLS Secret (cert-manager integration)
- Dominant language
- Go
- Stars
- 3k
- Forks
- 864
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 140
Description
`SecurityPolicy.spec.jwt.providers[].localJWKS` currently sources the JWKS
from an inline string or a ConfigMap (`valueRef`). There is no way to
point it at a Kubernetes `Secret`, and no way to drive it from a cert
managed by cert-manager — which is the typical way JWT signing keys are
managed in Kubernetes.
cert-manager writes `kubernetes.io/tls` Secrets containing PEM-encoded
x509 (`tls.crt`/`tls.key`). It does not emit JWKS JSON, and the ecosystem
does not provide that. `additionalOutputFormats` is exhaustively
`DER`/`CombinedPEM`, `keystores` is `jks`/`pkcs12`, trust-manager bundles
are PEM/JKS/PKCS12 — there is no JWKS path anywhere. The only tracking
issue on the cert-manager side, [cert-manager#6283][cm6283], was
bot-closed as stale with zero implementation. No maintained operator
bridges the gap either.
Envoy's `jwt_authn` filter hardcodes JWKS-JSON parsing
([`jwks_cache.cc` L67-L76][envoy-parse]: `createFrom(inline_jwks,
JwtVerify::Jwks::JWKS)`), so feeding raw PEM into `local_jwks` fails with
`JwksParseError`. The path forward has to be on the Envoy Gateway side:
read the cert from the Secret, synthesize a JWKS, pass that to Envoy.
## Proposal
Add a `SecretRef` union arm to `LocalJWKS` that targets a
`kubernetes.io/tls` Secret (the shape cert-manager writes). Envoy Gateway
reads `tls.crt`, parses the x509 cert, extracts the public key, builds a
JWKS, and passes it to the Envoy `jwt_authn` filter — the existing IR
field (`*ir.JWTProvider.LocalJWKS string`) already carries a resolved
JWKS string, so nothing downstream needs to change.
### API (`api/v1alpha1/jwt_types.go`)
```go
const (
LocalJWKSTypeInline LocalJWKSType = "Inline"
LocalJWKSTypeValueRef LocalJWKSType = "ValueRef"
LocalJWKSTypeSecretRef LocalJWKSType = "SecretRef" // new
)
// +kubebuilder:validation:XValidation:rule="(self.type == 'Inline' && has(self.inline) && !has(self.valueRef) && !has(self.secretRef)) || (self.type == 'ValueRef' && !has(self.inline) && has(self.valueRef) && !has(self.secretRef)) || (self.type == 'SecretRef' && !has(self.inline) && !has(self.valueRef) && has(self.secretRef))",message="Exactly one of inline, valueRef, or secretRef must be set and match type."
type LocalJWKS struct {
// +kubebuilder:validation:Enum=Inline;ValueRef;SecretRef
Type *LocalJWKSType `json:"type"`
Inline *string `json:"inline,omitempty"`
ValueRef *gwapiv1.LocalObjectReference `json:"valueRef,omitempty"`
// SecretRef references a same-namespace kubernetes.io/tls Secret
// (e.g. one managed by cert-manager). Envoy Gateway reads the leaf
// certificate from the Secret's `tls.crt` key, extracts the public
// key, and synthesises a JWKS passed to the Envoy jwt_authn filter.
// The Secret is re-read on every reconcile, so cert rotation is
// picked up automatically.
SecretRef *gwapiv1.SecretObjectReference `json:"secretRef,omitempty"`
}
```
### Translator (`internal/gatewayapi/securitypolicy.go`, `buildLocalJWKS`)
Add a `SecretRef` branch using `go-jose/v4` (already an indirect dep in
`go.mod`; this change promotes it to a direct dep) and stdlib
`crypto/x509`:
```go
case egv1a1.LocalJWKSTypeSecretRef:
secret := t.GetSecret(policy.Namespace, string(localJWKS.SecretRef.Name))
if secret == nil {
return "", fmt.Errorf("local JWKS Secret %s/%s not found",
policy.Namespace, localJWKS.SecretRef.Name)
}
pem, ok := secret.Data[corev1.TLSCertKey] // "tls.crt"
if !ok || len(pem) == 0 {
return "", fmt.Errorf("local JWKS Secret %s/%s has no %q",
policy.Namespace, secret.Name, corev1.TLSCertKey)
}
return buildJWKSFromPEMCert(pem) // see below
```
```go
// buildJWKSFromPEMCert parses the first certificate in a PEM bundle and
// returns a JWKS JSON string containing its public key. Supports the
// algorithms Envoy jwt_authn supports (RSA, ECDSA, Ed25519).
func buildJWKSFromPEMCert(pemBytes []byte) (string, error) {
block, _ := pem.Decode(pemBytes)
if block == nil || block.Type != "CERTIFICATE" {
return "", errors.New("tls.crt: no PEM CERTIFICATE block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("tls.crt: parse x509: %w", err)
}
kid := kidFromCert(cert) // sha256 thumbprint of the DER, base64url
alg, err := jwsAlgForKey(cert.PublicKey)
if err != nil {
return "", err
}
set := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{{
Key: cert.PublicKey,
KeyID: kid,
Algorithm: alg,
Use: "sig",
}},
}
out, err := json.Marshal(set)
if err != nil {
return "", err
}
return string(out), nil
}
```
`kid` is derived deterministically from the cert thumbprint (RFC 7638 /
x5t#S256 style) so it is stable across reconciles and survives cert
rotation iff the public key does not change. If the key does change
(typical cert-manager renewal), a new `kid` is minted automatically —
Envoy will accept both until the old JWKS is replaced, and old tokens
naturally expire.
### Controller / watches (`internal/provider/kubernetes/...`)
`processSecretRef` already exists and is used by every other
SecurityPolicy Secret-referencing feature (`basicAuth.users`,
`oidc.clientSecret`, `apiKeyAuth.credentialRefs`, `extAuth.*`). Wire it
in alongside the existing `processConfigMapRef` call for LocalJWKS, and
add a matching Secret indexer in `indexers.go` so that cert-manager
renewals retrigger reconciliation.
## Target YAML
```yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: jwt-from-certmanager
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: foo
jwt:
providers:
- name: example
issuer: https://issuer.example.com
audiences: [api.example.com]
localJWKS:
type: SecretRef
secretRef:
name: jwt-signing-cert # cert-manager Certificate target
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: jwt-signing-cert
spec:
secretName: jwt-signing-cert
issuerRef: { name: my-issuer, kind: Issuer }
commonName: jwt-signer
privateKey: { algorithm: RSA, size: 2048 }
usages: [digital signature]
```
No glue, no sidecar, no CronJob.
## Open design questions
1. **`kid` strategy.** Default: SHA-256 thumbprint of the DER cert
(deterministic, rotation-aware). Alternative: allow the user to
override via an annotation on the Secret
(`gateway.envoyproxy.io/jwks-kid`) or a field on `SecretRef`. The
thumbprint default is fine for most users; the override matters when
the upstream IdP mints tokens with a specific `kid`.
2. **Multiple certs in the chain.** Use the first (leaf) cert only. EG
should not publish intermediates as signing keys.
3. **Public-key-only Secrets.** If `tls.crt` holds a `PUBLIC KEY` PEM
block instead of a `CERTIFICATE` block, accept it too — same pubkey,
just skip the x509 parse step.
4. **Algorithms.** Derive from the public key type: RSA → `RS256`, P-256
→ `ES256`, P-384 → `ES384`, P-521 → `ES512`, Ed25519 → `EdDSA`.
Reject anything else with a clear error.
5. **Cross-namespace.** Out of scope for v1. Same-namespace only,
matching current `valueRef` behavior. Follow-up can gate on
`ReferenceGrant`.
## Scope of the change
- `api/v1alpha1/jwt_types.go` — one new type constant, one new field,
updated CEL rule, regenerated zz_generated and CRD manifests.
- `internal/gatewayapi/securitypolicy.go` — new `SecretRef` branch in
`buildLocalJWKS` plus `buildJWKSFromPEMCert` helper (~100 LOC).
- `internal/provider/kubernetes/controller.go` and
`internal/provider/kubernetes/indexers.go` — wire `processSecretRef`
and the Secret indexer for LocalJWKS.
- Promote `github.com/go-jose/go-jose/v4` from indirect → direct in
`go.mod`.
- Tests: CEL in `test/cel-validation/securitypolicy_test.go`, translator
testdata
`internal/gatewayapi/testdata/securitypolicy-with-jwt-local-jwks-secretRef.{in,out}.yaml`,
unit tests for `buildJWKSFromPEMCert` (RSA, EC, Ed25519, chain, bad
PEM, unsupported key type), and an E2E case mirroring
`test/e2e/testdata/jwt-local-jwks-valueRef.yaml` but with a
cert-manager-style Secret.
- Docs: extend
`site/content/en/latest/tasks/security/jwt-authentication.md` with a
cert-manager example.
*Relevant Links*:
- Current `LocalJWKS` struct: `api/v1alpha1/jwt_types.go` L120-L155
- Current translator path:
`internal/gatewayapi/securitypolicy.go` `buildLocalJWKS` L1474-L1507
- Existing `SecretObjectReference` precedent:
`api/v1alpha1/httproutefilter_types.go` L190-L200
- Original `LocalJWKS` PR: [#5670][eg5670] · earlier stale proposal that
included Secret: [#4684][eg4684] · tracking issue: [#2419][eg2419]
- Envoy `jwt_authn` hardcoded JWKS parse:
[`jwks_cache.cc` L67-L76][envoy-parse]
- cert-manager JWKS request (closed stale):
[cert-manager#6283][cm6283]
[envoy-parse]: https://github.com/envoyproxy/envoy/blob/main/source/extensions/filters/http/jwt_authn/jwks_cache.cc#L67-L76
[eg5670]: https://github.com/envoyproxy/gateway/pull/5670
[eg4684]: https://github.com/envoyproxy/gateway/pull/4684
[eg2419]: https://github.com/envoyproxy/gateway/issues/2419
[cm6283]: https://github.com/cert-manager/cert-manager/issues/6283
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.