ECDSA P-384 certificates with cert-manager, the secure way
You asked cert-manager for a certificate and got RSA-2048, the default nobody chose. For an edge that already runs ML-KEM-1024, the certificate is now the weakest link, and it is the one you can fix in two lines.
The short answer
Create an explicit cert-manager Certificate with privateKey.algorithm ECDSA, privateKey.size 384 and rotationPolicy Always. cert-manager then generates a P-384 key, a fresh one at every renewal, and Let's Encrypt signs it from an ECDSA P-384 intermediate. Check the Secret with openssl before and after the change.
On this page
What goes wrong
cert-manager generates the private key for every Certificate. If you do not say which algorithm, it uses RSA with a 2048-bit key. If you say ECDSA and forget the size, it uses P-256. Both are sound today. RSA-2048 is also the first to go: NIST's transition draft (IR 8547) deprecates 112-bit signatures after 2030, five years before all classical signatures are disallowed.
P-384 is the curve of NSA's CNSA 1.0 suite, it is the natural pair for an edge that runs ML-KEM-1024 key exchange, and every current browser supports it.
The second failure is key reuse. Before cert-manager v1.18 the default was
rotationPolicy: Never: the same private key was reused at every renewal,
for years. A key that leaked once stayed valid through every new
certificate.
The third failure hides in shortcuts. Annotating an Ingress or a Gateway creates a Certificate for you, with the defaults, unless you add the private key annotations too.
What the docs say
If algorithm is specified and size is not provided, key size of 2048 will be used for RSA key algorithm and key size of 256 will be used for ECDSA key algorithm.
Source: cert-manager API reference, CertificatePrivateKey
In cert-manager >= v1.18.0 the default is rotationPolicy: Always ; the private key is rotated automatically.
Source: cert-manager docs, Certificate resource
Subscriber certificates containing an ECDSA public key will be issued from one of the ECDSA intermediates
Source: Let's Encrypt, Chains of Trust
The cert-manager docs do not say which curve to choose, and the Let's Encrypt page does not say that its default chain for an ECDSA certificate still ends in a cross-sign by the RSA root ISRG Root X1. That cross-sign is there for old clients and is not a weakness of your key.
The secure configuration
An explicit Certificate for the Gateway listener:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: www-example-com
namespace: edge # the Gateway's namespace
spec:
secretName: www-example-com-tls # referenced by the Gateway listener
dnsNames:
- www.example.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt
privateKey:
algorithm: ECDSA # not the RSA default
size: 384 # P-384; the ECDSA default is 256
rotationPolicy: Always # new key at every renewal (default since v1.18, set it anyway)
encoding: PKCS8If you use Ingress annotations instead of a Certificate, add the private key annotations next to the issuer:
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt
cert-manager.io/private-key-algorithm: ECDSA
cert-manager.io/private-key-size: "384"
cert-manager.io/private-key-rotation-policy: AlwaysFor a Gateway, prefer the explicit Certificate above: it keeps the key settings in one reviewed object.
For a private CA issuer (the ca issuer), the CA signs with its own key. Make
the CA key P-384 as well. In the lab, cert-manager then signed with
ecdsa-with-SHA384 on its own; signatureAlgorithm: ECDSAWithSHA384 on the
Certificate makes that explicit. With an ACME issuer the CA chooses the
signature, and the field has no effect.
Prove it
The test in
secure-tests/ecdsa-p-384-certificates-cert-manager/run.sh checks the
Certificate against the cert-manager v1 CRD schema with kubeconform, and a
copy with a misspelled rotationPolicy:
== kubeconform certificate.yaml
Summary: 1 resource found in 1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
== kubeconform bad.yaml
bad.yaml - Certificate www-example-com is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/cert-manager.io/certificate_v1.json#' - at '/spec/privateKey/rotationPolicy': value must be one of 'Never', 'Always'
Summary: 1 resource found in 1 file - Valid: 0, Invalid: 1, Errors: 0, Skipped: 0The schema does not check the key size (a size of 385 passes it), so the real proof is the issued key.
Issued on a cluster
There is no public ACME endpoint in the lab, so the Certificate above was
issued by a private CA whose intermediate is itself P-384 (the one from
an offline internal CA), for
www.internal.example.com:
$ kubectl get certificate www-example-com -n edge
NAME READY SECRET AGE
www-example-com True www-example-com-tls 10s
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' \
| base64 -d | openssl x509 -noout -text \
| grep -E "Public Key Algorithm|Public-Key|NIST CURVE|Signature Algorithm|Issuer:" | sort -u
NIST CURVE: P-384
Public-Key: (384 bit)
Public Key Algorithm: id-ecPublicKey
Issuer: CN = Example Cluster Issuing CA 2026, O = Example
Signature Algorithm: ecdsa-with-SHA384
Signature Algorithm: ecdsa-with-SHA384
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.key}' | base64 -d | head -1
-----BEGIN PRIVATE KEY-----BEGIN PRIVATE KEY is PKCS#8, as encoding: PKCS8 asks (the default
would be BEGIN EC PRIVATE KEY). With Let's Encrypt, expect the same key
lines and an issuer that is one of its ECDSA intermediates (YE1 or YE2 at
the time of writing).
Rotation. The public key hash before and after a forced renewal:
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -pubkey | sha256sum
abdd2cefa649d66c24d3d28aef9a0553587bed2d458a6901f6a980db8b142f72 -
$ cmctl renew www-example-com -n edge
Manually triggered issuance of Certificate edge/www-example-com
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -pubkey | sha256sum
cd202414485de28163f089e36874728860f9b7fd8f05a367282c9cde97a73a67 -Served. The Secret on a Gateway listener, seen by a client:
$ openssl s_client -connect <gateway>:443 -servername www.internal.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -E "Public-Key|NIST CURVE"
Public-Key: (384 bit)
NIST CURVE: P-384
Peer signature type: ecdsa_secp384r1_sha384On your cluster, run the same commands against your Secret and your public
name (openssl s_client -connect www.example.com:443 -servername www.example.com). If READY stays False, run
kubectl describe certificate www-example-com -n edge and read the events
of the latest CertificateRequest.
Mistakes people make
Setting the algorithm and forgetting the size
algorithm: ECDSA alone gives P-256. The size is a separate field, and the
annotation is a separate annotation.
Changing the algorithm on a Certificate with rotationPolicy Never
With Never, cert-manager re-uses the private key already in the Secret at
every issuance. Changing the algorithm alone does not give you a new P-384
key. Set rotationPolicy: Always in the same change.
Expecting signatureAlgorithm to change a Let's Encrypt certificate
The ACME CA decides how it signs. The field matters for the ca and
self-signed issuers only.
Mixing key types without planning for old clients
Very old clients may lack ECDSA support. If you still serve them, Envoy and nginx can hold an RSA and an ECDSA certificate side by side; check your access logs before you drop RSA.
Believing P-384 is post-quantum
It is not. A large quantum computer breaks ECDSA at any size. P-384 is the strongest classical choice while publicly trusted post-quantum certificates do not exist; see the ML-DSA page. The key exchange, which is what recorded traffic depends on, is covered by ML-KEM on the edge.
Checklist
- Write an explicit Certificate for every public hostname.
- Set
privateKey.algorithm: ECDSAandprivateKey.size: 384. - Set
privateKey.rotationPolicy: Always. - Add the private key annotations wherever you still use Ingress annotations.
- Check the Secret shows
NIST CURVE: P-384. - Force one renewal and confirm the public key hash changed.
- Pair the certificate with ML-KEM key exchange on the listener.
Two fields, and the default nobody chose becomes the curve you did.
H2-CPQE
Learn it on a live range
Post-quantum TLS, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.
Start freeThe Dome
Want it run for you?
The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.
See the Dome