Skip to content

Authentication Pattern Mapping — SAS to Entra

Every SAS-based authentication pattern mapped to its Entra ID equivalent with Bicep before/after code snippets.

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


Pattern overview

This document maps each SAS-based authentication pattern used in IoT Hub and DPS to its Entra equivalent. Each pattern includes the SAS approach, the Entra replacement, migration complexity, and Bicep code showing the before and after states.

SAS pattern Entra replacement Complexity Guide
SAS device symmetric key X.509 device certificate Medium X.509 Migration
SAS connection string (service) Managed Identity + Azure RBAC Low Managed Identity Migration
SAS IoT Hub shared access policies Entra app registrations + RBAC roles Low Managed Identity Migration
DPS SAS enrollment group DPS X.509 enrollment group Medium DPS Migration
SAS token generation (device) Certificate thumbprint authentication Medium X.509 Migration
SAS token generation (service) Managed Identity token acquisition Low Managed Identity Migration
Connection retry with SAS Connection retry with certificate renewal Medium X.509 Migration
Device twin auth via SAS Entra-scoped device twin access Low This document

Pattern 1: SAS device symmetric key to X.509 device certificate

Before (SAS)

Each device is provisioned with a symmetric key from IoT Hub's device identity registry. The device generates a SAS token from this key and presents it during MQTT/AMQP connection.

# Device-side SAS authentication (BEFORE)
from azure.iot.device import IoTHubDeviceClient

# Connection string contains the device symmetric key
conn_str = (
    "HostName=hub-prod.azure-devices.net;"
    "DeviceId=sensor-floor3-unit47;"
    "SharedAccessKey=<device-symmetric-key>"
)

client = IoTHubDeviceClient.create_from_connection_string(conn_str)
client.connect()

After (X.509)

Each device holds a private key (ideally in an HSM/TPM) and an X.509 certificate signed by a CA registered with IoT Hub or DPS. The device presents the certificate during TLS handshake -- the private key never leaves the device.

# Device-side X.509 authentication (AFTER)
from azure.iot.device import IoTHubDeviceClient, X509

x509 = X509(
    cert_file="/certs/device-sensor-floor3-unit47.pem",
    key_file="/certs/device-sensor-floor3-unit47.key",
    pass_phrase="optional-passphrase",
)

client = IoTHubDeviceClient.create_from_x509_certificate(
    hostname="hub-prod.azure-devices.net",
    device_id="sensor-floor3-unit47",
    x509=x509,
)
client.connect()

Bicep change

// BEFORE: IoT Hub allows SAS authentication
resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = {
  name: iotHubName
  location: location
  sku: {
    name: 'S1'
    capacity: 1
  }
  properties: {
    disableLocalAuth: false  // SAS keys allowed
    // Default shared access policies are created automatically
  }
}

// AFTER: IoT Hub requires Entra authentication only
resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = {
  name: iotHubName
  location: location
  sku: {
    name: 'S1'
    capacity: 1
  }
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    disableLocalAuth: true   // SAS keys BLOCKED
    authorizationPolicies: [] // No SAS policies
  }
}

Migration notes

  • Certificate infrastructure must be established before this migration
  • Device firmware/software update required
  • HSM/TPM recommended for production; required for IL5
  • See X.509 Migration Guide for full procedure

Pattern 2: SAS connection string (service) to Managed Identity

Before (SAS)

Backend services authenticate to IoT Hub using a connection string containing a shared access key. The connection string is typically stored in Key Vault, app settings, or environment variables.

# Service-side SAS authentication (BEFORE)
from azure.iot.hub import IoTHubRegistryManager

conn_str = os.environ["IOTHUB_CONNECTION_STRING"]
# "HostName=hub-prod.azure-devices.net;
#  SharedAccessKeyName=iothubowner;
#  SharedAccessKey=dGhpcyBpcyBhIGZha2Uga2V5..."

registry = IoTHubRegistryManager(conn_str)
device_twin = registry.get_twin("sensor-floor3-unit47")

After (Managed Identity)

