One golden certificate for the whole production line
Contents8 ▾
Somewhere between the tenth prototype and the first production batch, every connected product runs into the same question. On the desk, each board got its certificate the easy way. Someone generated it in the console, copied the files into the project and flashed them together with the firmware. That works for ten boards and falls apart at a thousand, because the factory now needs a separate step for every unit, a secure way to hand it unique secrets, and a record of which certificate went onto which board. Most contract manufacturers are set up to flash one identical image onto every device, and injecting a unique private key into each unit adds cost, time and a secure handover of secrets that many of them would rather avoid. I saw exactly this question on Reddit recently from someone running ThingsBoard, and the answers were thin, so here is how I handle it.
The approach I reach for in this situation is often called the golden certificate. Every device leaves the factory with the same firmware and the same shared certificate, and that certificate is allowed to do exactly one thing, which is to ask for a real identity of its own the first time the device comes online. AWS IoT Core supports this directly under the name fleet provisioning by claim, and the rest of this post walks through how I set it up.
How the exchange works
The shared certificate is called the claim certificate. A device connects to AWS IoT Core with it, generates a fresh key pair locally, and sends a certificate signing request over MQTT. AWS signs it and returns a new certificate along with a short ownership token. The device then calls a provisioning template with that token and a few parameters, such as its serial number. The template creates a thing in the registry, activates the new certificate, attaches the right policy and hands back the thing name. From that moment the device disconnects, throws the claim certificate away for everyday use and reconnects with its own identity.
The private key for the device certificate is generated on the device and never travels anywhere. AWS also offers a variant where the cloud generates the key and sends it down. I avoid it, because the CSR route costs very little extra on an ESP32 and removes the one moment where a private key is in transit.
A claim certificate that can do almost nothing
The claim certificate will be on every unit you ship, so I assume from day one that someone will eventually extract it. Its policy should let it connect and use the provisioning topics, and nothing else at all.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Receive"],
"Resource": [
"arn:aws:iot:eu-central-1:123456789012:topic/$aws/certificates/create-from-csr/*",
"arn:aws:iot:eu-central-1:123456789012:topic/$aws/provisioning-templates/factory-template/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:eu-central-1:123456789012:topicfilter/$aws/certificates/create-from-csr/*",
"arn:aws:iot:eu-central-1:123456789012:topicfilter/$aws/provisioning-templates/factory-template/provision/*"
]
}
]
}
A device holding only this certificate cannot read or publish telemetry, cannot touch shadows and cannot see any other device. The worst it can do is ask for a new identity, and the next sections make sure that request only succeeds for serial numbers that actually came off your line.
The provisioning template
The template describes what AWS should create for each new device. Mine usually takes the serial number as a parameter, names the thing after it and attaches a single shared device policy.
{
"Parameters": {
"SerialNumber": { "Type": "String" },
"AWS::IoT::Certificate::Id": { "Type": "String" }
},
"Resources": {
"thing": {
"Type": "AWS::IoT::Thing",
"Properties": {
"ThingName": { "Fn::Join": ["", ["sensor-", { "Ref": "SerialNumber" }]] },
"AttributePayload": { "serial": { "Ref": "SerialNumber" } }
}
},
"certificate": {
"Type": "AWS::IoT::Certificate",
"Properties": {
"CertificateId": { "Ref": "AWS::IoT::Certificate::Id" },
"Status": "ACTIVE"
}
},
"policy": {
"Type": "AWS::IoT::Policy",
"Properties": { "PolicyName": "fleet-device-policy" }
}
},
"DeviceConfiguration": {}
}
The template needs an IAM role that AWS IoT can assume to create these resources. A role trusted by iot.amazonaws.com with the managed AWSIoTThingsRegistration policy attached covers it. Creating the template, the claim certificate and attaching the claim policy then takes three commands.
aws iot create-provisioning-template \
--template-name factory-template \
--provisioning-role-arn arn:aws:iam::123456789012:role/IoTFleetProvisioningRole \
--template-body file://template.json \
--pre-provisioning-hook targetArn=arn:aws:lambda:eu-central-1:123456789012:function:check-serial \
--enabled
aws iot create-keys-and-certificate --set-as-active \
--certificate-pem-outfile claim.cert.pem \
--private-key-outfile claim.private.key \
--query certificateArn --output text
aws iot attach-policy --policy-name claim-policy --target <certificate-arn>
The Lambda function in the hook also needs a resource policy that lets iot.amazonaws.com invoke it, which aws lambda add-permission takes care of.
Only serials you actually built
Without a check, anyone holding the claim certificate could invent serial numbers and provision as many fake devices as they like. The pre-provisioning hook closes that gap. AWS calls a Lambda function before creating anything, passes it the parameters the device sent, and only continues if the function says yes.
The factory side of this is pleasantly small. The end-of-line test already reads something unique from every board, and on an ESP32 the factory programmed MAC address in eFuse works well as a serial. The test station writes each serial into a DynamoDB table as it passes, with a provisioned flag set to false. The hook then accepts a serial only once, and only if it is in that table. AWS gives the hook five seconds to answer and refuses the request if it takes longer, which a single DynamoDB write fits into comfortably.
import boto3
table = boto3.resource("dynamodb").Table("factory-serials")
def handler(event, context):
serial = event.get("parameters", {}).get("SerialNumber", "")
try:
table.update_item(
Key={"serial": serial},
UpdateExpression="SET provisioned = :yes",
ConditionExpression="attribute_exists(serial) AND provisioned = :no",
ExpressionAttributeValues={":yes": True, ":no": False},
)
except table.meta.client.exceptions.ConditionalCheckFailedException:
return {"allowProvisioning": False}
return {"allowProvisioning": True}
The one-time rule needs an escape hatch. A device that passes the hook and then loses power before saving its new certificate will be refused on the next attempt, so support needs a simple way to deactivate the orphaned certificate on that thing and reset the flag for its serial. Saving the private key before sending the CSR, and the certificate the moment it arrives, keeps that window small.
What the device does on first boot
On the device, the first step is generating a key pair and a CSR. The code below is for ESP-IDF 5.x, which ships mbedTLS 3.6, and assumes a CTR-DRBG random generator has already been seeded. ESP-IDF 6.0 moved to mbedTLS 4, where key generation goes through the PSA crypto API, so on that release these calls need porting. I use an EC key on the P-256 curve, which is smaller and faster than RSA on a microcontroller.
mbedtls_pk_context key;
mbedtls_x509write_csr csr;
char subject[64];
unsigned char csr_pem[1024];
snprintf(subject, sizeof(subject), "CN=sensor-%s", serial);
mbedtls_pk_init(&key);
mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
mbedtls_ctr_drbg_random, &ctr_drbg);
mbedtls_x509write_csr_init(&csr);
mbedtls_x509write_csr_set_key(&csr, &key);
mbedtls_x509write_csr_set_md_alg(&csr, MBEDTLS_MD_SHA256);
mbedtls_x509write_csr_set_subject_name(&csr, subject);
mbedtls_x509write_csr_pem(&csr, csr_pem, sizeof(csr_pem),
mbedtls_ctr_drbg_random, &ctr_drbg);
The private key goes straight into storage that is protected on the device, which on an ESP32 means encrypted NVS with flash encryption enabled, or the Digital Signature peripheral on chips that have it.
Then the device connects with the claim certificate and has a short conversation over MQTT. It subscribes to $aws/certificates/create-from-csr/json/accepted and the matching rejected topic before sending anything, then publishes the CSR to $aws/certificates/create-from-csr/json.
{
"certificateSigningRequest": "-----BEGIN CERTIFICATE REQUEST-----\nMIIB..."
}
The accepted response carries the signed certificate and the ownership token. The token is valid for one hour, and AWS deletes the new certificate if it has not been registered by then, so the device should move straight on to the next step.
{
"certificateId": "8f2c...",
"certificatePem": "-----BEGIN CERTIFICATE-----\nMIIC...",
"certificateOwnershipToken": "eyJ2..."
}
The device subscribes to the accepted and rejected topics under $aws/provisioning-templates/factory-template/provision/json and publishes the token with its serial number to that same topic.
{
"certificateOwnershipToken": "eyJ2...",
"parameters": { "SerialNumber": "a4cf12b39e04" }
}
If the hook approves the serial, the response names the new thing.
{
"deviceConfiguration": {},
"thingName": "sensor-a4cf12b39e04"
}
At this point the device saves its certificate, disconnects and connects again with the new certificate, using the thing name as its MQTT client ID. Everything after that is normal operation. If you would rather not write the topic handling by hand, AWS publishes a small Fleet Provisioning library in C that builds and parses these exact topics and works with any MQTT client. Espressif's esp-aws-iot repository includes it together with a fleet_provisioning_with_csr example, which at the time of writing supports ESP-IDF 5.x and not yet 6.0.
A policy that fits every device
Since the template attaches the same policy to the whole fleet, that policy uses variables so each device can only act as itself. Because the template also attaches the thing to the certificate, AWS can resolve the thing name at connection time.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Receive"],
"Resource": "arn:aws:iot:eu-central-1:123456789012:topic/devices/${iot:Connection.Thing.ThingName}/*"
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": "arn:aws:iot:eu-central-1:123456789012:topicfilter/devices/${iot:Connection.Thing.ThingName}/*"
}
]
}
AWS IoT works out the thing from the certificate the device presents, so a device can only ever act as the thing attached to its own certificate, and its client ID has to match that thing's name for the connection to be allowed.
Looking after the golden certificate
The claim certificate is the most valuable secret in the whole setup, so I protect it the same way as the firmware itself. Secure Boot and flash encryption on the ESP32 make it much harder to read off a board. I also issue a new claim certificate for each production batch or each manufacturer, which keeps the damage small if one leaks and lets me deactivate an old one once every device from that batch has come online. Watching the provisioning numbers helps too. A sudden burst of requests at three in the morning, or a hook rejecting many unknown serials, is worth an alert.
One timing question is worth deciding early. Devices can provision during the end-of-line test, while they are still in the factory and on a known network, or later at the customer site. Provisioning in the factory gives you certainty that every shipped unit already has its identity, and lets you deactivate the claim certificate as soon as the batch is complete. Provisioning in the field keeps the factory process simpler and suits products that sit in a warehouse for months, at the cost of keeping the claim certificate active for longer.
When to choose something else
The golden certificate fits most small and medium production runs, and it is what I would suggest to the ThingsBoard user from the start of this post. ThingsBoard has the same idea built in, where a device sends the provisioning key and secret from its device profile on first connection and receives its own credentials back, so the pattern carries over outside AWS too. For larger volumes, or products where the security requirements are strict, secure elements such as Microchip's ATECC608B in its Trust&GO variant arrive from the chip vendor with a unique private key locked inside and a matching certificate, which removes the shared secret entirely. Microchip supplies a manifest of those certificates that can be registered with AWS IoT in bulk before the devices ship. Those options cost more per unit and add a supply chain dependency, so for many products the claim certificate with a careful hook is the sensible place to start.
If you are about to move a connected product from prototypes to its first production batch and want a second pair of eyes on the provisioning flow, I would be glad to help.