Upstream mTLS
Configure mutual TLS (mTLS) between API7 Gateway and upstream services so the gateway presents a client certificate to the upstream and optionally validates the upstream's server certificate.
When an upstream service requires mutual TLS, API7 Gateway can act as a TLS client — presenting a client certificate to the upstream and optionally validating the upstream's server certificate against a trusted CA.
Configure it on the service's upstream, with three fields that reference stored certificate objects:
| Field | Type | Purpose |
|---|---|---|
client_certificate | certificate object ID | The certificate and key the gateway presents to the upstream |
ca_certificates | array of CA certificate object IDs | The CAs used to verify the upstream's server certificate. Inert unless tls_verify is also true. |
tls_verify | boolean | Whether to verify the upstream's server certificate. Only takes effect alongside client_certificate or ca_certificates — see the note below. |
This page covers mTLS between the gateway and upstream services. For mTLS between API clients and the gateway, see Client mTLS Authentication. For mTLS between the control plane and data plane, see Mutual TLS between CP and DP.
tls_verify on its own does nothing. The control plane builds the upstream's TLS configuration only when at least one of client_certificate or ca_certificates is present. An upstream that sets tls_verify: true and neither reference is stored and returns 200, but no TLS configuration reaches the data plane at all — verification is not enabled, and nothing reports that.
Pair it with ca_certificates for verification against CAs you choose, or with client_certificate if you intend to verify against the data plane's own apisix.ssl.ssl_trusted_certificate.
An older upstream.tls block accepts the same material inline as PEM. It still works, but it is marked deprecated in the Admin API and cannot reference stored secrets. See The deprecated inline tls block if you are maintaining a configuration that uses it, or driving the gateway with ADC.
How it works
- The gateway opens a TLS connection to the upstream using the upstream's
host/SNI. - The upstream presents its server certificate. If
tls_verifyistrue, the gateway validates it against the CAs inca_certificates; otherwise the certificate is accepted without verification. - The upstream requests a client certificate. The gateway presents the certificate referenced by
client_certificate. - The upstream validates the client certificate against its own trusted CA. If validation succeeds, the request is proxied; otherwise the gateway returns
HTTP 502.
Prerequisites
-
A TLS-enabled upstream service that requests a client certificate and validates it against a trusted CA.
-
jq, for reading IDs out of the API responses. -
A token from the Dashboard, and the variables every command below uses:
export API7_ADMIN_URL="https://localhost:7443" export GATEWAY_GROUP_ID="default" # the group the quickstart creates; otherwise its UUID from Gateway Groups in the Dashboard read -rsp "Dashboard token: " API_KEY && export API_KEY && echo
Prepare the following PEM-encoded material:
| Item | Purpose |
|---|---|
| Client certificate + private key | Presented by the gateway to the upstream. Must be signed by a CA the upstream trusts. |
| Upstream CA certificate | Used by the gateway to validate the upstream's server certificate. Required only when tls_verify is true. |
The examples below use an nginx instance with the following configuration:
events {}
http {
server {
listen 8443 ssl;
server_name upstream.test.local;
ssl_certificate /certs/upstream.crt;
ssl_certificate_key /certs/upstream.key;
ssl_client_certificate /certs/ca.crt;
ssl_verify_client on;
location / {
return 200 "upstream-mtls-ok client=$ssl_client_s_dn\n";
add_header Content-Type text/plain;
}
}
}If you do not have a set of certificates for testing, generate one with:
# CA
openssl req -x509 -newkey rsa:2048 -keyout ca.key -out ca.crt -days 365 -nodes \
-subj "/CN=Test CA"
# Upstream server certificate
openssl req -newkey rsa:2048 -keyout upstream.key -out upstream.csr -nodes \
-subj "/CN=upstream.test.local"
openssl x509 -req -in upstream.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out upstream.crt -days 365
# Client certificate (presented by the gateway)
openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \
-subj "/CN=api7-gateway"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 365Configure upstream mTLS
Step 1: Store the client certificate
Create a certificate object holding the certificate and key the gateway will present. Note the id in the response — the upstream references it.
CLIENT_CRT=$(awk 1 ORS='\\n' client.crt)
CLIENT_KEY=$(awk 1 ORS='\\n' client.key)
CLIENT_CERT_ID=$(curl -sk "${API7_ADMIN_URL}/apisix/admin/certificates?gateway_group_id=${GATEWAY_GROUP_ID}" -X POST \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"upstream-mtls-client\",
\"cert\": \"${CLIENT_CRT}\",
\"key\": \"${CLIENT_KEY}\"
}" | jq -r '.value.id')
echo "${CLIENT_CERT_ID}"The POST returns the created object, so its id comes straight out of the response — do not look it up by name afterwards, because re-running this guide leaves two certificates with the same name and a lookup would pick one at random.
Build the PEM variables with a top-level assignment as shown. Substituting $(awk ...) directly inside the quoted -d string does not work: the outer double quotes collapse \\n to \n before awk runs, so awk emits real newlines into the JSON string and the request is rejected as malformed.
Step 2: Store the upstream CA certificate
CA certificates are a separate resource, and they take only a certificate — there is no key:
CA_CRT=$(awk 1 ORS='\\n' ca.crt)
CA_CERT_ID=$(curl -sk "${API7_ADMIN_URL}/apisix/admin/ca_certificates?gateway_group_id=${GATEWAY_GROUP_ID}" -X POST \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"upstream-mtls-ca\",
\"cert\": \"${CA_CRT}\"
}" | jq -r '.value.id')
echo "${CA_CERT_ID}"Skip this step if you are not verifying the upstream's certificate.
ca_certificates on its own does not turn verification on. The data plane installs the trust store you gave it but only enables verification when tls_verify is explicitly present, and NGINX's default is off. A service with pinned CAs and no tls_verify stores cleanly, shows the CAs in a GET, and verifies nothing. Always set both.
Step 3: Create a service that references them
Create a service whose upstream uses the https scheme (or grpcs for gRPC) and references the two certificate objects by ID.
pass_host: rewrite together with upstream_host ensures the SNI sent during the TLS handshake matches the upstream server's certificate, even when the node is addressed by IP.
curl -k "${API7_ADMIN_URL}/apisix/admin/services/upstream-mtls?gateway_group_id=${GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"upstream-mtls\",
\"upstream\": {
\"type\": \"roundrobin\",
\"scheme\": \"https\",
\"pass_host\": \"rewrite\",
\"upstream_host\": \"upstream.test.local\",
\"nodes\": [
{ \"host\": \"192.0.2.10\", \"port\": 8443, \"weight\": 1 }
],
\"client_certificate\": \"${CLIENT_CERT_ID}\",
\"ca_certificates\": [\"${CA_CERT_ID}\"],
\"tls_verify\": true
}
}"Replace the node host with the address of your upstream.
The control plane resolves each ID at write time and rejects the request if a certificate does not exist in that gateway group, so a typo fails immediately rather than at the first proxied request.
To skip upstream certificate verification — for example during development against a self-signed upstream certificate — set tls_verify to false and omit ca_certificates. The gateway still presents its client certificate but does not validate the upstream's. This is not recommended for production.
Do not set client_certificate/ca_certificates and the inline tls block on the same upstream. When either certificate reference is present, the control plane builds the upstream's TLS configuration from the referenced objects alone and discards the inline tls block entirely, including a verify you set there. The result is a configuration that reads as if both applied while only one did.
Step 4: Create a route on the service
curl -k "${API7_ADMIN_URL}/apisix/admin/routes/upstream-mtls?gateway_group_id=${GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "upstream-mtls",
"service_id": "upstream-mtls",
"paths": ["/*"]
}'Step 5: Verify
Send a request through the gateway:
curl -i "http://127.0.0.1:9080/anything"You should receive a 200 OK response and a body of the form:
upstream-mtls-ok client=CN=api7-gatewayThe client= portion echoes the subject DN of the client certificate the gateway presented, confirming that the upstream completed the mTLS handshake against it.
If you remove client_certificate and re-apply the service, the same request no longer succeeds — but the exact failure mode depends on your upstream's nginx/OpenSSL version. Older combinations reject the missing certificate at the TLS handshake, which the gateway surfaces as HTTP 502 with an SSL alert (peer did not return a certificate) in the gateway error log. Newer combinations complete the TLS handshake and reject the request at the HTTP layer instead, so the upstream's own error response (for example 400 No required SSL certificate was sent) is proxied through unchanged, with no SSL alert logged on the gateway side. Either way, the request stops succeeding — check the upstream's own logs if the response code does not match what you expect here.
Rotate the certificate
Because the upstream holds an ID rather than a copy of the PEM, rotation does not touch the service: update the certificate object in place and every upstream referencing it picks up the new material.
NEW_CRT=$(awk 1 ORS='\\n' client-new.crt)
NEW_KEY=$(awk 1 ORS='\\n' client-new.key)
curl -k "${API7_ADMIN_URL}/apisix/admin/certificates/${CLIENT_CERT_ID}?gateway_group_id=${GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"upstream-mtls-client\",
\"cert\": \"${NEW_CRT}\",
\"key\": \"${NEW_KEY}\"
}"Roll the CA the other way around: add the new CA as a second entry in ca_certificates and let both be trusted while the upstream switches over, then remove the old one. Replacing the only entry in a single step means every request in flight against the old certificate fails.
For the certificate rotation between the control plane and data planes, which is a separate mechanism, see Rotate Certificates.
Reference certificates from a secret provider
A certificate object's cert and key accept a $secret:// or $env:// reference instead of inline PEM, so the private key never has to be stored in the control plane:
curl -k "https://localhost:7443/apisix/admin/certificates?gateway_group_id={gateway_group_id}" -X POST \
-H "X-API-KEY: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "upstream-mtls-client",
"cert": "$secret://vault/1/upstream-mtls/cert",
"key": "$secret://vault/1/upstream-mtls/key"
}'See Secure Credentials for the available providers and the URI format.
This works only through a certificate object. The deprecated inline tls.client_cert and tls.client_key fields accept raw PEM only — the Admin API rejects any value that begins with $, and the data plane passes what it stores straight to the PEM parser. A CA certificate object's cert is likewise raw PEM only.
Referencing a secret is therefore another reason to prefer client_certificate over the inline block.
The deprecated inline tls block
The older form embeds the PEM material in the upstream itself:
"upstream": {
"scheme": "https",
"nodes": [{ "host": "192.0.2.10", "port": 8443, "weight": 1 }],
"tls": {
"client_cert": "-----BEGIN CERTIFICATE-----\n...",
"client_key": "-----BEGIN PRIVATE KEY-----\n...",
"verify": true,
"ca_certs": ["-----BEGIN CERTIFICATE-----\n..."]
}
}All four fields are implemented and enforced by the data plane, so an existing configuration keeps working. Prefer the certificate objects for new work: the inline block is marked deprecated in the Admin API schema, it cannot reference a secret provider, and rotating a certificate means rewriting every service that embeds it.
The Admin API will not accept an inline tls block without a client certificate. Its schema offers two shapes — client_cert together with client_key, or client_cert_id — and requires one of them. A block containing only verify and ca_certs, meaning "verify the upstream but present nothing", is rejected with HTTP 400 and property "client_cert" is missing, even though the data plane implements exactly that behaviour.
Use ca_certificates with tls_verify to configure server-certificate verification on its own. That path has no such restriction.
tls.client_cert_id is the one bridge between the two forms — it references a certificate object while keeping the rest of the block inline.
client_cert_id lives inside the tls block, so it is not the same thing as the top-level client_certificate. An upstream that uses client_cert_id still has no certificate reference at the top level, which means a tls_verify set alongside it is discarded exactly as described above. Keep verify inside the block when you use this form, and do not mix the two spellings on one upstream.
With ADC
ADC's schema supports only the inline form, and only four of its fields: client_cert, client_key, client_cert_id and verify.
services:
- name: upstream-mtls
upstream:
type: roundrobin
scheme: https
pass_host: rewrite
upstream_host: upstream.test.local
nodes:
- host: 192.0.2.10
port: 8443
weight: 1
tls:
client_cert_id: "<certificate object id>"
verify: false
routes:
- name: upstream-mtls
uris:
- /*adc sync -f adc.yamlADC cannot pin a CA for upstream verification. There is no ca_certs field in its schema, and the schema rejects unknown properties, so verify: true under ADC validates the upstream against whatever apisix.ssl.ssl_trusted_certificate holds on the data plane rather than against a CA you chose for this upstream.
If you need a pinned CA, configure that service through the Admin API using ca_certificates, and exclude it from the ADC-managed set so a later adc sync does not overwrite it.
Troubleshooting
| Symptom | Likely cause | Resolution |
|---|---|---|
HTTP 502 from the gateway after enabling upstream mTLS | The upstream rejected the gateway's client certificate. | Verify the client certificate is signed by a CA the upstream trusts. Check the upstream logs for TLS errors. |
HTTP 502 with tls_verify: true | The upstream's server certificate is not trusted by the CAs in ca_certificates. | Add the CA that signed the upstream's server certificate, or temporarily set tls_verify: false to confirm this is the cause. |
HTTP 502 with SNI mismatch in upstream logs | The upstream certificate's CN/SAN does not match the SNI sent by the gateway. | Set pass_host: rewrite and upstream_host on the upstream so the SNI matches the upstream certificate. |
| Verification appears configured but is not enforced | The upstream sets both a certificate reference and an inline tls block; the inline block was discarded. | Move verify to tls_verify and the CAs to ca_certificates, and remove the tls block. |
HTTP 400 when writing the service | A referenced CA certificate ID does not exist in that gateway group (some ca certificates not found), or an inline field was given a $secret:// value. | Confirm the IDs with a GET on /apisix/admin/ca_certificates; use a certificate object for secret references. |
HTTP 404 when writing the service | The client_certificate ID does not exist. A missing client certificate is a not-found error, not a validation error. | Confirm the ID with a GET on /apisix/admin/certificates. |
HTTP 400 with property "client_cert" is missing | An inline tls block set verify/ca_certs without a client certificate. | Use ca_certificates and tls_verify instead of the inline block. |
| The write succeeds but no TLS settings reach the gateway | tls_verify was set without client_certificate or ca_certificates. | Add the CA certificate references, or the client certificate. |
| Connection refused or plaintext error | The upstream scheme is http instead of https/grpcs. | Set the upstream scheme to https or grpcs. |
A tls stream upstream presents the client certificate but never verifies the peer | Verification is implemented for the https and grpcs schemes only. | On an L4 tls upstream, verify and ca_certs validate and store but are not applied. Do not rely on them there. |
A traffic-split destination ignores its tls block | traffic-split accepts the full upstream schema but installs only a fixed field list, which does not include tls. | Put the mTLS configuration on the service's own upstream rather than inside a traffic-split rule. |
Next steps
- Client mTLS Authentication: Configure mTLS for clients connecting to the gateway.
- SSL Certificates: Manage certificates in API7 Gateway.
- Secure Credentials: Store certificates in external secret managers.