Skip to content

mTLS Client Certificates

Use Flask middleware to parse X-ARR-ClientCert, validate the forwarded client certificate, and attach a private certificate to outbound HTTPS calls when the remote service requires mutual TLS.

flowchart TD
    Client[Client certificate] --> FE[App Service front end]
    FE --> Header[X-ARR-ClientCert]
    Header --> Flask[Flask middleware]
    Flask --> Policy[CN or thumbprint allowlist]
    Flask --> Outbound[requests session with client certificate]

Prerequisites

  • Flask app running on Azure App Service
  • clientCertEnabled=true with an appropriate clientCertMode
  • Python 3.11 or later
  • Private certificate loaded for outbound calls when required

requirements.txt additions:

Flask==3.0.3
cryptography==44.0.2
httpx==0.28.1
requests==2.32.3

What You'll Build

  • A Flask before_request hook that parses X-ARR-ClientCert
  • Allowlist validation by thumbprint or common name
  • An outbound helper that loads a .p12 certificate and writes temporary PEM files for requests

Steps

1. Add the Flask middleware and routes

import hashlib
import os
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Iterator

import requests
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs12
from flask import Flask, abort, g, jsonify, request

app = Flask(__name__)

ALLOWED_COMMON_NAMES = {
    value.strip()
    for value in os.getenv("ALLOWED_CLIENT_CERT_COMMON_NAMES", "api-client.contoso.com").split(",")
    if value.strip()
}
ALLOWED_THUMBPRINTS = {
    value.strip().upper()
    for value in os.getenv("ALLOWED_CLIENT_CERT_THUMBPRINTS", "").split(",")
    if value.strip()
}
OUTBOUND_CERT_PATH = os.getenv("OUTBOUND_CLIENT_CERT_PATH", "/var/ssl/private/<thumbprint>.p12")
OUTBOUND_CERT_PASSWORD = os.getenv("OUTBOUND_CLIENT_CERT_PASSWORD", "")
REMOTE_API_URL = os.getenv("REMOTE_API_URL", "https://api.contoso.com/health")


def load_forwarded_certificate() -> x509.Certificate:
    header_value = request.headers.get("X-ARR-ClientCert")
    if not header_value:
        abort(403, description="client certificate header missing")

    pem_bytes = (
        "-----BEGIN CERTIFICATE-----\n"
        f"{header_value}\n"
        "-----END CERTIFICATE-----\n"
    ).encode("utf-8")

    try:
        return x509.load_pem_x509_certificate(pem_bytes)
    except ValueError as exc:
        abort(403, description=f"invalid client certificate header: {exc}")


def certificate_thumbprint(certificate: x509.Certificate) -> str:
    return hashlib.sha1(certificate.public_bytes(serialization.Encoding.DER)).hexdigest().upper()


def certificate_common_name(certificate: x509.Certificate) -> str | None:
    try:
        return certificate.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
    except IndexError:
        return None


def validate_certificate(certificate: x509.Certificate) -> dict:
    now = datetime.now(timezone.utc)
    not_before = certificate.not_valid_before_utc
    not_after = certificate.not_valid_after_utc

    if now < not_before or now > not_after:
        abort(403, description="client certificate is expired or not yet valid")

    thumbprint = certificate_thumbprint(certificate)
    common_name = certificate_common_name(certificate)

    if ALLOWED_THUMBPRINTS and thumbprint in ALLOWED_THUMBPRINTS:
        return {"thumbprint": thumbprint, "commonName": common_name}

    if common_name and common_name in ALLOWED_COMMON_NAMES:
        return {"thumbprint": thumbprint, "commonName": common_name}

    abort(403, description="client certificate is not allowlisted")


@app.before_request
def require_known_client_certificate() -> None:
    if request.path == "/health":
        return

    certificate = load_forwarded_certificate()
    g.client_certificate = validate_certificate(certificate)


