Skip to content

X.509 Certificate Authentication Migration

Migrate IoT device authentication from SAS symmetric keys to X.509 certificates with certificate chain setup, DPS enrollment, rotation strategy, and HSM integration.

Finding: CSA-0025 (HIGH, BREAKING) | Ballot: AQ-0014 (approved)


Overview

X.509 certificate authentication replaces SAS symmetric keys with a public key infrastructure (PKI) model. Each device holds a private key (ideally in a hardware security module) and a certificate signed by a Certificate Authority registered with IoT Hub or DPS. The private key never leaves the device, eliminating the credential exposure vectors that make SAS keys a compliance failure.

This guide covers the full lifecycle: certificate hierarchy design, DPS enrollment group configuration, device provisioning flow, certificate rotation, and HSM integration.


Certificate types

Self-signed certificates

Suitable for development and testing only. Each device generates its own key pair and self-signs a certificate. The certificate thumbprint is registered directly in IoT Hub.

Limitations:

  • No chain of trust
  • Each device certificate must be individually registered
  • No centralized revocation
  • Not acceptable for FedRAMP High or IL5

CA-signed certificates (production)

A Certificate Authority issues device certificates. IoT Hub or DPS validates the certificate chain against a registered root or intermediate CA. This is the only acceptable model for production and compliance environments.

Advantages:

  • Centralized trust anchor (root CA)
  • Scalable (add devices without per-device IoT Hub registration)
  • Revocable (CRL or OCSP)
  • Compliant with NIST 800-53 IA-5(2) (PKI-based authentication)

Certificate chain setup

Architecture

┌──────────────────────────────────────────────────────────┐
│                    Root CA Certificate                    │
│              (offline, air-gapped, HSM-stored)           │
│              Lifetime: 10-20 years                       │
│              CN: "CSA IoT Root CA"                       │
└──────────────────────┬───────────────────────────────────┘
                       │ Signs
┌──────────────────────▼───────────────────────────────────┐
│               Intermediate CA Certificate                 │
│              (online, Key Vault-stored)                   │
│              Lifetime: 2-5 years                         │
│              CN: "CSA IoT Intermediate CA 01"            │
└──────────────────────┬───────────────────────────────────┘
                       │ Signs
┌──────────────────────▼───────────────────────────────────┐
│                  Leaf Certificate (Device)                │
│              (on-device, HSM/TPM-stored)                 │
│              Lifetime: 90-365 days                       │
│              CN: "{device-id}"                           │
└──────────────────────────────────────────────────────────┘

Design decisions

Decision Recommendation Rationale
Root CA storage Offline HSM (Azure Managed HSM or physical) Root compromise = total PKI compromise
Intermediate CA storage Azure Key Vault (Premium SKU with HSM) Online signing with hardware protection
Leaf cert lifetime 90 days (default), 30 days (high-security) Balance rotation frequency with device connectivity
Key algorithm RSA 2048 (broad device support) or ECC P-256 (resource-constrained devices) FIPS 140-2 approved algorithms
CRL distribution Azure Blob Storage with CDN Low-latency revocation checking
Cert renewal trigger 30 days before expiry Allows for offline device reconnection window

Generate root CA certificate

# Generate root CA private key (store offline after generation)
openssl genrsa -aes256 -out root-ca.key 4096

# Generate root CA certificate (20-year lifetime)
openssl req -new -x509 -days 7300 -key root-ca.key \
  -out root-ca.pem \
  -subj "/CN=CSA IoT Root CA/O=CSA-in-a-Box/C=US" \
  -extensions v3_ca \
  -config <(cat <<EOF
[v3_ca]
basicConstraints = critical, CA:TRUE
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuer:always
EOF
)

# Verify root CA certificate
openssl x509 -in root-ca.pem -text -noout

Generate intermediate CA certificate

# Generate intermediate CA key (will be imported to Key Vault)
openssl genrsa -aes256 -out intermediate-ca.key 4096

# Generate CSR for intermediate CA
openssl req -new -key intermediate-ca.key \
  -out intermediate-ca.csr \
  -subj "/CN=CSA IoT Intermediate CA 01/O=CSA-in-a-Box/C=US"

