Provisioning IoT devices at scale is one of those problems that looks simple on a whiteboard and gets messy the moment you touch real hardware and real certificates. Azure IoT Hub Device Provisioning Service (DPS) exists precisely to solve this: it lets you register thousands of devices without hardcoding connection strings into each one, and it can use X.509 certificates as the trust mechanism instead of shared keys. This article walks through an ASP.NET Core web application that manages the entire certificate chain and device registration flow for Azure IoT Hub DPS, built by Damien Bod.
The setup uses chained X.509 certificates created entirely in .NET, backed by an EF Core database for persistence, and the CertificateManager NuGet package to abstract away some of the more painful parts of the .NET certificate APIs. If you have ever tried to work with X509Certificate2 directly, especially around exporting private keys or moving certificates between Windows and Linux, you already know why an abstraction layer helps here.
Why certificate-based provisioning
Azure IoT Hub supports two main attestation mechanisms for DPS: symmetric keys and X.509 certificates. Symmetric keys are easier to get started with, but every device effectively shares a secret that flows through your provisioning pipeline. X.509 certificates give you a proper chain of trust: a root certificate signs an intermediate (the enrollment group), and the enrollment group signs each individual device certificate.
This chained model matters in production because you can revoke an entire batch of devices by revoking the enrollment group certificate, without touching the root. It also means a compromised device certificate does not expose your root of trust. The trade-off is operational complexity: you now own certificate lifecycle management, and that is not a small commitment.
How the pieces fit together
The web application creates a new certificate using an ECDsa private key through the .NET Core certificate APIs. The certificate is exported into two PEM files, one for the public certificate and one for the private key, and both get persisted to a SQL database through EF Core. The public PEM is downloaded from the web app and uploaded to the certificates blade in the Azure IoT Hub DPS portal, where it becomes the root of trust for that DPS instance.
From that root certificate, the application creates an intermediate certificate for the DPS enrollment group, chained from the root. Every device that gets registered under that enrollment group receives its own device certificate, chained from the enrollment group certificate. When a physical device boots up and calls DPS using its certificate and private key, DPS validates the chain, provisions the device to a linked IoT Hub, and from that point the device authenticates directly against the hub using its own certificate.

This UI is intentionally minimal at this stage. It lets you create certificates, create enrollment groups, and register devices under those groups. Damien notes in the original write-up that adding, disabling, and deleting devices, along with proper authorization for different user roles, are follow-up work rather than finished features. Treat this as a working reference implementation, not a production-ready admin console.

