Terminating Mutual TLS for Agency WFS Clients

This guide adds certificate-based client authentication for partner organisations consuming WFS, terminated at the proxy and mapped to the same tenant identity the rest of the portal uses. It belongs to Reverse Proxy Configuration for WMS/WFS, within the Infrastructure Orchestration & Configuration Management framework.

Prerequisites

When mutual TLS is the right answer

Client certificates are a heavyweight mechanism and they suit one situation particularly well: a small number of long-lived, machine-to-machine integrations between organisations, where the consuming system cannot participate in an interactive login and the relationship is formal enough to justify certificate management.

Three ways a partner system can prove who it is API key, identity-provider token and client certificate, compared by what they prove, what they demand of the client, and what they demand of you. MECHANISM PROVES COSTS API key a shared secret possession of a copyable string trivial for both sides; easy to leak identity token OIDC client credentials an identity, with claims and expiry the client must implement a flow client certificate mutual TLS possession of a private key certificate lifecycle, on both sides — and somebody must own the expiry

Step-by-step implementation

1. Use a separate hostname

Requiring a client certificate on the main hostname breaks every browser and every ordinary client. Give the mutual-TLS endpoint its own name so the requirement applies only where it belongs.

server {
    listen 443 ssl;
    server_name partners.portal.example.gov;

    ssl_certificate     /etc/ssl/portal/partners-fullchain.pem;
    ssl_certificate_key /etc/ssl/portal/partners-privkey.pem;

    # Require a client certificate signed by a CA we trust for this purpose.
    ssl_client_certificate /etc/ssl/partners/trusted-cas.pem;
    ssl_verify_client on;
    ssl_verify_depth 2;

    # Revocation must be checked, or a withdrawn certificate keeps working.
    ssl_crl /etc/ssl/partners/revoked.crl;

    location /geoserver/wfs {
        proxy_pass http://geoserver_backend;
    }
}

2. Map the certificate to a tenant

The certificate proves who connected; the portal needs to know which tenant that is. Map explicitly from a stable field, and refuse anything unmapped rather than defaulting.

# Map the client certificate's subject DN to a tenant. Explicit, and no default.
map $ssl_client_s_dn $partner_tenant {
    default                                              "";
    "CN=highways-integration,O=County Highways,C=GB"      "tenant-highways";
    "CN=planning-etl,O=District Planning,C=GB"            "tenant-planning";
}

server {
    # ... TLS configuration from step 1

    location /geoserver/wfs {
        # An unmapped but validly signed certificate must not be treated as a tenant.
        if ($partner_tenant = "") { return 403 '{"error":"certificate_not_mapped"}'; }

        proxy_set_header X-Tenant-Id      $partner_tenant;
        proxy_set_header X-Client-Serial  $ssl_client_serial;
        # Never let a caller supply these themselves.
        proxy_set_header X-Forwarded-Client-Cert "";

        proxy_pass http://geoserver_backend;
    }
}

Mapping on the subject DN rather than on the certificate fingerprint is deliberate: a renewal keeps the DN and changes the fingerprint, so a fingerprint map turns every routine renewal into an outage requiring a configuration change on your side.

3. Log the identity, including the serial

log_format mtls '$remote_addr $partner_tenant "$ssl_client_s_dn" '
                'serial=$ssl_client_serial verify=$ssl_client_verify '
                'expires="$ssl_client_v_end" $status "$request"';

access_log /var/log/nginx/partners.log mtls;

The serial and expiry in the log are what make a support question answerable: which certificate was this, and was it the one we think they are using.

Expiry is the operational problem, not authentication

Client certificates work reliably and expire silently. The partner’s certificate is managed by the partner, often by somebody who has left, and the first indication of an expiry is usually a nightly integration that stops without anybody on either side noticing for days.

Three notifications before a partner certificate expires A countdown to expiry with notification points at sixty, thirty and seven days, and the failure mode if none exist. 60 days internal report lists it 30 days named partner contact told 7 days escalated, integration at risk expiry Without these notifications The certificate expires, the nightly integration fails with a handshake error into a log nobody reads, and the problem surfaces days later as “the data has not updated” — from a third party, not from monitoring.
# A scheduled check over the trusted client certificates, from the mapping file
# rather than from memory. Alerts at the three points above.
for CERT in /etc/ssl/partners/clients/*.pem; do
  END=$(openssl x509 -in "$CERT" -noout -enddate | cut -d= -f2)
  DAYS=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
  CN=$(openssl x509 -in "$CERT" -noout -subject | sed 's/.*CN=\([^,]*\).*/\1/')
  case $DAYS in
    [0-7])   echo "CRITICAL $CN expires in $DAYS days" ;;
    [8-9]|[12][0-9]|30) echo "WARN $CN expires in $DAYS days" ;;
    *) [ "$DAYS" -le 60 ] && echo "INFO $CN expires in $DAYS days" ;;
  esac
done

Onboarding a partner without a two-week email thread

Most of the friction in mutual TLS is not technical; it is the exchange of files and expectations between two organisations. A short, written onboarding sequence removes nearly all of it.