# Sign intermediate CA with root CA (5-year lifetime)
openssl x509 -req -days 1825 \
  -in intermediate-ca.csr \
  -CA root-ca.pem -CAkey root-ca.key \
  -CAcreateserial \
  -out intermediate-ca.pem \
  -extensions v3_intermediate_ca \
  -extfile <(cat <<EOF
[v3_intermediate_ca]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuer:always
EOF
)

# Create the certificate chain file
cat intermediate-ca.pem root-ca.pem > chain.pem

Generate leaf (device) certificates

# Generate device private key
openssl genrsa -out device-sensor-001.key 2048

# Generate CSR with device ID as CN
openssl req -new -key device-sensor-001.key \
  -out device-sensor-001.csr \
  -subj "/CN=sensor-001/O=CSA-in-a-Box/C=US"

# Sign with intermediate CA (90-day lifetime)
openssl x509 -req -days 90 \
  -in device-sensor-001.csr \
  -CA intermediate-ca.pem -CAkey intermediate-ca.key \
  -CAcreateserial \
  -out device-sensor-001.pem \
  -extensions v3_device \
  -extfile <(cat <<EOF
[v3_device]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuer:always
EOF
)

# Create full chain for device (leaf + intermediate)
cat device-sensor-001.pem intermediate-ca.pem > device-sensor-001-fullchain.pem

DPS enrollment group with X.509 attestation

Upload and verify CA certificate

# Upload intermediate CA to DPS
az iot dps certificate create \
  --dps-name "$DPS_NAME" \
  --resource-group "$RG" \
  --certificate-name "csa-iot-intermediate-ca-01" \
  --path intermediate-ca.pem \
  --verified true  # Use automatic verification (2023+ API)

# Alternative: manual proof-of-possession verification
# 1. Get verification code
ETAG=$(az iot dps certificate show \
  --dps-name "$DPS_NAME" -g "$RG" \
  --certificate-name "csa-iot-intermediate-ca-01" \
  --query etag -o tsv)

VERIFICATION_CODE=$(az iot dps certificate generate-verification-code \
  --dps-name "$DPS_NAME" -g "$RG" \
  --certificate-name "csa-iot-intermediate-ca-01" \
  --etag "$ETAG" \
  --query properties.verificationCode -o tsv)

# 2. Generate verification certificate
openssl req -new -key intermediate-ca.key \
  -out verification.csr \
  -subj "/CN=$VERIFICATION_CODE"
openssl x509 -req -days 1 -in verification.csr \
  -CA intermediate-ca.pem -CAkey intermediate-ca.key \
  -CAcreateserial -out verification.pem

# 3. Upload verification certificate
az iot dps certificate verify \
  --dps-name "$DPS_NAME" -g "$RG" \
  --certificate-name "csa-iot-intermediate-ca-01" \
  --path verification.pem \
  --etag "$NEW_ETAG"

Create X.509 enrollment group

# Create enrollment group using the intermediate CA
az iot dps enrollment-group create \
  --dps-name "$DPS_NAME" \
  --resource-group "$RG" \
  --enrollment-id "csa-iot-fleet-x509" \
  --certificate-path intermediate-ca.pem \
  --provisioning-status enabled \
  --allocation-policy hashed \
  --iot-hubs "$IOT_HUB_HOSTNAME" \
  --initial-twin-properties '{"tags":{"authType":"x509","enrollmentGroup":"csa-iot-fleet-x509"}}'

Device provisioning flow

sequenceDiagram
    participant Device
    participant DPS as Device Provisioning Service
    participant CA as Certificate Authority
    participant Hub as IoT Hub

    Note over Device: Device has leaf cert + private key

    Device->>DPS: TLS handshake with X.509 client cert
    DPS->>DPS: Validate cert chain against enrollment group CA
    DPS->>DPS: Check enrollment group is enabled
    DPS->>DPS: Apply allocation policy

    alt Certificate chain valid
        DPS->>Hub: Register device identity
        Hub-->>DPS: Device ID + assigned hub
        DPS-->>Device: Registration result (hub hostname, device ID)
        Device->>Hub: Connect with X.509 cert
        Hub->>Hub: Validate cert chain against registered CAs
        Hub-->>Device: Connection established
    else Certificate chain invalid
        DPS-->>Device: 401 Unauthorized
        Note over Device: Log error, check certificate
    end

    Note over Device: Certificate approaching expiry

    Device->>CA: Request new leaf certificate (or auto-renew via EST)
    CA-->>Device: New leaf certificate
    Device->>DPS: Re-provision with new certificate
    DPS-->>Device: Updated registration