Backend services authenticate using their managed identity. No credentials are stored, managed, or rotated.

# Service-side Managed Identity authentication (AFTER)
from azure.identity import DefaultAzureCredential
from azure.iot.hub import IoTHubRegistryManager

credential = DefaultAzureCredential()
registry = IoTHubRegistryManager.from_token_credential(
    url=f"https://{iot_hub_name}.azure-devices.net",
    token_credential=credential,
)
device_twin = registry.get_twin("sensor-floor3-unit47")

Bicep change

// BEFORE: Azure Function using SAS connection string
resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp'
  properties: {
    siteConfig: {
      appSettings: [
        {
          name: 'IOTHUB_CONNECTION_STRING'
          value: '@Microsoft.KeyVault(VaultName=${kvName};SecretName=iothub-conn-str)'
        }
      ]
    }
  }
}

// AFTER: Azure Function using Managed Identity
resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    siteConfig: {
      appSettings: [
        {
          name: 'IOTHUB_HOSTNAME'
          value: '${iotHubName}.azure-devices.net'
        }
        // No connection string. No secret reference.
      ]
    }
  }
}

// RBAC role assignment: Function -> IoT Hub Data Contributor
resource functionIoTHubRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(functionApp.id, iotHub.id, iotHubDataContributorRoleId)
  scope: iotHub
  properties: {
    principalId: functionApp.identity.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4fc6c259-987e-4a07-842e-c321cc9d413f' // IoT Hub Data Contributor
    )
    principalType: 'ServicePrincipal'
  }
}

Migration notes

  • Managed identity must be enabled on the compute resource
  • RBAC role must be assigned before SAS connection string is removed
  • Allow up to 30 minutes for RBAC propagation (typically < 60 seconds)
  • See Managed Identity Migration Guide for full procedure

Pattern 3: SAS IoT Hub shared access policies to Entra RBAC roles

Before (SAS)

IoT Hub provides five built-in shared access policies. Each policy grants broad, coarse-grained access.

SAS policy Permissions
iothubowner All operations (full control)
service Service-connect, registry read/write
device Device connect
registryRead Registry read
registryReadWrite Registry read + write

After (Entra RBAC)

Entra provides granular, least-privilege roles.

Entra RBAC role Role ID Permissions
IoT Hub Data Contributor 4fc6c259-987e-4a07-842e-c321cc9d413f Full data plane (registry, twin, direct methods, C2D)
IoT Hub Data Reader b447c946-2db7-41ec-983d-d8bf3b1c77e3 Read device registry, read twins
IoT Hub Registry Contributor 4ea46cd5-c1b2-4a8e-910b-273211f9ce47 Create/update/delete device identities
IoT Hub Twin Contributor 494bdba2-168f-4f31-a0a1-191d2f7c028c Read/write device twins
Contributor (built-in) Control plane (manage IoT Hub resource itself)
Reader (built-in) Control plane read (view IoT Hub configuration)

Mapping

SAS policy Entra RBAC role(s) Notes
iothubowner IoT Hub Data Contributor + Contributor Split data plane and control plane
service IoT Hub Data Contributor Or IoT Hub Data Reader for read-only services
device N/A (devices use X.509) Device authentication via certificate, not RBAC
registryRead IoT Hub Data Reader Least-privilege read access
registryReadWrite IoT Hub Registry Contributor Device identity management only

Bicep change

// BEFORE: Relying on built-in SAS policies (implicit)
resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = {
  name: iotHubName
  properties: {
    disableLocalAuth: false
    // Built-in policies: iothubowner, service, device,
    // registryRead, registryReadWrite created automatically
  }
}

// AFTER: Explicit RBAC role assignments
resource iotHub 'Microsoft.Devices/IotHubs@2023-06-30' = {
  name: iotHubName
  properties: {
    disableLocalAuth: true
    authorizationPolicies: []
  }
}

// Each service gets exactly the role it needs
resource backendApiRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(backendApi.id, iotHub.id, 'IoTHubDataContributor')
  scope: iotHub
  properties: {
    principalId: backendApi.identity.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4fc6c259-987e-4a07-842e-c321cc9d413f'
    )
    principalType: 'ServicePrincipal'
  }
}

