Safeguard
Kubernetes Security

TLS termination and cert-manager: a hardening guide for Kubernetes Ingress

IngressNightmare's CVSS 9.8 RCE showed that ingress-nginx's own admission webhook can be turned against cluster Secrets — here's how to configure TLS safely.

Safeguard Research Team
Research
Updated 7 min read

On March 24, 2025, researchers disclosed IngressNightmare: five CVEs in ingress-nginx, the most widely deployed Kubernetes Ingress controller. The headline flaw, CVE-2025-1974, scored CVSS 9.8 and let an unauthenticated attacker who could reach the controller's internal admission webhook submit a crafted Ingress object and achieve remote code execution — inside a pod that, by default, holds a service account with read access to every TLS Secret in the cluster. Three companion bugs, CVE-2025-24514, CVE-2025-1097, and CVE-2025-1098, let attacker-controlled Ingress annotations (auth-url, auth-tls-match-cn, and mirror-target fields) inject arbitrary directives straight into the controller's NGINX configuration. All were fixed in ingress-nginx 1.12.1 and 1.11.5 (note that 1.12.0 itself remained vulnerable — the fix landed in the 1.12.1 point release, not 1.12.0). Kubernetes Ingress TLS is supposed to be the boring, solved part of running Kubernetes — the certificate is issued, the handshake happens, traffic flows encrypted. IngressNightmare is a reminder that the controller terminating that TLS is itself a privileged, internet-facing attack surface, and that cert-manager automation around it needs the same scrutiny as the workloads it protects. This guide covers the configuration choices and cert-manager pitfalls that actually matter.

What made IngressNightmare exploitable, and does patching alone fix it?

IngressNightmare was exploitable because ingress-nginx's validating admission webhook, which exists to reject malformed Ingress objects before they reach the controller, was itself reachable and under-restricted, and it ran with elevated privileges by design. CVE-2025-1974 let a crafted object make the webhook process render attacker-controlled NGINX config internally, achieving RCE in a pod whose service account could typically read Secrets across the ingress-nginx namespace and, depending on RBAC, further afield. Patching to 1.12.1+ or 1.11.5+ closes the specific bugs, but the underlying lesson is structural: the admission webhook endpoint should never be reachable from outside the cluster network, and its pod's service account should hold the minimum RBAC needed — not blanket get secrets at the namespace or cluster scope. Kubernetes issue kubernetes/kubernetes#131006 and writeups from Datadog Security Labs both trace the chain back to webhook network exposure combined with over-broad Secret access as the multiplier that turned a config-injection bug into a full credential-theft path.

How should TLS actually terminate at the Ingress layer?

Getting Kubernetes Ingress TLS right starts with the Secret type and the object that references it. Terminate TLS at the Ingress controller using a Secret of type kubernetes.io/tls referenced from the Ingress object's spec.tls block, and decide deliberately whether the controller re-encrypts traffic to your backend or passes it through in plaintext on the cluster network. Edge termination — decrypting at the Ingress and forwarding plaintext to pods over the cluster network — is the common default and is acceptable when you trust your CNI's network policies to segment traffic; re-encryption (TLS all the way to the pod) adds defense in depth against a compromised node or a misconfigured NetworkPolicy. Whichever mode you choose, the TLS Secret itself is the crown jewel: it should live in the same namespace as the Ingress that references it, since cross-namespace Secret references are not supported without an explicit mechanism like an ExternalSecret or a shared cert-manager ClusterIssuer deliberately scoped for that purpose. Treat every namespace holding a TLS private key as needing the same RBAC discipline as one holding database credentials — because functionally, it is one.

How does cert-manager's renewal timing actually work?

cert-manager issues certificates with a default duration of 90 days and begins attempting renewal automatically once two-thirds of that lifetime has elapsed — meaning renewBefore defaults to roughly one-third of the certificate's duration, or about 30 days before expiry on a 90-day cert. You can override this explicitly with spec.renewBefore (an absolute duration) or spec.renewBeforePercentage on the Certificate resource, which matters if your ACME issuer or internal PKI has slower revocation/reissuance latency than the default assumes. The practical failure mode teams hit isn't cert-manager missing a renewal window — it's not noticing when renewal fails silently for several cycles because nothing alerts on Certificate resources stuck in a non-Ready condition. cert-manager's own documentation (cert-manager.io/docs/devops-tips/prometheus-metrics) recommends alerting directly on the exposed Prometheus metrics — certificate expiry timestamp and the certificate ready-condition status — rather than trusting that automation will always succeed.

What's the most common cert-manager misconfiguration in production?

The most common operational pitfall is pointing a ClusterIssuer at Let's Encrypt's production ACME endpoint during testing and iteration, then hitting production rate limits — most notably the duplicate-certificate limit for a given exact set of hostnames. Let's Encrypt now enforces this with a token-bucket algorithm rather than a fixed weekly quota, so the allowance for re-requesting the same set of hostnames refills gradually (on the order of one certificate roughly every day and a half) instead of resetting all at once — but a single typo'd annotation, a CI pipeline re-applying manifests in a loop, or a staging environment accidentally wired to a production issuer can still burn through the available allowance fast. Let's Encrypt and cert-manager's own documentation explicitly recommend using the letsencrypt-staging endpoint for any non-production testing and only cutting over to the production issuer once the Certificate and ClusterIssuer configuration is verified working end to end. Once you've exhausted the allowance, the fix is waiting for it to refill — there is no override — so the cheap prevention is a naming convention (*-staging issuers) and a policy check that non-production namespaces cannot reference a production ClusterIssuer at all.

Why doesn't TLS termination alone limit blast radius if a pod is compromised?

TLS termination protects data in transit; it does nothing about who can read the certificate's private key at rest, and that's a distinct, well-documented gap. A TLS Secret sitting in a namespace is readable by any pod's service account that has get or list permission on secrets in that namespace via RBAC — which, on clusters where teams grant broad edit or admin ClusterRole bindings for convenience, is often far more service accounts than anyone intended. This means a compromised pod with no direct network path to your certificate authority can still exfiltrate the private key for your production TLS cert simply by reading the Secret it's mounted from, and use it to impersonate your domain elsewhere. The defense is RBAC scoped per-namespace and per-Secret name using granular resourceNames restrictions rather than blanket secrets access, combined with periodically auditing which ServiceAccounts actually hold get/watch/list on secrets — an audit that's easy to defer indefinitely because, unlike an expired certificate, an over-permissioned RBAC binding produces no outage to force the issue.

How Safeguard helps

Ingress controllers and cert-manager are container images and Helm charts like everything else in your cluster, which means they carry the same supply-chain exposure as any other dependency — and IngressNightmare is a direct example of a widely deployed component shipping exploitable code for months before disclosure. Safeguard's SBOM generation and dependency scanning cover the ingress-nginx and cert-manager images and charts you deploy alongside your application workloads, so a CVE like CVE-2025-1974 shows up as a tracked, versioned finding against the exact image tag running in your cluster rather than something your team has to remember to check manually against release notes. Because Safeguard ingests SBOMs continuously rather than at a single point in time, when a new ingress-controller or cert-manager CVE is disclosed, you can query "are we running an affected version, and where" across every cluster in minutes instead of grepping Helm values files by hand.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.