cert-manager automates TLS certificate issuance and renewal in Kubernetes, integrating with ACME (Let's Encrypt), Vault, Venafi, and self-signed CA issuers. While it removes certificate management toil, it also centralizes sensitive credential material: ACME account private keys, DNS01 solver cloud credentials, and CA private keys all reside as Kubernetes secrets. A compromise of cert-manager's service account or the credential secrets it manages can enable certificate forgery, DNS manipulation, or cluster-wide PKI compromise. This guide covers systematic cert-manager security assessment.
# Enumerate all Issuers and ClusterIssuers
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
meta = i['metadata']
spec = i.get('spec',{})
kind = list(spec.keys())[0] if spec else 'unknown'
print(f\"Issuer: {meta['namespace']}/{meta['name']} type: {kind}\")
if 'acme' in spec:
acme = spec['acme']
print(f\" ACME server: {acme.get('server','?')}\")
print(f\" Account key: {acme.get('privateKeySecretRef',{}).get('name','?')}\")
if 'ca' in spec:
ca = spec['ca']
print(f\" CA secret: {ca.get('secretName','?')}\")
"
kubectl get clusterissuers -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
meta = i['metadata']
spec = i.get('spec',{})
kind = list(spec.keys())[0] if spec else 'unknown'
print(f\"ClusterIssuer: {meta['name']} type: {kind}\")
# ClusterIssuer: cluster-scoped, can be referenced from any namespace
# Credential secrets are read by cert-manager controller, stored in cert-manager namespace
"
# ACME issuers store the ACME account private key as a Kubernetes secret
# The private key is used to sign all ACME certificate requests to Let's Encrypt
# Theft allows registering new certificates under the compromised account
# Find ACME account key secrets
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
acme = i.get('spec',{}).get('acme',{})
if acme:
ns = i['metadata']['namespace']
key_secret = acme.get('privateKeySecretRef',{}).get('name','?')
print(f\"{ns}/{key_secret} — ACME account key for {acme.get('server','?')}\")
"
# Also check ClusterIssuers (key stored in cert-manager namespace)
kubectl get clusterissuers -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
acme = i.get('spec',{}).get('acme',{})
if acme:
key_secret = acme.get('privateKeySecretRef',{}).get('name','?')
print(f\"cert-manager/{key_secret} — ClusterIssuer ACME key for {i['metadata']['name']}\")
"
# Extract the ACME account private key
kubectl get secret letsencrypt-prod-acme-key -n cert-manager -o json | python3 -c "
import json,sys,base64
secret = json.load(sys.stdin)
for k,v in secret.get('data',{}).items():
print(f'{k}: {base64.b64decode(v).decode()[:100]}...')
"
# tls.key contains the ACME JWK private key (EC P-256 or RSA)
# With this key: forge ACME signed requests to issue certificates for domains the account controls
# DNS01 ACME challenge solvers need DNS provider credentials to create TXT records
# These credentials often have broad DNS zone permissions
# Find DNS01 solver credentials
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
solvers = i.get('spec',{}).get('acme',{}).get('solvers',[])
for s in solvers:
dns01 = s.get('dns01',{})
if dns01:
meta = i['metadata']
print(f\"Issuer: {meta['namespace']}/{meta['name']} uses DNS01\")
# Route53 auth
if 'route53' in dns01:
r53 = dns01['route53']
secret_ref = r53.get('secretAccessKeySecretRef',{})
role_arn = r53.get('role','')
print(f\" Route53 region: {r53.get('region','?')}\")
print(f\" Access key secret: {secret_ref.get('namespace',meta['namespace'])}/{secret_ref.get('name','?')}\")
if role_arn:
print(f\" IAM role ARN: {role_arn}\")
# Cloudflare auth
if 'cloudflare' in dns01:
cf = dns01['cloudflare']
token_ref = cf.get('apiTokenSecretRef',{})
print(f\" Cloudflare token secret: {token_ref.get('namespace',meta['namespace'])}/{token_ref.get('name','?')}\")
"
# Extract DNS credentials and check their scope
# Route53 credentials with iam:GetPolicy
kubectl get secret route53-credentials -n cert-manager -o json | python3 -c "
import json,sys,base64
s = json.load(sys.stdin)
for k,v in s.get('data',{}).items():
print(f'{k}: {base64.b64decode(v).decode()}')
"
# Dangerous if IAM policy allows:
# - route53:ChangeResourceRecordSets on *.example.com (entire zone)
# - route53:ListHostedZones (exposes all DNS zones)
# Should be scoped to only the specific hosted zone ID and TXT record type
# CA Issuers use a CA private key stored in a Kubernetes secret
# Theft = ability to sign arbitrary certificates as that CA
# Find CA Issuer secrets
kubectl get issuers --all-namespaces -o json | python3 -c "
import json,sys
issuers = json.load(sys.stdin)['items']
for i in issuers:
ca = i.get('spec',{}).get('ca',{})
if ca:
meta = i['metadata']
print(f\"CA Issuer: {meta['namespace']}/{meta['name']} CA secret: {ca.get('secretName','?')}\")
"
# Extract CA private key and certificate
CA_SECRET=example-ca-secret
NAMESPACE=cert-manager
kubectl get secret $CA_SECRET -n $NAMESPACE -o json | python3 -c "
import json,sys,base64
s = json.load(sys.stdin)
tls_crt = base64.b64decode(s['data']['tls.crt']).decode()
tls_key = base64.b64decode(s['data']['tls.key']).decode()
print('Cert (first 200 chars):', tls_crt[:200])
print('Key (first 50 chars):', tls_key[:50]) # Never log full private key
"
# With CA private key: forge certificates for any service in the cluster
# that trusts this CA (often used for internal mTLS)
openssl x509 -in /tmp/ca.crt -text -noout | grep -A 5 "Basic Constraints"
# CA:TRUE = full certificate authority, can sign any cert
# pathLen:0 = can sign end-entity certs but not subordinate CAs
# Check which namespaces trust this CA (mounted as CA bundle)
kubectl get configmaps --all-namespaces -o json | python3 -c "
import json,sys
cms = json.load(sys.stdin)['items']
for cm in cms:
if 'ca-bundle' in cm['metadata'].get('name','').lower() or \
'ca.crt' in cm.get('data',{}):
print(f\"{cm['metadata']['namespace']}/{cm['metadata']['name']}: CA bundle\")
"
# cert-manager v1.3+ introduced CertificateRequest approval
# CertificateRequests must be approved before cert-manager issues a certificate
# Test who can approve and whether self-approval is possible
# Check CertificateRequest approval RBAC
kubectl auth can-i update certificaterequests/approval \
--as=system:serviceaccount:developer:developer-sa \
-n developer
# If allowed: a developer can create AND approve their own CertificateRequest
# = issue certificates for any domain the Issuer is authorized to sign
# Test: self-approval of a CertificateRequest for a sensitive domain
kubectl apply -f - </dev/null | base64 | tr -d '\n')
issuerRef:
name: cluster-ca # References ClusterIssuer
kind: ClusterIssuer
EOF
# Then attempt self-approval
kubectl certificate approve escalation-test -n developer
# If succeeds: developer gets a cert for admin.cluster.local signed by cluster CA
# Check auto-approver configuration
# cert-manager-approver-policy or built-in cert-manager approver may auto-approve
kubectl get certificaterequestpolicies --all-namespaces 2>/dev/null
kubectl get clusterrolebindings | grep "cert-manager:cert-manager-approver"
certificaterequests/approval to developer or application service accounts| Security Test | Method | Risk |
|---|---|---|
| CA Issuer private key readable by developer SA | kubectl auth can-i get secrets -n cert-manager | Critical |
| Developer can self-approve CertificateRequests | kubectl auth can-i update certificaterequests/approval | Critical |
| DNS01 IAM credential has full zone ChangeResourceRecordSets | AWS IAM policy check on Route53 credentials | High |
| ACME account private key accessible cluster-wide | kubectl get secret ACME-KEY-SECRET -n cert-manager | High |
| ClusterIssuer without CertificateRequestPolicy restrictions | kubectl get certificaterequestpolicies | High |
| cert-manager SA can read all secrets (not just cert namespaces) | Check cert-manager ClusterRole resources | Medium |
Ironimo tests cert-manager deployments for ACME account key exposure, DNS01 solver IAM over-privilege, CA private key accessibility, CertificateRequest approval escalation paths, ClusterIssuer namespace isolation gaps, and certificate rotation blind spots.
Start free scan