Certificate rotation strategy

Rolling rotation updates device certificates gradually over time, avoiding fleet-wide outages.

Timeline (90-day certificate lifetime):
Day 0:  Certificate issued
Day 60: Renewal window opens (30 days before expiry)
Day 61: Device requests new certificate
Day 62: New certificate installed alongside old
Day 63: Device re-provisions with new certificate
Day 90: Old certificate expires (already replaced)

Implementation:

import datetime
from pathlib import Path

def check_and_renew_certificate(device_id, cert_path, key_path, dps_scope):
    """Check certificate expiry and renew if within 30 days."""
    cert = load_x509_certificate(cert_path)
    days_remaining = (cert.not_valid_after - datetime.datetime.utcnow()).days

    if days_remaining <= 30:
        log.info(f"Certificate expires in {days_remaining} days. Renewing.")

        # Request new certificate from CA/EST server
        new_cert, new_key = request_certificate_from_est(
            est_server="https://est.csa-iot.internal",
            device_id=device_id,
            existing_cert=cert_path,
            existing_key=key_path,
        )

        # Save new certificate (keep old as backup)
        backup_path = Path(cert_path).with_suffix(".old.pem")
        Path(cert_path).rename(backup_path)
        save_certificate(new_cert, cert_path)
        save_private_key(new_key, key_path)

        # Re-provision through DPS with new cert
        reprovision_device(device_id, cert_path, key_path, dps_scope)

        log.info("Certificate renewed and device re-provisioned.")
        return True

    log.info(f"Certificate valid for {days_remaining} more days.")
    return False

Emergency rotation

If a certificate is compromised, immediate revocation is required.

# Revoke a specific device certificate
# 1. Add to CRL
openssl ca -revoke device-compromised.pem -keyfile intermediate-ca.key -cert intermediate-ca.pem

# 2. Generate updated CRL
openssl ca -gencrl -keyfile intermediate-ca.key -cert intermediate-ca.pem -out crl.pem

# 3. Upload CRL to distribution point
az storage blob upload \
  --account-name "$STORAGE_ACCOUNT" \
  --container-name crl \
  --file crl.pem \
  --name crl.pem \
  --overwrite

# 4. Disable the device identity in IoT Hub
az iot hub device-identity update \
  --hub-name "$IOT_HUB" \
  --device-id "device-compromised" \
  --set status=disabled

HSM integration for high-security deployments

Why HSM

For DoD IL5, FIPS 140-2 Level 2 (minimum) or Level 3 cryptographic modules are required. Hardware Security Modules provide:

  • Private key generation inside tamper-resistant hardware
  • Private key never exported or readable
  • Cryptographic operations performed inside the HSM
  • Physical tamper evidence (Level 3) or active tamper response (Level 4)

Device-level HSM options

HSM/TPM option FIPS level Device type Notes
TPM 2.0 140-2 Level 1-2 Industrial PCs, gateways Built into many commercial devices
ATECC608B (Microchip) 140-2 Level 2 Embedded, MCU-based Low-cost, I2C interface
OPTIGA Trust M (Infineon) 140-2 Level 2 Embedded, MCU-based Pre-provisioned X.509 support
SE050 (NXP) 140-2 Level 3 High-security embedded EdgeLock platform
Azure Sphere (MediaTek MT3620) Custom IoT devices Integrated HSM + OS + cloud security

Provisioning with TPM 2.0

# Device provisioning with TPM-stored X.509 certificate
from azure.iot.device import ProvisioningDeviceClient, X509
from tpm2_pytss import ESAPI