@contextmanager
def export_p12_to_temp_pem_files(p12_path: str, password: str) -> Iterator[tuple[str, str]]:
    with open(p12_path, "rb") as handle:
        private_key, certificate, _additional = pkcs12.load_key_and_certificates(
            handle.read(),
            password.encode("utf-8") if password else None,
        )

    if private_key is None or certificate is None:
        raise RuntimeError("PKCS12 archive did not contain both a private key and certificate")

    cert_file = tempfile.NamedTemporaryFile("wb", delete=False)
    key_file = tempfile.NamedTemporaryFile("wb", delete=False)

    try:
        cert_file.write(certificate.public_bytes(serialization.Encoding.PEM))
        key_file.write(
            private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption(),
            )
        )
        cert_file.close()
        key_file.close()
        yield cert_file.name, key_file.name
    finally:
        for path in (cert_file.name, key_file.name):
            try:
                os.remove(path)
            except FileNotFoundError:
                pass


@app.get("/cert-info")
def cert_info():
    return jsonify(g.client_certificate)


@app.get("/outbound-mtls")
def outbound_mtls():
    with export_p12_to_temp_pem_files(OUTBOUND_CERT_PATH, OUTBOUND_CERT_PASSWORD) as cert_pair:
        response = requests.get(
            REMOTE_API_URL,
            cert=cert_pair,
            timeout=10,
        )
        response.raise_for_status()

    return jsonify({"upstreamStatus": response.status_code, "clientCertificatePath": OUTBOUND_CERT_PATH})


@app.get("/health")
def health():
    return jsonify({"status": "ok"})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)

2. Configure environment variables

az webapp config appsettings set \
  --resource-group $RG \
  --name $APP_NAME \
  --settings \
    ALLOWED_CLIENT_CERT_COMMON_NAMES="api-client.contoso.com,partner-gateway.contoso.com" \
    ALLOWED_CLIENT_CERT_THUMBPRINTS="" \
    OUTBOUND_CLIENT_CERT_PATH="/var/ssl/private/<thumbprint>.p12" \
    OUTBOUND_CLIENT_CERT_PASSWORD="<certificate-password>" \
    REMOTE_API_URL="https://api.contoso.com/health" \
  --output json

3. Test the inbound certificate path

curl --include \
  --cert ./client.pem \
  --key ./client.key \
  "https://$APP_NAME.azurewebsites.net/cert-info"

Verification

  • An allowlisted client certificate returns 200 OK from /cert-info
  • A non-allowlisted certificate returns 403
  • /health works without certificate validation if that path is excluded in the platform configuration
  • /outbound-mtls succeeds only when the outbound PKCS#12 file exists and the remote service trusts it

Next Steps / Clean Up

  • Replace CN-only validation with issuer, SAN, and chain validation for production
  • Move outbound certificate passwords or related configuration into your approved secret-management workflow
  • Delete temporary diagnostics routes after validation if they expose more certificate metadata than your policy allows

Run It in the Portal

Portal view: Configuration > General settings > Incoming client certificates section

Configuration General settings blade for a Web App scrolled down to the Incoming client certificates section, with General settings (active), Stack settings, Health check, Path mappings, and Error pages tabs visible at the top and a Refresh command bar action beneath the tabs. Above the section the remaining transport controls are visible: Session affinity proxy (unchecked), HTTPS only (unchecked), Minimum Inbound TLS Version (1.2), SCM Minimum Inbound TLS Version (1.2), Minimum Inbound TLS Cipher Suite (TLS_RSA_WITH_AES_128_CBC_SHA, Default) with a Change link, and End-to-end TLS encryption (unchecked). A Debugging section shows Remote debugging (unchecked). The Incoming client certificates section presents Client certificate mode as four radio options — Required (with description "All requests must be authenticated through a client certificate."), Optional (with description "Clients will be prompted for a certificate, if no certificate is provided fallback to SSO or other means of authentication. Unauthenticated requests will be blocked."), Optional Interactive User (with description "Clients will not be prompted for a certificate by default. Unless the request can be authenticated through other means (like SSO), it will be blocked."), and Ignore (selected, with description "No client authentication is required. Unauthenticated requests will not be blocked."). Apply and Discard buttons are at the bottom.

The Configuration > General settings blade scrolled to Incoming client certificates is the Portal surface that shows the four platform modes this recipe depends on before Flask can consume X-ARR-ClientCert. The visible Client certificate mode radios — Required, Optional, Optional Interactive User, and Ignore — are the settings the app-side certificate parsing logic must align with, and the screenshot clearly shows Ignore as the current default state. Use this blade as the verification point that the intended client-certificate mode is set before testing the Python middleware against incoming certificate-bearing requests.

See Also

Sources