resource monitoringRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(monitoring.id, iotHub.id, 'IoTHubDataReader')
  scope: iotHub
  properties: {
    principalId: monitoring.identity.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      'b447c946-2db7-41ec-983d-d8bf3b1c77e3'
    )
    principalType: 'ServicePrincipal'
  }
}

Pattern 4: DPS SAS enrollment group to X.509 enrollment group

Before (SAS)

DPS uses a symmetric key enrollment group. Devices derive their individual keys from the group key using HMAC-SHA256.

# DPS symmetric key provisioning (BEFORE)
from azure.iot.device import ProvisioningDeviceClient
import hmac, hashlib, base64

# Derive device key from group key
group_key = base64.b64decode(os.environ["DPS_GROUP_KEY"])
device_key = base64.b64encode(
    hmac.new(group_key, device_id.encode(), hashlib.sha256).digest()
).decode()

client = ProvisioningDeviceClient.create_from_symmetric_key(
    provisioning_host="global.azure-devices-provisioning.net",
    registration_id=device_id,
    id_scope=dps_id_scope,
    symmetric_key=device_key,
)
result = client.register()

After (X.509)

DPS uses an X.509 enrollment group. Devices present their leaf certificate signed by the enrollment group's CA.

# DPS X.509 provisioning (AFTER)
from azure.iot.device import ProvisioningDeviceClient, X509

x509 = X509(
    cert_file=f"/certs/{device_id}.pem",
    key_file=f"/certs/{device_id}.key",
)

client = ProvisioningDeviceClient.create_from_x509_certificate(
    provisioning_host="global.azure-devices-provisioning.net",
    registration_id=device_id,
    id_scope=dps_id_scope,
    x509=x509,
)
result = client.register()

Migration notes

  • Root or intermediate CA certificate must be uploaded and verified in DPS
  • Each device needs a unique leaf certificate signed by the CA
  • See DPS Migration Guide for full procedure

Pattern 5: SAS token generation to certificate thumbprint authentication

Before (SAS)

The device SDK generates a SAS token from the symmetric key. The token has a configurable expiry (typically 1-24 hours) and must be regenerated before expiry.

# SAS token generation (SDK handles internally)
# Token format:
# SharedAccessSignature sig={signature}&se={expiry}&skn={policyName}&sr={resourceURI}

After (X.509)

The device presents its X.509 certificate during TLS handshake. IoT Hub validates the certificate chain against registered CAs. No token generation is needed -- authentication is at the transport layer.

TLS Handshake:
  Client Hello
  Server Hello + Server Certificate
  Certificate Request
  Client Certificate (device leaf cert)     ◄── Authentication happens here
  Client Key Exchange
  Certificate Verify (proves private key)   ◄── No secret transmitted
  Finished

Key difference

  • SAS: Authentication at the application layer (token in MQTT CONNECT)
  • X.509: Authentication at the transport layer (certificate in TLS handshake)
  • X.509 private key never leaves the device -- only a proof of possession is transmitted

Pattern 6: Connection retry with SAS to connection retry with certificate renewal

Before (SAS)

When a SAS token expires, the device must generate a new token and reconnect. If the underlying key has been rotated (e.g., during a planned key rotation), the device cannot generate a valid token and fails permanently until reconfigured.

# SAS connection retry (BEFORE)
def connect_with_retry(conn_str, max_retries=5):
    for attempt in range(max_retries):
        try:
            client = IoTHubDeviceClient.create_from_connection_string(conn_str)
            client.connect()
            return client
        except Exception as e:
            if "401" in str(e):
                # Key may have been rotated -- cannot recover automatically
                log.error("Authentication failed. Key may be rotated.")
                raise  # Permanent failure
            time.sleep(min(2 ** attempt, 60))
    raise ConnectionError("Max retries exceeded")

After (X.509)

When a certificate approaches expiry, the device can re-provision through DPS to obtain updated registration (and potentially a new certificate if using an automated certificate issuance pipeline). The private key remains on the device.