Securing the application
The web application itself is secured using Microsoft.Identity.Web and requires an authenticated user for every request. This is standard practice for any internal tooling that can mint certificates and register IoT devices; you do not want this UI exposed without authentication, given what it is capable of doing to your device fleet.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddAuthentication(
OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(
builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddDistributedTokenCaches();
This registers the OpenID Connect code flow against Microsoft Entra ID, using the standard AzureAd configuration section. EnableTokenAcquisitionToCallDownstreamApi is included here even though this particular sample does not call a downstream API yet; it sets up token caching so that pattern is ready to extend later. Right now every authenticated user gets the same level of access, which the author explicitly calls out as something to fix once role-based flows are added.
Creating the DPS root certificate
The DpsCertificateProvider class creates the root self-signed certificate that anchors the whole chain. It calls NewRootCertificate from the CertificateManager package, which wraps the standard .NET certificate APIs and removes a fair amount of boilerplate around building a CertificateRequest and generating a self-signed cert. You could write this against the raw APIs directly, but the abstraction saves you from re-solving the same X509 plumbing every time.
public class DpsCertificateProvider
{
private readonly CreateCertificatesClientServerAuth _createCertsService;
private readonly ImportExportCertificate _iec;
private readonly DpsDbContext _dpsDbContext;
public DpsCertificateProvider(CreateCertificatesClientServerAuth ccs,
ImportExportCertificate importExportCertificate,
DpsDbContext dpsDbContext)
{
_createCertsService = ccs;
_iec = importExportCertificate;
_dpsDbContext = dpsDbContext;
}
public async Task<(string PublicPem, int Id)> CreateCertificateForDpsAsync(string certName)
{
var certificateDps = _createCertsService.NewRootCertificate(
new DistinguishedName { CommonName = certName, Country = "CH" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
3, certName);
var publicKeyPem = _iec.PemExportPublicKeyCertificate(certificateDps);
string pemPrivateKey = string.Empty;
using (ECDsa? ecdsa = certificateDps.GetECDsaPrivateKey())
{
pemPrivateKey = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{certName}-private.pem", pemPrivateKey);
}
var item = new DpsCertificate
{
Name = certName,
PemPrivateKey = pemPrivateKey,
PemPublicKey = publicKeyPem
};
_dpsDbContext.DpsCertificates.Add(item);
await _dpsDbContext.SaveChangesAsync();
return (publicKeyPem, item.Id);
}
public async Task<List<DpsCertificate>> GetDpsCertificatesAsync()
{
return await _dpsDbContext.DpsCertificates.ToListAsync();
}
public async Task<DpsCertificate?> GetDpsCertificateAsync(int id)
{
return await _dpsDbContext.DpsCertificates.FirstOrDefaultAsync(item => item.Id == id);
}
}
Notice the fifty-year validity period on ValidTo. That is a deliberate choice, not an oversight. The author wanted long-living certificates so devices deployed in the field do not stop working because a certificate expired while nobody was watching. It is a reasonable call for a first pass, but in a real fleet you would want this configurable per environment, and you would want a rotation plan in place before you ever ship a device with a certificate that outlives your company’s roadmap.
Once CreateCertificateForDpsAsync runs, the public PEM is available for download from the web UI. That file gets uploaded manually into the Azure IoT Hub DPS portal under the certificates blade, and DPS then trusts anything signed by that root going forward.

Creating the enrollment group
Azure IoT Hub gives you a few ways to register devices in DPS. This implementation uses an enrollment group backed by certificate attestation, which lets you register many devices under a single group policy rather than enrolling each device individually. The DpsEnrollmentGroupProvider class handles this, reading the root certificate back from the database and chaining a new intermediate certificate from it using NewIntermediateChainedCertificate.
public class DpsEnrollmentGroupProvider
{
private IConfiguration Configuration { get;set;}
private readonly ILogger<DpsEnrollmentGroupProvider> _logger;
private readonly DpsDbContext _dpsDbContext;
private readonly ImportExportCertificate _iec;
private readonly CreateCertificatesClientServerAuth _createCertsService;
private readonly ProvisioningServiceClient _provisioningServiceClient;
public DpsEnrollmentGroupProvider(IConfiguration config, ILoggerFactory loggerFactory,
ImportExportCertificate importExportCertificate,
CreateCertificatesClientServerAuth ccs,
DpsDbContext dpsDbContext)
{
Configuration = config;
_logger = loggerFactory.CreateLogger<DpsEnrollmentGroupProvider>();
_dpsDbContext = dpsDbContext;
_iec = importExportCertificate;
_createCertsService = ccs;
_provisioningServiceClient = ProvisioningServiceClient.CreateFromConnectionString(
Configuration.GetConnectionString("DpsConnection"));
}
public async Task<(string Name, int Id)> CreateDpsEnrollmentGroupAsync(
string enrollmentGroupName, string certificatePublicPemId)
{
var dpsCertificate = _dpsDbContext.DpsCertificates
.FirstOrDefault(t => t.Id == int.Parse(certificatePublicPemId));
var rootCertificate = X509Certificate2.CreateFromPem(
dpsCertificate!.PemPublicKey, dpsCertificate.PemPrivateKey);
// create an intermediate for each group
var certName = $"{enrollmentGroupName}";
var certDpsGroup = _createCertsService.NewIntermediateChainedCertificate(
new DistinguishedName { CommonName = certName, Country = "CH" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
2, certName, rootCertificate);
// get the public key certificate for the enrollment
var pemDpsGroupPublic = _iec.PemExportPublicKeyCertificate(certDpsGroup);
string pemDpsGroupPrivate = string.Empty;
using (ECDsa? ecdsa = certDpsGroup.GetECDsaPrivateKey())
{
pemDpsGroupPrivate = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{enrollmentGroupName}-private.pem", pemDpsGroupPrivate);
}
Attestation attestation = X509Attestation.CreateFromRootCertificates(pemDpsGroupPublic);
EnrollmentGroup enrollmentGroup = CreateEnrollmentGroup(enrollmentGroupName, attestation);
EnrollmentGroup enrollmentGroupResult = await _provisioningServiceClient
.CreateOrUpdateEnrollmentGroupAsync(enrollmentGroup);
DpsEnrollmentGroup newItem = await PersistData(enrollmentGroupName,
dpsCertificate, pemDpsGroupPublic, pemDpsGroupPrivate);
return (newItem.Name, newItem.Id);
}
private static EnrollmentGroup CreateEnrollmentGroup(string enrollmentGroupName, Attestation attestation)
{
return new EnrollmentGroup(enrollmentGroupName, attestation)
{
ProvisioningStatus = ProvisioningStatus.Enabled,
ReprovisionPolicy = new ReprovisionPolicy
{
MigrateDeviceData = false,
UpdateHubAssignment = true
},
Capabilities = new DeviceCapabilities
{
IotEdge = false
}
};
}
}
The CreateEnrollmentGroup helper sets ProvisioningStatus to Enabled, so any device registered under this group starts sending messages immediately once provisioned. That is fine for a demo, but think about whether you want that in production. Setting it to Disabled and flipping it to Enabled only when a device is first activated by an end customer, perhaps triggered by a MAC address or serial number check, gives you a cleaner way to control exactly when a device starts consuming hub resources.
ProvisioningServiceClient is the SDK type that actually talks to the DPS control plane, separate from the certificate generation logic. It is created once from a connection string in the constructor and reused across calls, which is the right pattern here since creating this client is not free.
Registering a device
DpsRegisterDeviceProvider is where an individual device gets its own chained certificate and actually calls DPS to register itself. The device certificate chains from the enrollment group certificate, mirroring the same pattern used to chain the group from the root.
public async Task<(int? DeviceId, string? ErrorMessage)> RegisterDeviceAsync(
string deviceCommonNameDevice, string dpsEnrollmentGroupId)
{
var scopeId = Configuration["ScopeId"];
var dpsEnrollmentGroup = _dpsDbContext.DpsEnrollmentGroups
.FirstOrDefault(t => t.Id == int.Parse(dpsEnrollmentGroupId));
var certDpsEnrollmentGroup = X509Certificate2.CreateFromPem(
dpsEnrollmentGroup!.PemPublicKey, dpsEnrollmentGroup.PemPrivateKey);
var newDevice = new DpsEnrollmentDevice
{
Password = GetEncodedRandomString(30),
Name = deviceCommonNameDevice.ToLower(),
DpsEnrollmentGroupId = dpsEnrollmentGroup.Id,
DpsEnrollmentGroup = dpsEnrollmentGroup
};
var certDevice = _createCertsService.NewDeviceChainedCertificate(
new DistinguishedName { CommonName = $"{newDevice.Name}" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
$"{newDevice.Name}", certDpsEnrollmentGroup);
var deviceInPfxBytes = _iec.ExportChainedCertificatePfx(newDevice.Password,
certDevice, certDpsEnrollmentGroup);
newDevice.PathToPfx = FileProvider.WritePfxToDisk($"{newDevice.Name}.pfx", deviceInPfxBytes);
newDevice.PemPublicKey = _iec.PemExportPublicKeyCertificate(certDevice);
FileProvider.WriteToDisk($"{newDevice.Name}-public.pem", newDevice.PemPublicKey);
using (ECDsa? ecdsa = certDevice.GetECDsaPrivateKey())
{
newDevice.PemPrivateKey = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{newDevice.Name}-private.pem", newDevice.PemPrivateKey);
}
var pemExportDevice = _iec.PemExportPfxFullCertificate(certDevice, newDevice.Password);
var certDeviceForCreation = _iec.PemImportCertificate(pemExportDevice, newDevice.Password);
using (var security = new SecurityProviderX509Certificate(certDeviceForCreation,
new X509Certificate2Collection(certDpsEnrollmentGroup)))
using (var transport = new ProvisioningTransportHandlerAmqp(TransportFallbackType.TcpOnly))
{
var client = ProvisioningDeviceClient.Create("global.azure-devices-provisioning.net",
scopeId, security, transport);
try
{
var result = await client.RegisterAsync();
_logger.LogInformation("DPS client created: {result}", result);
}
catch (Exception ex)
{
_logger.LogError("DPS client created: {result}", ex.Message);
return (null, ex.Message);
}
}
_dpsDbContext.DpsEnrollmentDevices.Add(newDevice);
dpsEnrollmentGroup.DpsEnrollmentDevices.Add(newDevice);
await _dpsDbContext.SaveChangesAsync();
return (newDevice.Id, null);
}
A few things worth calling out here. The method exports the device certificate two ways: as a PFX for Windows certificate store scenarios, and as separate PEM files for public certificate and private key. The PEM route is platform independent and is what gets used later when a device actually connects and sends telemetry, while the PFX path requires a password and is mainly there for Windows-based tooling.
The transport is set to ProvisioningTransportHandlerAmqp with TCP-only fallback. DPS also supports HTTP and MQTT transports, commented out in the source as alternatives, and the right choice depends on your device’s network constraints and whether it needs to minimize the binary footprint. If you are running on a constrained embedded device, referencing only the transport you actually need keeps the compiled size down.
One design decision to be aware of: RegisterAsync is called synchronously as part of the HTTP request handling the device registration. In a fleet with heavy provisioning traffic, you would want to move this off the request thread into a background job or queue, since DPS calls and certificate generation both add latency you do not want blocking a web request.
Downloading certificates from the web UI
Once a device is registered, its PEM files need to reach the actual device or the test harness sending telemetry. The web application exposes this through a simple HTML form that posts to a file download API.
<form action="/api/FileDownload/DpsDevicePublicKeyPem" method="post">
<input type="hidden" value="@Model.DpsDevice.Id" id="Id" name="Id" />
<button type="submit" style="padding-left:0" class="btn btn-link">Download Public PEM</button>
</form>
This is a plain form post rather than a GET link, which is a sensible choice since it avoids the PEM content or device id showing up in browser history or server access logs as a query string. The corresponding controller action reads the certificate back from the database and streams it as a file.
[HttpPost("DpsDevicePublicKeyPem")]
public async Task<IActionResult> DpsDevicePublicKeyPemAsync([FromForm] int id)
{
var cert = await _dpsRegisterDeviceProvider
.GetDpsDeviceAsync(id);
if (cert == null) throw new ArgumentNullException(nameof(cert));
if (cert.PemPublicKey == null)
throw new ArgumentNullException(nameof(cert.PemPublicKey));
return File(Encoding.UTF8.GetBytes(cert.PemPublicKey),
"application/octet-stream",
$"{cert.Name}-public.pem");
}
Nothing exotic here. The action fetches the device, guards against nulls with explicit exceptions rather than letting a NullReferenceException bubble up, and returns the PEM content as a downloadable file with the device name baked into the filename. The equivalent action for the private key PEM follows the same shape, though naturally that one deserves tighter access control in a real deployment since a leaked private key defeats the entire point of certificate-based auth.

Using the certificate from the device client
The last piece is the device side: loading the PEM files back into an X509Certificate2 and using it to authenticate a DeviceClient connection to IoT Hub.
var serviceProvider = new ServiceCollection()
.AddCertificateManager()
.BuildServiceProvider();
var iec = serviceProvider.GetService<ImportExportCertificate>();
var deviceNamePem = "robot1-feed";
var certPem = File.ReadAllText($"{_pathToCerts}{deviceNamePem}-public.pem");
var eccPem = File.ReadAllText($"{_pathToCerts}{deviceNamePem}-private.pem");
var cert = X509Certificate2.CreateFromPem(certPem, eccPem);
// setup deviceCert windows store export
var pemDeviceCertPrivate = iec!.PemExportPfxFullCertificate(cert);
var certDevice = iec.PemImportCertificate(pemDeviceCertPrivate);
var auth = new DeviceAuthenticationWithX509Certificate(deviceNamePem, certDevice);
var deviceClient = DeviceClient.Create(iotHubUrl, auth, transportType);
if (deviceClient == null)
{
Console.WriteLine("Failed to create DeviceClient!");
}
else
{
Console.WriteLine("Successfully created DeviceClient!");
SendEvent(deviceClient).Wait();
}
CreateFromPem combines the public certificate and private key PEM text directly into a single X509Certificate2, which is the modern, cross-platform way to do this without touching the Windows certificate store. The round trip through PemExportPfxFullCertificate and PemImportCertificate here is really just re-normalizing the certificate object so it behaves consistently with the rest of the CertificateManager APIs; on Linux you generally want to avoid store-based certificate handling entirely and stick to this PEM-based flow.
DeviceAuthenticationWithX509Certificate wraps the certificate for the IoT Hub SDK, and DeviceClient.Create opens the actual connection using whatever transport type you configured, matching the transport chosen back in DPS registration. From here, SendEvent is just the usual IoT Hub Message-based telemetry send, no different from a symmetric key device once the connection is established.
Production considerations and limitations
Certificate handling in .NET, especially around private key export and import across Windows and Linux, remains genuinely fiddly. The APIs for moving keys in and out of stores are not intuitive, and the official documentation does not always make the distinction between platforms clear. If you hit obscure exceptions while adapting this pattern, you are not doing anything unusual; this area of .NET has a real learning curve.
The fifty-year certificate validity used throughout this sample is a practical shortcut for a demo, not a recommendation. Long-lived certificates reduce operational overhead but increase the blast radius if a private key ever leaks, since there is no natural expiry forcing rotation. A production system needs configurable validity periods and, ideally, a certificate rotation workflow built in from day one rather than bolted on later.
The sample also uses ECDsa keys throughout, though RSA is equally valid depending on your device hardware’s crypto support. And the root certificate here is self-signed; nothing stops you from replacing it with a certificate issued by an actual CA, as long as that CA supports issuing child chained certificates the way this flow expects.
Beyond the certificate story, this implementation is explicitly a starting point. Authorization is all-or-nothing right now, there is no device disable or delete flow, and paging on the device lists is basic. A natural next step, which the original author flags as planned follow-up work, is routing device telemetry into hot and cold storage paths and using device twins, which are a genuinely useful mechanism for two-way state synchronization between the cloud and a device without needing a constant open connection.
Leave a Reply