Understanding the API Gateway mTLS chain depth limit

Learn why Amazon API Gateway allows only four CA levels for mTLS, what causes 403 errors, and how to reproduce and troubleshoot the depth‑limit.

A short question from one of our customers at DoiT turned into a deep dive on mutual TLS (mTLS) with Amazon API Gateway and the certificate‑chain depth limit. The customer was using a multi-level Public Key Infrastructure (PKI) and getting an HTTP 403 error when using some client certificates.

Both REST APIs and HTTP APIs in API Gateway terminate TLS. When you enable mTLS on a custom domain, you must upload a truststore (a PEM bundle) to Amazon S3 and point the domain at it.

However, there is a key quota to be aware of, from the Amazon API Gateway Developer Guide:

"Certificates can have a maximum chain length of four".

That’s four Certificate Authorities (CA) certificates in the truststore — root plus up to three intermediate Certificate Authorities.

NOTE: The leaf client certificate itself is not counted.

Below is how I reproduced the issue, confirmed the limit, and found a pragmatic logging workaround for HTTP APIs.

Reproducing the certificate infrastructure in the lab

I generated a Private Key Infrastructure (PKI) to verify the limit. The script below creates a nested PKI with 5 Certificate Authorities (CAs) and corresponding Client Certificates at each level:

Level CA Common Name Client Cert
0 Root CA client‑root
1 Intermediate CA 1 client‑int1
2 Intermediate CA 2 client‑int2
3 Intermediate CA 3 client‑int3
4 Intermediate CA 4 client‑int4

NOTE: Each CA and leaf lives one year (--not-after 8760h).

In other words, I created this nested CA structure:

Root CA
└── Intermediate CA 1
└── Intermediate CA 2
└── Intermediate CA 3
└── Intermediate CA 4

And for every CA, I minted one client certificate (client-rootclient-int1client-int2 ,  client-int3 and client-int4).

Then I built two identical API Gateway stacks backed by a couple of simple AWS Lambda functions:

  1. HTTP API + custom domain http.example.com
  2. REST API + custom domain rest.example.com

Here's the script to create the certificates:

Prerequisites to run the scriptstep , openssl and aws configured with permissions to read/write the S3 bucket that backs your truststore.
###########################################################################
# 0. Variables you might want to tweak first
###########################################################################
TRUST_BUCKET="s3://yours3truststore"  # S3 bucket for truststore
HTTP_DOMAIN="https://http.example.com"   # custom domain mapped to an HTTP API
REST_DOMAIN="https://rest.example.com/dev/"  # custom domain mapped to a REST API
EXPIRY="8760h"   # 1 year
###########################################################################

####################################################################################
# 1. Create the **Root CA** and a client cert signed directly by it
####################################################################################
cat > root.tpl <<'EOF'
{
  "subject": { "commonName": "Root CA" },
  "issuer":  { "commonName": "Root CA" },
  "keyUsage": ["certSign","crlSign"],
  "basicConstraints": { "isCA": true, "maxPathLen": 8 }
}
EOF

step certificate create \
  --template root.tpl \
  "Root CA" root-ca.crt root-ca.key \
  --not-after ${EXPIRY} --no-password --insecure

step certificate create \
  "Client-of-Root" client-root.crt client-root.key \
  --ca root-ca.crt --ca-key root-ca.key \
  --profile leaf --not-after ${EXPIRY} --no-password --insecure

aws s3 cp root-ca.crt ${TRUST_BUCKET}/root-ca.pem


####################################################################################
# 2‑5. Repeat for four intermediates and their corresponding clients
####################################################################################
for i in 1 2 3 4; do
  cat > int${i}.tpl <<EOF
{
  "subject": { "commonName": "Intermediate CA ${i}" },
  "keyUsage": ["certSign","crlSign"],
  "basicConstraints": { "isCA": true, "maxPathLen": $((8-i)) }
}
EOF

  # parent CA is root for level‑1, otherwise previous intermediate
  if [ $i -eq 1 ]; then
    PARENT_CRT=root-ca.crt
    PARENT_KEY=root-ca.key
  else
    PARENT_CRT=intermediate$((i-1))-ca.crt
    PARENT_KEY=intermediate$((i-1))-ca.key
  fi

  step certificate create \
    --template int${i}.tpl \
    "Intermediate CA ${i}" intermediate${i}-ca.crt intermediate${i}-ca.key \
    --ca ${PARENT_CRT} --ca-key ${PARENT_KEY} \
    --not-after ${EXPIRY} --no-password --insecure

  step certificate create \
    "Client-of-Int${i}" client-int${i}.crt client-int${i}.key \
    --ca intermediate${i}-ca.crt --ca-key intermediate${i}-ca.key \
    --profile leaf --not-after ${EXPIRY} --no-password --insecure

  # rebuild truststore: newest intermediate first is fine — order is irrelevant
  cat intermediate${i}-ca.crt $(seq $((i-1)) -1 1 | sed "s/.*/intermediate&-ca.crt/") root-ca.crt \
    > certchain-up-to-int${i}.pem
  aws s3 cp certchain-up-to-int${i}.pem ${TRUST_BUCKET}/certchain-up-to-int${i}.pem

done

