Guide · AWS ACM · Private CA · mTLS
ACM Private CA for MCP Servers — Internal mTLS and Certificate Authority
ACM Private CA is a managed private certificate authority that issues X.509 certificates for internal workloads — including MCP server components — that need mutual TLS (mTLS), where both sides of a connection present and verify a certificate. Unlike ACM public certificates (which are free and auto-renewed but tied to AWS service endpoints), Private CA certificates can be exported and used anywhere: EC2 instances terminating TLS directly, Kubernetes pods, Lambda functions using custom HTTP clients, and inter-service communication channels that must authenticate callers by certificate rather than by API key. The cost model is $400/month per active CA plus $0.75 per 1,000 certificates issued — significant compared to free public ACM certificates, so Private CA is best reserved for scenarios that genuinely require it: mTLS, on-premises integration, custom certificate lifetimes, or exportable certificates.
TL;DR
Create a ROOT CA + SUBORDINATE CA hierarchy (never issue end-entity certificates directly from the root), export the subordinate CA certificate, configure clients to trust your CA certificate, issue short-lived certificates (24h or less for ephemeral tasks), and use certificate revocation via CRL or OCSP. For public certificates see ACM overview. For ALB/CloudFront TLS see ACM with ALB.
When to use Private CA vs public ACM
| Requirement | Public ACM | ACM Private CA |
|---|---|---|
| External HTTPS (clients connect from internet) | Yes — free, auto-renewed | No — not publicly trusted |
| Internal service-to-service TLS | No — cannot export; only works with AWS services | Yes — exportable, works anywhere |
| Mutual TLS (both sides present certificates) | No — cannot export client certificates | Yes — issue client certificates to services |
| TLS on EC2 or containers that terminate TLS directly | No | Yes |
| Custom certificate validity period (e.g., 1 hour) | No — 13 months fixed | Yes — any validity from 1 second to 30 years |
| On-premises integration | No | Yes — export cert and key, use with any TLS library |
| Cost | Free | $400/month/CA + $0.75/1,000 certs |
For most MCP server deployments, public ACM certificates with an ALB or CloudFront handle external TLS at zero cost. Add Private CA only when you specifically need mTLS for service-to-service authentication — for example, when an MCP gateway service must verify that incoming connections come from authenticated MCP clients, not arbitrary HTTP callers.
Create a Private CA hierarchy
Best practice is a two-tier hierarchy: a ROOT CA that signs only the SUBORDINATE CA certificate, and the SUBORDINATE CA that issues end-entity certificates. The root CA's private key is then less exposed — you can keep it disabled (Active=false) between uses. Never issue end-entity certificates directly from the root CA.
# Step 1: Create the root CA (RSA 4096 for CA key; EC_prime256v1 also supported)
ROOT_CA_ARN=$(aws acm-pca create-certificate-authority \
--certificate-authority-configuration '{
"KeyAlgorithm": "RSA_4096",
"SigningAlgorithm": "SHA512WITHRSA",
"Subject": {
"Country": "US",
"Organization": "MCP Server Internal CA",
"OrganizationalUnit": "Security",
"State": "California",
"Locality": "San Francisco",
"CommonName": "MCP Server Root CA"
}
}' \
--certificate-authority-type ROOT \
--tags Key=Service,Value=mcp-server Key=Type,Value=root-ca \
--query 'CertificateAuthorityArn' --output text)
# Step 2: Get the CSR for the root CA and self-sign it
aws acm-pca get-certificate-authority-csr \
--certificate-authority-arn $ROOT_CA_ARN \
--output text > root_ca.csr
# Issue the self-signed root certificate (validity: 10 years)
ROOT_CERT_ARN=$(aws acm-pca issue-certificate \
--certificate-authority-arn $ROOT_CA_ARN \
--csr fileb://root_ca.csr \
--signing-algorithm SHA512WITHRSA \
--template-arn arn:aws:acm-pca:::template/RootCACertificate/V1 \
--validity Value=3650,Type=DAYS \
--query 'CertificateArn' --output text)
aws acm-pca wait certificate-issued \
--certificate-authority-arn $ROOT_CA_ARN \
--certificate-arn $ROOT_CERT_ARN
# Step 3: Import the self-signed certificate to activate the root CA
aws acm-pca get-certificate \
--certificate-authority-arn $ROOT_CA_ARN \
--certificate-arn $ROOT_CERT_ARN \
--query 'Certificate' --output text > root_cert.pem
aws acm-pca import-certificate-authority-certificate \
--certificate-authority-arn $ROOT_CA_ARN \
--certificate fileb://root_cert.pem
# Step 4: Create the subordinate CA
SUB_CA_ARN=$(aws acm-pca create-certificate-authority \
--certificate-authority-configuration '{
"KeyAlgorithm": "EC_prime256v1",
"SigningAlgorithm": "SHA256WITHECDSA",
"Subject": {
"Country": "US",
"Organization": "MCP Server Internal CA",
"OrganizationalUnit": "MCP Services",
"CommonName": "MCP Server Intermediate CA"
}
}' \
--revocation-configuration '{
"CrlConfiguration": {
"Enabled": true,
"ExpirationInDays": 7,
"S3BucketName": "mcp-ca-crls-123456789012",
"S3ObjectAcl": "BUCKET_OWNER_FULL_CONTROL"
}
}' \
--certificate-authority-type SUBORDINATE \
--query 'CertificateAuthorityArn' --output text)
# Step 5: Sign the subordinate CA with the root CA
aws acm-pca get-certificate-authority-csr \
--certificate-authority-arn $SUB_CA_ARN \
--output text > sub_ca.csr
SUB_CERT_ARN=$(aws acm-pca issue-certificate \
--certificate-authority-arn $ROOT_CA_ARN \
--csr fileb://sub_ca.csr \
--signing-algorithm SHA512WITHRSA \
--template-arn arn:aws:acm-pca:::template/SubordinateCACertificate_PathLen0/V1 \
--validity Value=5,Type=YEARS \
--query 'CertificateArn' --output text)
aws acm-pca wait certificate-issued \
--certificate-authority-arn $ROOT_CA_ARN \
--certificate-arn $SUB_CERT_ARN
# Retrieve cert + chain and activate the subordinate CA
aws acm-pca get-certificate \
--certificate-authority-arn $ROOT_CA_ARN \
--certificate-arn $SUB_CERT_ARN \
--query '{Cert:Certificate,Chain:CertificateChain}' \
--output json > sub_cert_and_chain.json
aws acm-pca import-certificate-authority-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--certificate fileb://<(jq -r '.Cert' sub_cert_and_chain.json) \
--certificate-chain fileb://<(jq -r '.Chain' sub_cert_and_chain.json)
Issue certificates to MCP server components
With the subordinate CA active, you can issue end-entity certificates to MCP server ECS tasks, Lambda functions, or any service that needs a TLS identity. Use short validity periods (hours to days for ephemeral workloads) — Private CA has no forced minimum lifetime, unlike public ACM's 13 months.
# Issue a certificate to an MCP gateway service (valid 24 hours for ephemeral ECS task)
# Step 1: Generate a key pair and CSR in the service (outside of ACM — never share private keys)
openssl ecparam -name prime256v1 -genkey -noout -out service.key
openssl req -new -key service.key \
-subj "/CN=mcp-gateway.internal/O=MCP Server" \
-out service.csr
# Step 2: Issue the certificate from Private CA
SERVICE_CERT_ARN=$(aws acm-pca issue-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--csr fileb://service.csr \
--signing-algorithm SHA256WITHECDSA \
--validity Value=24,Type=HOURS \
--query 'CertificateArn' --output text)
aws acm-pca wait certificate-issued \
--certificate-authority-arn $SUB_CA_ARN \
--certificate-arn $SERVICE_CERT_ARN
# Step 3: Retrieve and store certificate (private key stays on service; only cert is from ACM)
aws acm-pca get-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--certificate-arn $SERVICE_CERT_ARN \
--query 'Certificate' --output text > service.crt
# Step 4: Configure the MCP server to present service.crt + service.key for TLS
# The CA certificate chain (root + subordinate) must be distributed to all clients
# that need to verify this service's identity
# Pattern: issue certificates in ECS task using a sidecar container
# The sidecar requests a certificate at task startup and writes it to a shared volume.
# The MCP server container reads the cert/key from the shared volume and starts serving.
# ECS task definition (relevant snippet):
{
"containerDefinitions": [
{
"name": "cert-provisioner",
"image": "public.ecr.aws/amazonlinux/amazonlinux:2023",
"essential": false,
"command": [
"/bin/bash", "-c",
"openssl ecparam -name prime256v1 -genkey -noout -out /certs/service.key && \
openssl req -new -key /certs/service.key -subj \"/CN=${TASK_ARN}/O=MCPServer\" -out /tmp/service.csr && \
CERT_ARN=$(aws acm-pca issue-certificate --certificate-authority-arn $CA_ARN --csr fileb:///tmp/service.csr --signing-algorithm SHA256WITHECDSA --validity Value=4,Type=HOURS --query CertificateArn --output text) && \
aws acm-pca wait certificate-issued --certificate-authority-arn $CA_ARN --certificate-arn $CERT_ARN && \
aws acm-pca get-certificate --certificate-authority-arn $CA_ARN --certificate-arn $CERT_ARN --query Certificate --output text > /certs/service.crt"
],
"mountPoints": [{ "sourceVolume": "certs", "containerPath": "/certs" }],
"environment": [
{ "name": "CA_ARN", "value": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/abc-123" }
]
},
{
"name": "mcp-server",
"image": "your-mcp-server-image:latest",
"essential": true,
"dependsOn": [{ "containerName": "cert-provisioner", "condition": "SUCCESS" }],
"mountPoints": [{ "sourceVolume": "certs", "containerPath": "/certs", "readOnly": true }]
}
],
"volumes": [{ "name": "certs" }]
}
Configuring mTLS on ALB with Private CA
ALB supports mTLS (mutual TLS) where the ALB verifies client certificates against a trust store. This enables the ALB to authenticate MCP clients by certificate before forwarding requests to the backend, without requiring application-layer API key validation.
# Step 1: Create a trust store with the Private CA certificate chain
# Export CA certificate to include in the trust store
aws acm-pca get-certificate-authority-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--query 'Certificate' --output text > sub_ca_cert.pem
# Include both root and subordinate CA in the trust store bundle
cat root_cert.pem sub_ca_cert.pem > ca_bundle.pem
# Upload the CA bundle to an S3 bucket
aws s3 cp ca_bundle.pem s3://mcp-alb-trust-store/ca-bundle.pem
# Step 2: Create the ALB trust store
TRUST_STORE_ARN=$(aws elbv2 create-trust-store \
--name mcp-client-trust-store \
--ca-certificates-bundle-s3-bucket mcp-alb-trust-store \
--ca-certificates-bundle-s3-key ca-bundle.pem \
--query 'TrustStores[0].TrustStoreArn' --output text)
# Step 3: Enable mTLS on the HTTPS listener
LISTENER_ARN=$(aws elbv2 describe-listeners \
--load-balancer-arn $ALB_ARN \
--query 'Listeners[?Protocol==`HTTPS`].ListenerArn' --output text)
aws elbv2 modify-listener \
--listener-arn $LISTENER_ARN \
--mutual-authentication \
Mode=verify,TrustStoreArn=$TRUST_STORE_ARN,IgnoreClientCertificateExpiry=false
# Step 4: ALB forwards client cert details to backend in HTTP headers
# X-Amzn-Mtls-Clientcert-Subject: "CN=mcp-client-123,O=MCPServer"
# X-Amzn-Mtls-Clientcert-Issuer: "CN=MCP Server Intermediate CA"
# X-Amzn-Mtls-Clientcert-Serial: "01:23:45:67:89:ab"
# X-Amzn-Mtls-Clientcert-Validity: "NotBefore=2026-10-09T00:00:00Z;NotAfter=2026-10-10T00:00:00Z"
# X-Amzn-Mtls-Clientcert: (full URL-encoded PEM certificate)
# X-Amzn-Mtls-Clientcert-Leaf: (leaf cert only)
Once mTLS is enabled on the ALB listener, the ALB validates the client certificate against the trust store before forwarding the request. Requests without a valid client certificate receive a 400 response at the ALB level — the MCP server backend never sees unauthenticated requests. The backend can access the client certificate details from the X-Amzn-Mtls-Clientcert-* headers to implement fine-grained authorization (e.g., only allow clients with CN=mcp-orchestrator to call specific tool endpoints).
Certificate revocation with CRL
Private CA supports Certificate Revocation Lists (CRL) and OCSP. CRL is more commonly used — it publishes a list of revoked certificate serial numbers to an S3 bucket, which clients check periodically. OCSP provides real-time revocation checking but requires clients to support it.
# Revoke a specific certificate (e.g., compromised service key)
aws acm-pca revoke-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--certificate-serial SERIAL_HEX_VALUE \
--revocation-reason KEY_COMPROMISE
# Revocation reason options:
# KEY_COMPROMISE, CA_COMPROMISE, AFFILIATION_CHANGED,
# SUPERSEDED, CESSATION_OF_OPERATION, PRIVILEGE_WITHDRAWN,
# UNSPECIFIED, A_A_COMPROMISE
# Retrieve the CRL distribution point URL from the CA certificate
aws acm-pca get-certificate-authority-certificate \
--certificate-authority-arn $SUB_CA_ARN \
--query 'Certificate' --output text | \
openssl x509 -noout -text | grep "CRL Distribution"
# Output: URI:http://mcp-ca-crls-123456789012.s3.amazonaws.com/crl.crl
# For short-lived certificates (hours), revocation is less critical —
# the certificate will expire before the CRL is widely propagated anyway.
# Short validity is the preferred revocation strategy for ephemeral workloads.
Pricing and cost management
| Charge | Rate | Notes |
|---|---|---|
| Active CA per month | $400/month | Per CA in ACTIVE state; disabled CAs are $0; root CA can be disabled when not signing subordinate certs |
| Certificate issuance (Private CA mode) | $0.75/1,000 certs | First 1,000 certificates/month free; reissuing a cert to the same ECS task on restart counts as a new cert |
| Certificate issuance (End-entity certificates via AWS services) | $0.75/1,000 certs | Same rate regardless of whether issued via ACM or direct PCA API |
| S3 CRL storage | Standard S3 pricing | CRL files are small (KB range); negligible cost |
Cost optimization: keep the root CA disabled — set it to ACTIVE only when signing a new subordinate CA certificate, then disable again. This saves $400/month for the root CA when you have multiple subordinate CAs per account. For ECS deployments with frequent task restarts, use longer certificate validity periods (24 hours instead of 1 hour) to reduce issuance volume — at $0.75/1,000 certs, 1,000 issuances per day = $22.50/month in certificate fees, which may exceed the CA fee itself.
Failure modes reference
| Symptom | Cause | Fix |
|---|---|---|
InvalidStateException when issuing certificate | CA is not in ACTIVE state; CA was just created and certificate has not been imported yet | Complete CA activation (import the signed CA certificate); verify CA status with describe-certificate-authority |
| Client rejects server certificate with "unknown CA" | CA certificate chain not distributed to clients; client trust store does not include the Private CA's root certificate | Export CA certificate chain and add to client trust store; for ALB trust stores, upload updated CA bundle and refresh |
| mTLS returning 400 for valid client certificate | Client certificate CN does not match trust store; certificate is revoked; certificate has expired | Check ALB access logs for ssl_error_code field; use IgnoreClientCertificateExpiry=true temporarily to diagnose; verify certificate chain matches trust store CA |
| High Private CA costs despite few MCP services | ECS tasks restarting frequently each issuing a new certificate; short validity forcing frequent reissuance | Increase certificate validity period; use ECS task role assumption to cache certificates in SSM Parameter Store for the task's lifetime; consider Let's Encrypt for non-mTLS internal TLS needs |
Certificate issuance fails with LimitExceededException | Rate limit on certificate issuance per CA (default: 25 certs/second) | Implement certificate caching; use exponential backoff on issuance; request limit increase via Service Quotas if traffic genuinely requires higher rates |