def provision_with_tpm(device_id, dps_host, id_scope):
    """Provision device using X.509 certificate stored in TPM."""
    # Certificate is stored in TPM NV index
    # Private key operations happen inside TPM
    with ESAPI() as ectx:
        # Read certificate from TPM NV storage
        cert_pem = read_cert_from_tpm_nv(ectx, nv_index=0x01C00002)

        # Create X509 object pointing to TPM-backed key
        # The SDK will use the TPM for TLS handshake signing
        x509 = X509(
            cert_file=cert_pem,  # PEM certificate
            key_file=None,        # Key is in TPM, not filesystem
            pass_phrase=None,
        )

        # For TPM-backed keys, use the custom HSM interface
        client = ProvisioningDeviceClient.create_from_x509_certificate(
            provisioning_host=dps_host,
            registration_id=device_id,
            id_scope=id_scope,
            x509=x509,
        )

        result = client.register()
        return result.registration_state

Worked example: Provision a fleet of 1,000 devices with X.509

Step 1: Generate certificates in batch

#!/bin/bash
# generate-fleet-certs.sh
# Generate leaf certificates for a fleet of devices

FLEET_SIZE=1000
CERT_DIR="./fleet-certs"
INTERMEDIATE_CA="intermediate-ca.pem"
INTERMEDIATE_KEY="intermediate-ca.key"

mkdir -p "$CERT_DIR"

for i in $(seq -w 1 $FLEET_SIZE); do
    DEVICE_ID="sensor-${i}"
    echo "Generating certificate for $DEVICE_ID..."

    # Generate key
    openssl genrsa -out "$CERT_DIR/$DEVICE_ID.key" 2048 2>/dev/null

    # Generate CSR
    openssl req -new -key "$CERT_DIR/$DEVICE_ID.key" \
      -out "$CERT_DIR/$DEVICE_ID.csr" \
      -subj "/CN=$DEVICE_ID/O=CSA-in-a-Box/C=US" 2>/dev/null

    # Sign with intermediate CA (90-day lifetime)
    openssl x509 -req -days 90 \
      -in "$CERT_DIR/$DEVICE_ID.csr" \
      -CA "$INTERMEDIATE_CA" -CAkey "$INTERMEDIATE_KEY" \
      -CAcreateserial \
      -out "$CERT_DIR/$DEVICE_ID.pem" 2>/dev/null

    # Clean up CSR
    rm "$CERT_DIR/$DEVICE_ID.csr"
done

echo "Generated $FLEET_SIZE device certificates in $CERT_DIR/"

Step 2: Deploy certificates to devices

# deploy-certs.py
# Deploy certificates to devices via secure channel (e.g., SSH, Azure IoT Edge)

import paramiko
import os
from concurrent.futures import ThreadPoolExecutor

CERT_DIR = "./fleet-certs"
DEVICES_FILE = "device-inventory.csv"  # device_id,ip_address,ssh_user

def deploy_cert_to_device(device_id, ip_address, ssh_user):
    """Deploy certificate and key to a single device."""
    cert_file = os.path.join(CERT_DIR, f"{device_id}.pem")
    key_file = os.path.join(CERT_DIR, f"{device_id}.key")

    ssh = paramiko.SSHClient()
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    ssh.connect(ip_address, username=ssh_user, key_filename="~/.ssh/deploy_key")

    sftp = ssh.open_sftp()
    sftp.put(cert_file, f"/etc/iot-certs/{device_id}.pem")
    sftp.put(key_file, f"/etc/iot-certs/{device_id}.key")

    # Set restrictive permissions
    ssh.exec_command(f"chmod 644 /etc/iot-certs/{device_id}.pem")
    ssh.exec_command(f"chmod 600 /etc/iot-certs/{device_id}.key")

    # Restart IoT agent to use new certificate
    ssh.exec_command("systemctl restart iot-device-agent")

    sftp.close()
    ssh.close()
    print(f"Deployed certificate to {device_id} ({ip_address})")

# Deploy in parallel (max 50 concurrent connections)
with ThreadPoolExecutor(max_workers=50) as executor:
    with open(DEVICES_FILE) as f:
        for line in f:
            device_id, ip_address, ssh_user = line.strip().split(",")
            executor.submit(deploy_cert_to_device, device_id, ip_address, ssh_user)

Step 3: Verify provisioning

# Verify devices are provisioning through DPS with X.509
az iot dps enrollment-group registration list \
  --dps-name "$DPS_NAME" \
  --resource-group "$RG" \
  --enrollment-id "csa-iot-fleet-x509" \
  --query "[].{deviceId:deviceId, status:status, lastUpdated:lastUpdatedDateTimeUtc}" \
  -o table