Testing access to the APIs using Client Certificates at different nesting levels

Once the PKI and the APIs are set up, we can test access using the client certificates generated under each CA. These are the expected results when using the largest truststore (containing 5 CAs):

Client cert Chain depth HTTP API REST API
client-root 0 200 200
client-int1 1 200 200
client-int2 2 200 200
client-int3 3 200 200
client-int4 4 403 403

Once the fifth CA (root + 4 intermediates) appears, both API types should reject the handshake. The behaviour is identical for both REST and HTTP APIs because the TLS layer that enforces it sits in front of the API variant.

These are the commands to check the responses from both APIs:

echo "Test mTLS access to HTTP API with client-root certificate:"
curl https://http.example.com --key client-root.key --cert client-root.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to HTTP API with client-int1 certificate:"
curl https://http.example.com --key client-int1.key --cert client-int1.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to HTTP API with client-int2 certificate:"
curl https://http.example.com --key client-int2.key --cert client-int2.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to HTTP API with client-int3 certificate:"
curl https://http.example.com --key client-int3.key --cert client-int3.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to HTTP API with client-int4 certificate:"
curl https://http.example.com --key client-int4.key --cert client-int4.crt -w "\nHTTP %{http_code}\n"
echo "=========================================================="
echo "Test mTLS access to REST API with client-root certificate:"
curl https://rest.example.com/dev/ --key client-root.key --cert client-root.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to REST API with client-int1 certificate:"
curl https://rest.example.com/dev/ --key client-int1.key --cert client-int1.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to REST API with client-int2 certificate:"
curl https://rest.example.com/dev/ --key client-int2.key --cert client-int2.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to REST API with client-int3 certificate:"
curl https://rest.example.com/dev/ --key client-int3.key --cert client-int3.crt -w "\nHTTP %{http_code}\n"
echo "Test mTLS access to REST API with client-int4 certificate:"
curl https://rest.example.com/dev/ --key client-int4.key --cert client-int4.crt -w "\nHTTP %{http_code}\n"

Testing at the root level

As the setup script already uploads the truststore files to S3, we just need to let API Gateway know of these files (and optionally, their Version ID). Let's start with the root level truststore (repeat it for both the REST and API custom domain names):

NOTE: We could also use the aws apigateway update-domain-name command to update the truststore URI and version but the API Gateway API seems not like multiple concurrent changes and ofter complains when using retries, thus I prefer to make that change using the UI.

Running the curl commands above, returns this (as expected):

Test mTLS access to HTTP API with client-root certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int1 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to HTTP API with client-int2 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to HTTP API with client-int3 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to HTTP API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403
==========================================================
Test mTLS access to REST API with client-root certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int1 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to REST API with client-int2 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to REST API with client-int3 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to REST API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403

The trust store only allows us to use the client-root certificate.

Testing at the Intermediate 1 level

Now, we'll repeat the test after changing the truststore URI to the intermediate level 1 truststore file already stored in S3 (repeat it for both the REST and API custom domain names):

Running the curl commands above, returns this (also, as expected):

Test mTLS access to HTTP API with client-root certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int1 certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int2 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to HTTP API with client-int3 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to HTTP API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403
==========================================================
Test mTLS access to REST API with client-root certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int1 certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int2 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to REST API with client-int3 certificate:
{"message":"Forbidden"}
HTTP 403
Test mTLS access to REST API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403

The trust store allows us to use the client-root and the client-int1 certificates only.

Testing at the Intermediate 2 and 3 levels

I'm not going to show this, but access would be granted up to client-int2 and client-int3, respectively.

Testing at the Intermediate 4 level

Finally, we should see HTTP 403 errors for clients created at this level after changing the truststore URI to the intermediate level 4 truststore file. There is no way to avoid this, as this is the limit imposed by API Gateway. The Management Console will also show a warning about the truststore.

Running the curl commands above, returns:

Test mTLS access to HTTP API with client-root certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int1 certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int2 certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int3 certificate:
"Hello from an HTTP API in API Gateway!"
HTTP 200
Test mTLS access to HTTP API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403
==========================================================
Test mTLS access to REST API with client-root certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int1 certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int2 certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int3 certificate:
Hello from a REST API in API Gateway!
HTTP 200
Test mTLS access to REST API with client-int4 certificate:
{"message":"Forbidden"}
HTTP 403

Where to look when you hit 403 with mTLS enabled

  • HTTP APIs have no execution logs (as of July 2025). If you need insight, temporarily remap your custom domain to a dummy REST API stage with access logging enabled. The same failing request will surface identity.clientCert details in CloudWatch.
  • Watch for misleading INVALID_API_KEY (or similar) in the log’s error.responseType.
  • To log HTTP API requests, you can temporarily change the mapping of the HTTP Custom Domain Name and point it to the REST API.

TL;DR

  • Both HTTP and REST APIs reject client certificate chains longer than four Certificate Authorities (CAs): root + up to three intermediates. Exceed that quota and you get a 403 Forbidden error before your integration even sees the request.
  • The limit lives in the TLS engine that fronts API Gateway (and matches the same depth limit in Application Load Balancer).

Subscribe to Javier in the Cloud

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe