Cluster Trust Bundles were filed as KEP-3257 on 16 February 2022. They went stable in Kubernetes 1.37, released on 26 August 2026, together with Pod Certificates from KEP-4317. Between them they build X.509 issuance for TLS and mTLS straight into the kubelet, with the same ergonomics that made service account tokens invisible.
And core Kubernetes ships nothing that can issue you a certificate. The API is stable, the machinery is in the kubelet, and the policy is somebody else’s problem. That gap is the part worth planning around, because the component you install to fill it lands somewhere most teams do not expect: on the path a pod has to walk before it starts.
Why not just keep the token
Service account JWTs are good at almost everything. The kubelet writes them into the container filesystem before the workload starts and keeps them fresh, the node restriction admission plugin means only the kubelet actually running your pod can request one, and they federate, so every major cloud accepts them as a pod-to-cloud identity.
They have one structural flaw: they are bearer tokens. To authenticate to a peer you hand the peer a copy, which makes that peer able to be you. Audience, time and object binding narrow the window without closing it. X.509 closes it by never sending the credential at all: the private key stays in the workload, and the peer only sees proof that the workload holds it.
What the pod spec looks like
Two new projected volume sources. podCertificate asks the kubelet to generate a key and get it signed. clusterTrustBundle projects the trust anchors to verify the other side.
# yaml
apiVersion: v1
kind: Pod
metadata:
name: payments-api
spec:
serviceAccountName: payments
containers:
- name: main
image: registry.example.com/payments-api:1.4.2
volumeMounts:
- name: workload-identity
mountPath: /var/run/identity
readOnly: true
volumes:
- name: workload-identity
projected:
defaultMode: 0600
sources:
- podCertificate:
keyType: ECDSAP256
signerName: naviteq.example.com/workload-spiffe
credentialBundlePath: credentialbundle.pem
maxExpirationSeconds: 14400
- clusterTrustBundle:
signerName: naviteq.example.com/workload-spiffe
labelSelector:
matchLabels:
trust: live
path: trust-anchors.pem
The kubelet generates the private key itself, according to keyType. It supports RSA3072, RSA4096, ECDSAP256, ECDSAP384, ECDSAP521 and ED25519, and nothing else. It then creates a PodCertificateRequest addressed to signerName, waits for an answer, and writes the result into the projected volume before your container starts.
On the trust side the kubelet collects every ClusterTrustBundle matching the signer name and label selector, unifies them, and deliberately reorders the result so no application can come to depend on a particular ordering.
Set the lifetime yourself
maxExpirationSeconds is optional, and leaving it out is the first mistake. Omit it and kube-apiserver sets 86400, a full day. The API server rejects anything under 3600, and the ceiling for a third-party signer is 7862400, which is 91 days. Any signer Kubernetes eventually ships under the kubernetes.io namespace will never issue longer than 24 hours.
That range is the design decision, not a detail. Four hours, as in the spec above, is a number somebody chose. Twenty four hours is a number nobody chose.
A certificate that lives for 91 days is a static credential wearing a costume.
The signer is on the startup path
A PodCertificateRequest ends in one of three states, and two of them stop the pod.
| Outcome | What the kubelet does | What you see |
|---|---|---|
| Issued | Writes key and chain, schedules refresh at status.beginRefreshAt | Pod starts normally |
| Denied | Treats it as fatal and does not retry, on the assumption the signer will not change its mind | Pod never starts |
| Failed | Treats it as a permanent error at the volume level | Pod never starts; the Deployment or Job supplies the retry by creating a new Pod |
The signer’s whole job is to fill in the status subresource, which is also the only write permission it needs.
# yaml
# The signer answers by updating status. RBAC: update on podcertificaterequests/status.
status:
conditions:
- type: Issued
status: "True"
certificateChain: |
-----BEGIN CERTIFICATE-----
...leaf, then intermediates, in order...
-----END CERTIFICATE-----
notBefore: "2026-09-22T09:00:00Z"
beginRefreshAt: "2026-09-22T11:00:00Z" # kubelet starts here, plus up to 5m jitter
notAfter: "2026-09-22T13:00:00Z" # errors become fatal from here
The window between beginRefreshAt and notAfter is your entire margin for a signer outage. Set them close together and a signer that is down for twenty minutes takes healthy pods with it.
Read that table as an availability statement. The controller you installed to answer these requests is now a hard dependency of pod startup, cluster wide. A signer that is down, slow or holding a policy nobody reviewed does not degrade your mesh, it stops new pods coming up while the existing ones carry on. The failure looks like a deployment that will not roll and an autoscaler that cannot add capacity.
On a fleet running Karpenter this bites harder than it looks, because peak PodCertificateRequest concurrency happens during a node scale-up, exactly when you have least room to absorb a slow answer. Run the signer with more than one replica and a PodDisruptionBudget, and decide in advance how you get pods up without it.
Rotation is the application’s job
The signer sets status.beginRefreshAt, and the kubelet starts trying to refresh then, with up to five minutes of random jitter so that volumes across the fleet do not drift into lockstep. Refresh errors are logged and swallowed while the current certificate is still valid. Once it expires they are returned from the volume setup, on the reasoning that a pod should noisily go unhealthy rather than quietly run with a dead certificate.
Nothing in that sequence restarts your container. An application that reads its certificate once at startup keeps using the expired one until the volume turns fatal, and the gap between those two events is however long you set the lifetime to be.
This is where credentialBundlePath earns its place. It writes one file: a PKCS#8 PRIVATE KEY block first, then the certificate chain, leaf and intermediates in order. One file means one read, and one read means the key and the leaf always match.
// go
// Reload on write, and keep serving the old pair until the new one parses.
func (s *identity) reload(path string) error {
pem, err := os.ReadFile(path) // single atomic read: key + chain
if err != nil {
return err
}
cert, err := tls.X509KeyPair(pem, pem)
if err != nil {
return err // keep the previous cert in place
}
s.mu.Lock()
s.cert = &cert
s.mu.Unlock()
return nil
}
// tls.Config wires it in once; GetCertificate reads whatever reload last stored.
cfg := &tls.Config{
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return s.current(), nil
},
}
The alternative, keyPath plus certificateChainPath, hands you two files and a race. The API documentation says it plainly: with separate files your code has to check that the leaf certificate was actually issued to the key it sits next to, because you can read the pair mid rotation. That check is not hard, it is just work nobody remembers to do, and the bug it prevents shows up as an intermittent TLS handshake failure under load.
We have done the application half before
When we moved Folloze, a SaaS marketing platform, off Heroku onto AWS, credentials were part of the migration rather than an afterthought. Secrets went into SSM Parameter Store and AWS Secrets Manager, and the application logic that read them was reworked as part of the same piece of work. The infrastructure change was the small half. Teaching a running process to fetch a credential instead of inheriting one from its environment was the real half.
The estate was built the way we build all of them: Naviteq Terraform modules under a Terragrunt wrapper, EKS with Karpenter, CI/CD through GitHub Actions. The migration finished with roughly two hours of downtime in total.
Pod Certificates is the same lesson at a higher frequency. The platform can hand a workload a fresh key every four hours, and it will make no difference at all if the workload reads it once.
Where this stands today
The honest answer for most estates is that you cannot use it yet, and the useful work is elsewhere.
# bash
# Three commands decide whether this conversation is theoretical on your cluster.
kubectl get --raw /version | jq -r .gitVersion # needs v1.37 or later
kubectl api-resources --api-group=certificates.k8s.io # podcertificaterequests present?
kubectl get clustertrustbundles -o name # anything publishing trust anchors?
| Service account JWT | cert-manager and trust-manager | Pod Certificates | |
|---|---|---|---|
| Who issues | Control plane | Controller you run | Signer you run |
| Blocks pod startup | No | No | Yes |
| Credential type | Bearer token | X.509 via Secret | X.509, key never leaves the pod |
| App must reload | No | Yes, if you rotate | Yes, always |
| Available on EKS today | Yes | Yes | No |
EKS standard support currently runs 1.34, 1.35 and 1.36, so 1.37 and therefore stable Pod Certificates are not there. On the cert-manager side, issue 8378 asking for PodCertificateRequest support has been open since 2 January 2026 and was last touched in August; the community is testing third-party controllers in the meantime. The Kubernetes project’s own reference signer, Tinycert, is described by its author as explicitly not a full production solution.
Where you do control the version, which for us usually means on-premise k3s and self-managed clusters, a pilot is worth running now. Everywhere else the work that carries over is the application work: mount credentials at a stable path, prefer a single file, make the process reload on change. Swapping the issuance mechanism under a workload that already does that is a volume source edit. Retrofitting reload into a fleet that reads a certificate at boot is a quarter.
When not to reach for this
- You run a service mesh that already issues and rotates workload certificates. Two identity systems on one cluster is worse than either alone.
- You need a shared key across several pods. That is a Secret projected volume, and the KEP says so directly.
- You want a human in the approval loop. PodCertificateRequests do not separate approval from issuance, because issuance blocks pod startup.
- You cannot commit to running the signer as a production service with the availability of the control plane. Until you can, a bearer token you can reason about beats a certificate that stops deployments.
# yaml
# The KEP's own health statement, as an alert: no volume should ever sit in
# overdue_for_refresh or expired.
- alert: PodCertificateNotRotating
expr: sum by (state) (kubelet_pod_certificate_states{state=~"overdue_for_refresh|expired"}) > 0
for: 5m
labels: { severity: critical }
annotations:
summary: "Pod certificates are not rotating ({{ $labels.state }})"
Watch kubelet_pod_certificate_states before you need it, alert on denied and failed requests, and force a rotation in a test cluster to prove the application picks it up. Assume nothing about the reload path until you have watched a certificate roll under load.
Facing this on your stack? Naviteq’s senior platform team does this for SaaS, FinTech, and Enterprise teams across the US, EU, and Israel. Let’s talk.
Naviteq. DevOps, FinOps and AI-driven cloud automation, delivered at scale.