Four steps, and who performs each Signing request, issuance, mapping commit, and joint verification, with the responsible party for each. 1 · partner generates a key pair, sends a signing request 2 · you issue agreed subject and lifetime; no key changes hands 3 · map it subject to tenant, in a reviewed commit 4 · verify a real request from their own network The private key never leaves the partner's environment, which removes the awkward question of how to transmit it and the record-keeping that follows. Capture the partner's technical contact at step one — it is needed at expiry.

Verification

# 1. A request without a certificate is refused
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://partners.portal.example.gov/geoserver/wfs?service=WFS&request=GetCapabilities"
#   expect: 400 or 496 — the handshake requires a certificate

# 2. A valid, mapped certificate is accepted and carries its tenant
curl -s --cert highways.pem --key highways.key -o /dev/null -w '%{http_code}\n' \
  "https://partners.portal.example.gov/geoserver/wfs?service=WFS&version=2.0.0&request=GetCapabilities"
#   expect: 200, and X-Tenant-Id: tenant-highways in the upstream log

# 3. A valid but unmapped certificate is refused, not defaulted
curl -s --cert unmapped.pem --key unmapped.key -w '\n%{http_code}\n' \
  "https://partners.portal.example.gov/geoserver/wfs?service=WFS&request=GetCapabilities"
#   expect: 403 certificate_not_mapped

# 4. A revoked certificate stops working
openssl crl -in /etc/ssl/partners/revoked.crl -noout -text | grep -c "$REVOKED_SERIAL"
curl -s -o /dev/null -w '%{http_code}\n' --cert revoked.pem --key revoked.key \
  "https://partners.portal.example.gov/geoserver/wfs?service=WFS&request=GetCapabilities"
#   expect: rejected at the handshake

# 5. The upstream cannot be told a tenant by the caller
curl -s --cert highways.pem --key highways.key -H "X-Tenant-Id: tenant-planning" \
  -o /dev/null -w '%{http_code}\n' \
  "https://partners.portal.example.gov/geoserver/wfs?service=WFS&request=GetCapabilities"
#   expect: 200, and the upstream sees tenant-highways — the header was overwritten

Check 5 is the one that matters most. A proxy that forwards a caller-supplied tenant header alongside the derived one has made the whole mechanism decorative.

Troubleshooting matrix

Symptom Likely cause Fix
Browsers prompt for a certificate on the main site Client verification enabled on the shared hostname Use a separate hostname for the mutual-TLS endpoint
A partner’s renewal breaks their access Mapping keyed on fingerprint rather than subject DN Map on the DN; treat a DN change as a deliberate re-onboarding
A revoked certificate still works CRL not configured, or stale Configure the CRL and refresh it on a schedule; alert if it is old
A validly signed but unknown certificate gets access A default in the mapping Refuse unmapped certificates explicitly
Handshake fails with an intermediate CA ssl_verify_depth too shallow for the partner’s chain Raise the depth deliberately; confirm which CAs are actually trusted
The upstream sees the wrong tenant Caller-supplied header forwarded Clear inbound identity headers at the proxy before setting them
An integration fails days after expiry with no alert No expiry monitoring for client certificates Run the scheduled check; notify the partner contact, not only the platform team

FAQ

Should the portal issue the certificates or accept the partner’s?

Issuing them yourself is simpler to control — you set the lifetime, the naming and the revocation — and it means distributing a private key to the partner, which needs a secure channel and a record. Accepting their CA avoids that distribution and means trusting their issuance practices for everything that CA signs. For a small number of formal partners, issuing is usually the better trade.

How long should a client certificate live?

Long enough to avoid constant renewal friction and short enough to bound an undetected compromise: one year is a common compromise for partner integrations, with the notification schedule above. Anything longer tends to outlive the person who arranged it.

Can mutual TLS coexist with API keys and OIDC?

Yes, and it usually should. Different consumers have different capabilities: a partner ETL system with a certificate, a web map with an origin-restricted key, an internal service with a token. What matters is that all three resolve to the same tenant identity before authorisation, so the policy layer has one concept to reason about.

Does terminating at the proxy weaken the guarantee?

It moves it. Everything behind the proxy trusts the proxy’s assertion, which is why the derived headers must be set by the proxy and cleared from inbound requests. If the network behind the proxy is not trusted, extend mutual TLS inward as a separate hop rather than passing the client certificate through.

What should happen when a partner’s certificate expires anyway?

Fail clearly and tell somebody. The handshake failure is not visible to the partner’s application as anything more than a connection error, so the portal side should alert on a partner whose traffic has stopped — an absence-of-traffic alert per partner is cheap and catches this, along with several other silent failures.

Is this suitable for many small consumers?

No. The certificate lifecycle cost is per consumer and falls on both sides, so it scales badly past a handful of relationships. For a long tail of small integrations, scoped API keys are the proportionate mechanism, with mutual TLS reserved for the formal, high-volume, machine-to-machine cases.

How should a compromised partner certificate be handled?

Revoke it, publish the updated revocation list, and confirm at the proxy that the certificate is actually refused — a revocation that the proxy has not reloaded is a policy statement rather than a control. Then issue a replacement through the normal onboarding steps rather than reusing the subject, and record the incident against the partner relationship so the next review knows it happened.

Up one level: Reverse Proxy Configuration for WMS/WFS.