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-root
, client-int1
, client-int2
, client-int3
and client-int4
).
Then I built two identical API Gateway stacks backed by a couple of simple AWS Lambda functions:
- HTTP API + custom domain http.example.com
- REST API + custom domain rest.example.com
Here's the script to create the certificates:
Prerequisites to run the script:step
,openssl
andaws
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’serror.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).