# Check IoT Hub for registered devices
az iot hub device-identity list \
  --hub-name "$IOT_HUB" \
  --query "[?authentication.type=='certificateAuthority'].{deviceId:deviceId, status:status}" \
  -o table | head -20

Device client code (Python SDK)

"""
IoT device client with X.509 authentication.
Replaces SAS-based device client for CSA-0025 compliance.
"""

import asyncio
import datetime
import json
import logging
from pathlib import Path

from azure.iot.device.aio import IoTHubDeviceClient, ProvisioningDeviceClient
from azure.iot.device import X509, Message

log = logging.getLogger(__name__)

# Configuration
DPS_HOST = "global.azure-devices-provisioning.net"
DPS_ID_SCOPE = "0ne00XXXXXX"
DEVICE_ID = "sensor-floor3-unit47"
CERT_PATH = "/etc/iot-certs/sensor-floor3-unit47.pem"
KEY_PATH = "/etc/iot-certs/sensor-floor3-unit47.key"
CERT_RENEWAL_DAYS = 30


async def provision_device():
    """Provision device through DPS using X.509 certificate."""
    x509 = X509(cert_file=CERT_PATH, key_file=KEY_PATH)

    provisioning_client = ProvisioningDeviceClient.create_from_x509_certificate(
        provisioning_host=DPS_HOST,
        registration_id=DEVICE_ID,
        id_scope=DPS_ID_SCOPE,
        x509=x509,
    )

    result = await provisioning_client.register()
    log.info(f"Provisioned to hub: {result.registration_state.assigned_hub}")
    return result.registration_state


async def connect_and_send(hub_hostname):
    """Connect to IoT Hub and send telemetry using X.509 certificate."""
    x509 = X509(cert_file=CERT_PATH, key_file=KEY_PATH)

    client = IoTHubDeviceClient.create_from_x509_certificate(
        hostname=hub_hostname,
        device_id=DEVICE_ID,
        x509=x509,
    )

    await client.connect()
    log.info("Connected to IoT Hub with X.509 certificate.")

    try:
        while True:
            # Check certificate expiry
            cert_expiry = get_cert_expiry(CERT_PATH)
            days_remaining = (cert_expiry - datetime.datetime.utcnow()).days

            if days_remaining <= CERT_RENEWAL_DAYS:
                log.warning(f"Certificate expires in {days_remaining} days.")

            # Send telemetry
            telemetry = {
                "temperature": 22.5,
                "humidity": 45.2,
                "timestamp": datetime.datetime.utcnow().isoformat(),
                "certDaysRemaining": days_remaining,
            }
            message = Message(json.dumps(telemetry))
            message.content_type = "application/json"
            message.content_encoding = "utf-8"
            await client.send_message(message)

            await asyncio.sleep(60)
    finally:
        await client.disconnect()


def get_cert_expiry(cert_path):
    """Read certificate expiry date."""
    from cryptography import x509 as crypto_x509
    cert_data = Path(cert_path).read_bytes()
    cert = crypto_x509.load_pem_x509_certificate(cert_data)
    return cert.not_valid_after


async def main():
    registration = await provision_device()
    await connect_and_send(registration.assigned_hub)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

Troubleshooting

| Symptom | Likely cause | Resolution | | ------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ | ------------ | | 401 Unauthorized during DPS registration | Certificate chain does not match enrollment group CA | Verify openssl verify -CAfile chain.pem device.pem | | 403 Forbidden after DPS registration | IoT Hub does not have the CA registered | Upload root/intermediate CA to IoT Hub CA certificates | | TLS handshake failure | Certificate key mismatch | Verify key matches cert: openssl x509 -noout -modulus -in cert.pem | openssl md5vsopenssl rsa -noout -modulus -in key.pem | openssl md5 | | Device connects but cannot send telemetry | Device ID in IoT Hub does not match certificate CN | Ensure registration ID matches CN in certificate | | Certificate expired | Renewal process not triggered | Check renewal monitoring alerts; see Monitoring |


Last updated: 2026-04-30 Maintainers: CSA-in-a-Box core team Related: Feature Mapping | DPS Migration | Tutorial: Device Fleet Migration