# X.509 connection retry with certificate awareness (AFTER)
import datetime

def connect_with_cert_awareness(hostname, device_id, cert_path, key_path):
    # Check certificate expiry
    cert = load_certificate(cert_path)
    days_until_expiry = (cert.not_valid_after - datetime.datetime.utcnow()).days

    if days_until_expiry < 30:
        log.warning(f"Certificate expires in {days_until_expiry} days. "
                    "Triggering re-provisioning.")
        reprovision_via_dps(device_id, cert_path, key_path)

    x509 = X509(cert_file=cert_path, key_file=key_path)
    client = IoTHubDeviceClient.create_from_x509_certificate(
        hostname=hostname,
        device_id=device_id,
        x509=x509,
    )

    def on_connection_state_change():
        if not client.connected:
            log.info("Disconnected. Reconnecting with existing certificate.")
            client.connect()  # SDK handles TLS handshake with same cert

    client.on_connection_state_change = on_connection_state_change
    client.connect()
    return client

Pattern 7: Device twin auth via SAS to Entra-scoped device twin access

Before (SAS)

Service applications access device twins using a connection string with service or iothubowner policy. This grants access to all device twins with no per-device scoping.

# Service reads ALL device twins with SAS (BEFORE)
registry = IoTHubRegistryManager(conn_str)
# This single connection string grants access to every device twin
twin_a = registry.get_twin("device-a")
twin_b = registry.get_twin("device-b")
twin_z = registry.get_twin("device-z")
# No differentiation in access scope

After (Entra RBAC)

Service applications access device twins using managed identity. Access is scoped by RBAC role assignment. Different services can have different levels of access.

# Service reads device twins with Managed Identity (AFTER)
credential = DefaultAzureCredential()
registry = IoTHubRegistryManager.from_token_credential(
    url=f"https://{iot_hub_name}.azure-devices.net",
    token_credential=credential,
)

# Access is governed by the managed identity's RBAC role:
# - IoT Hub Data Reader: read twins only
# - IoT Hub Twin Contributor: read + write twins
# - IoT Hub Data Contributor: full data plane access
twin = registry.get_twin("sensor-floor3-unit47")

Bicep for scoped access

// Give the monitoring service read-only twin access
resource monitoringTwinRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(monitoringSvc.id, iotHub.id, 'IoTHubTwinContributor')
  scope: iotHub
  properties: {
    principalId: monitoringSvc.identity.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '494bdba2-168f-4f31-a0a1-191d2f7c028c' // IoT Hub Twin Contributor
    )
    principalType: 'ServicePrincipal'
  }
}

// Give the analytics service read-only access
resource analyticsReadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(analyticsSvc.id, iotHub.id, 'IoTHubDataReader')
  scope: iotHub
  properties: {
    principalId: analyticsSvc.identity.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      'b447c946-2db7-41ec-983d-d8bf3b1c77e3' // IoT Hub Data Reader
    )
    principalType: 'ServicePrincipal'
  }
}

Complete migration matrix

# SAS pattern Entra pattern Credential change Code change Bicep change Complexity
1 Device symmetric key X.509 certificate Key -> cert + private key SDK method change disableLocalAuth: true Medium
2 Service connection string Managed Identity Conn string -> no credential SDK method change Add identity + RBAC Low
3 Shared access policies RBAC roles Policy name -> role assignment None (transparent) authorizationPolicies: [] Low
4 DPS SAS enrollment DPS X.509 enrollment Group key -> CA certificate SDK method change DPS config update Medium
5 SAS token generation Certificate TLS auth Token -> TLS handshake SDK method change None Medium
6 SAS connection retry Certificate-aware retry Token refresh -> cert check Custom retry logic None Medium
7 Device twin (SAS) Device twin (RBAC) Conn string -> managed identity SDK method change Add RBAC assignment Low

Last updated: 2026-04-30 Maintainers: CSA-in-a-Box core team Related: Security Analysis | X.509 Migration | Managed Identity Migration