Beyond mTLS: identity for a fleet of IoT devices
Certificates and mTLS are the easy part. The hard part is identity over a device's whole life: first enrolment, rotation, power loss mid-rotation, and a gateway that was offline for two years.
When I first think about securing an IoT gateway, the architecture can sound deceptively simple:
Put a certificate on the device, enable mTLS, sign the firmware, and we're secure.
For a prototype, that may even be enough.
For a platform I expect to operate for years, it isn't.
The difficult questions begin later.
What happens when a certificate expires while a gateway has been powered off for eighteen months? Who generated the private key? Could somebody else have copied it? How does a completely new device prove that it is allowed to receive its first production certificate? How do I rotate the key without bricking a remote gateway? What happens if power disappears halfway through that rotation? Who is allowed to sign firmware? What if the company operating the platform wants its own CA and does not want the manufacturer controlling production identity?
This is the point where IoT security stops being a TLS configuration exercise and becomes an identity lifecycle architecture.
This article describes how I would build such a platform for myself if I wanted a very high security level. The same architecture can then be relaxed or adapted for commercial deployments.
My embedded platform of choice here is Nerves, so I will look at the problem primarily from the device side.
I would start by separating three kinds of trust
The first mistake I want to avoid is using the word "certificate" as if all certificates solve the same problem.
I actually have three different trust domains:
1. Secure boot trust
2. Manufacturing / bootstrap identity
3. Operational identity
They have different jobs and different lifetimes.
Secure boot
Secure boot answers:
Is this gateway running software that I have authorized?
Conceptually:
hardware root
↓
bootloader
↓
kernel/system
↓
Nerves firmware
This has almost nothing to do with whether MQTT accepts the gateway.
I don't want to use my MQTT client certificate as some universal device security primitive.
Firmware authenticity is one problem.
Device identity is another.
Manufacturing identity
When I manufacture gateway:
GW-2026-000173
I want it to leave manufacturing with a unique cryptographic identity.
Something like:
device ID
bootstrap private key
bootstrap certificate
The bootstrap certificate might be issued by a manufacturing CA.
This credential is deliberately weak in authorization, even though it is cryptographically strong.
It cannot access production MQTT.
It cannot call production APIs.
Its job is:
initial provisioning
+
controlled disaster recovery
Operational identity
The operational identity is what the gateway eventually uses for:
MQTT
HTTPS APIs
other production mTLS
This certificate is issued by the CA controlling the actual deployment.
For a high-security design, I want the operational certificate to be relatively short-lived and periodically replaced.
Most importantly:
I want the operational private key to be generated by the gateway itself.
That one decision influences much of the architecture.
A private key should be private in the literal sense
A common provisioning approach is convenient:
server generates:
device.key
device.crt
server sends both to device
I don't like this for a serious platform.
If the server created the key, how many places could that private key have existed?
server RAM
temporary filesystem
automation workspace
backup
debug dump
network transfer
operator machine
gateway
Maybe everything is perfectly implemented.
But why create the exposure at all?
Instead, my Nerves gateway does:
generate Kpriv
derive Kpub
create CSR
The private key stays on the device.
The CSR contains the public key and is signed with the private key.
Gateway CA
Kpriv
|
+--> Kpub
|
+--> CSR ----------------------->
<---------------- certificate
The CA never needs the private key.
Eventually, if the hardware supports it, I want to go further:
TPM / secure element
|
+-- generate key internally
|
+-- private key non-exportable
|
+-- sign operations only
Then even my Nerves application does not need access to raw private-key bytes.
It simply asks the hardware:
Sign this.
That is a much better end state.
But how does a new device get its first certificate?
Now I have a chicken-and-egg problem.
I want mTLS for production.
But a brand-new gateway does not yet have its production certificate.
How does it securely ask for one?
This is why I use the manufacturing/bootstrap identity.
At manufacturing time:
GW-2026-000173
bootstrap.key
bootstrap.crt
are installed.
Every gateway gets its own key.
I would not use one shared fleet-wide bootstrap secret unless I had a very good reason.
The gateway also knows one special endpoint:
https://provision.example.net
This is not my production MQTT broker.
It is not my normal cloud API.
It is a dedicated provisioning service.
The provisioning service is the center of the lifecycle
I think of this service as a Registration Authority, or RA.
It sits between devices and the CA.
Gateway
|
| HTTPS + mTLS
v
Provisioning / RA
|
| approved CSR
v
CA
Why not let gateways talk directly to the CA?
Because a CA should not need to understand my entire device business model.
My provisioning service understands:
device inventory
manufacturing batches
hardware revisions
sites
device lifecycle
whether enrollment is allowed
whether recovery is allowed
certificate policy
configuration
audit history
The CA understands certificates.
That separation feels right.
If I were building this in my usual stack, a Phoenix service would be a very natural implementation.
“Bootstrap certificate can only provision” needs to be real architecture
It is easy to say:
This certificate only has provisioning privileges.
But how?
My preferred answer is network/service trust separation.
bootstrap certificate
|
+--> provision.example.net YES
|
+--> mqtt.example.net NO
|
+--> api.example.net NO
The provisioning endpoint trusts my bootstrap/manufacturing CA.
Production MQTT and APIs simply do not.
They trust only the operational Device CA.
That means I don't depend solely on a complicated list of application ACLs.
Even a perfectly valid bootstrap certificate is useless against the production MQTT TLS listener.
Inside the provisioning service I add another check:
valid bootstrap certificate?
↓
known device?
↓
expected manufacturing batch?
↓
authorized for enrollment?
↓
correct lifecycle state?
↓
CSR acceptable?
↓
issue
This gives me two layers:
authentication:
"Who cryptographically connected?"
authorization:
"What is this device currently allowed to do?"
That distinction matters everywhere in security.
A serial number is not authentication
Suppose a gateway sends:
{
"device_id": "GW-2026-000173"
}
That proves nothing.
Anyone can type that string.
The identity should come from the authenticated client certificate.
If the certificate says:
GW-2026-000173
and the request claims:
GW-2026-000999
the request fails.
I would bind:
unique device ID
+
unique bootstrap public key/certificate
+
server-side inventory record
The provisioning database may contain:
GW-2026-000171 AUTHORIZED
GW-2026-000172 ACTIVE
GW-2026-000173 AUTHORIZED
GW-2026-000174 SUSPENDED
A valid certificate alone does not necessarily mean the operation is allowed.
First boot from the Nerves side
This is where I find the architecture especially interesting.
I don't want my firmware to be a special one-time installer.
I want the same application to understand its lifecycle.
At boot it asks:
What state am I actually in?
Maybe I keep small metadata in Nerves.Runtime.KV:
device_uid=GW-2026-000173
provisioning_state=active
But I do not trust a boolean flag as the whole truth.
If it says:
provisioned=yes
while my private key is gone, I am obviously not provisioned.
So I reconstruct the effective state.
Conceptually:
case identity_state() do
:unprovisioned ->
start_bootstrap()
:active ->
start_operational_services()
:rotation_due ->
start_operational_services()
start_rotation()
:expired ->
start_restricted_recovery()
end
Before calling an identity active I check:
key exists
certificate exists
certificate matches key
identity is correct
chain is trusted
validity is acceptable
configuration is valid
There is another embedded-system detail hiding here: time.
Certificates have notBefore and notAfter.
What does a gateway do after a complete power loss if its clock is wrong?
A serious design has to solve trusted-enough time bootstrap as part of identity validation.
These are exactly the little details that turn a diagram into a real IoT platform.
My first enrollment flow
The gateway starts with:
bootstrap.key
bootstrap.crt
The server already knows:
GW-2026-000173 = AUTHORIZED_FOR_ENROLLMENT
The gateway connects:
Nerves gateway
|
| HTTPS
| mTLS using bootstrap identity
v
Provisioning service
Then the gateway generates:
K1priv
K1pub
and creates:
CSR1
The CSR effectively says:
Here is the public key for the private key I possess. Please issue the permitted identity for this device.
The provisioning service does not blindly trust the requested Subject/SAN.
It already knows the caller is:
GW-2026-000173
Therefore the policy should enforce that identity.
A compromised gateway must not be able to request:
CN=GW-ADMIN
and have the CA obediently sign it.
The RA sends the validated PKCS#10 request to the CA.
The CA returns:
Cert1
certificate chain
Nerves verifies that Cert1:
matches K1
has expected identity
chains to expected CA
has expected usages
has sensible validity
Only then do I persist and activate it.
Why HTTPS instead of MQTT for enrollment?
I could build provisioning over MQTT.
There is nothing fundamentally wrong with that.
I could create:
bootstrap/device-id/request
bootstrap/device-id/response
and configure strict broker ACLs.
I still prefer HTTPS for initial enrollment.
Provisioning is naturally:
request CSR
↓
validate
↓
issue
↓
return certificate/config
HTTP maps directly to that transaction.
It also lets me keep bootstrap infrastructure completely separate from the operational broker.
So my architecture becomes:
BEFORE PROVISIONING
Gateway
|
| HTTPS + bootstrap mTLS
v
Provisioning service
AFTER PROVISIONING
Gateway
|
| MQTT + operational mTLS
v
Production broker
I like how easy that is to reason about.
Provisioning is also configuration
Once I know exactly which device I am talking to, I can return its deployment configuration.
For example:
{
"config_version": 42,
"mqtt": {
"host": "mqtt.example.net",
"port": 8883
},
"api": {
"base_url": "https://api.example.net"
},
"firmware": {
"base_url": "https://firmware.example.net"
}
}
The actual deployment may also include:
APN
topic namespace
site identifier
tenant
CA bundles
network policy
firmware channel
I want this configuration versioned.
Ideally I keep:
active
pending
previous-known-good
rather than editing files in place and hoping power stays on.
Embedded devices lose power.
That is not an exceptional event.
The architecture should assume it.
Provisioning is not finished after first boot
This is probably the biggest conceptual change.
If my device will live for ten years, provisioning is not an installation event.
It is a lifecycle service.
One year later my certificate may need replacement.
Suppose:
Cert1 lifetime = 12 months
rotate 30 days before expiry
I could simply request a new certificate for the same key.
But in a high-security design I would rather rotate the key too.
Current:
K1 + Cert1
Gateway generates:
K2
CSR2
Now it does not need its bootstrap credential.
It already has a valid production identity.
It calls the provisioning service using:
K1 + Cert1
and sends CSR2.
Gateway
|
| mTLS: Cert1/K1
| CSR for K2
v
Provisioning service
|
v
CA
|
+-- Cert2
The gateway now temporarily has:
ACTIVE:
K1 + Cert1
PENDING:
K2 + Cert2
This is intentional.
I do not delete K1 immediately.
I test K2/Cert2 against the real service.
Only after success:
K2 + Cert2 -> ACTIVE
K1 + Cert1 -> RETIRED
If something fails, I still have my known-good identity.
This is the same mindset I want for firmware updates:
Never destroy the working state before proving the replacement.
Then comes the awkward case: the gateway was offline for two years
This is where many nice-looking certificate architectures become operationally painful.
Suppose:
certificate expires: September 2027
gateway powered off: February 2027
gateway returns: January 2029
It cannot perform normal authenticated rotation because its operational certificate has expired.
What now?
I do not want the answer to be:
Temporarily disable certificate checking.
That is how emergency procedures become permanent vulnerabilities.
Instead I design a recovery enrollment path from the beginning.
I would keep the manufacturing identity for recovery
After first provisioning, I do not need to delete the bootstrap key.
Instead I disable its authorization on the server.
The credential remains cryptographically valid, but the provisioning service says:
GW-2026-000173
bootstrap enrollment = DENIED
recovery = DENIED
Normally it can do nothing.
If the gateway returns years later with an expired operational certificate, an administrator can explicitly authorize:
GW-2026-000173
recovery allowed until 15:00
single use
Now the gateway uses its lifetime manufacturing identity to reach a dedicated recovery operation.
Gateway
|
| bootstrap mTLS
v
Recovery endpoint
The gateway generates a new operational key:
K3
CSR3
The provisioning service verifies the explicit recovery authorization and obtains:
Cert3
from the CA.
After success:
recovery authorization = consumed
device = ACTIVE
The bootstrap identity goes dormant again.
This gives me a very useful hierarchy:
Manufacturing identity
|
+-- long-lived proof of device origin
+-- initial enrollment
+-- controlled recovery
Operational identity
|
+-- normal production access
+-- short-lived
+-- regularly rotated
That is one of my favorite parts of this architecture.
Recovery must be more difficult than normal operation
A recovery mechanism is valuable precisely because it bypasses a broken normal credential.
Therefore it is also dangerous.
I would consider controls such as:
explicit operator authorization
short validity window
single use
full audit log
rate limiting
alerts
old-certificate revocation
possibly two-person approval
And critically:
Bootstrap CA is STILL not trusted by production MQTT.
Stealing a bootstrap key does not directly make an attacker a production gateway.
They would also need the provisioning service to authorize recovery.
I would model this as OTP, not a pile of boot scripts
One reason Nerves fits this architecture nicely is OTP.
I can model device lifecycle explicitly.
For example:
Gateway.Application
|
+-- Identity
+-- CertificateStore
+-- ConfigStore
+-- TimeManager
|
+-- Provisioning.Supervisor
| |
| +-- BootstrapClient
| +-- RotationManager
| +-- RecoveryManager
|
+-- OperationalSupervisor
|
+-- MQTT
+-- API clients
+-- application workers
At boot:
+------------------+
| Evaluate identity|
+--------+---------+
|
+---------------+---------------+
| | |
v v v
UNPROVISIONED ACTIVE EXPIRED
| | |
Bootstrap start normal restricted
provisioning services recovery
This is much cleaner than scattering:
if File.exists?(...)
through unrelated application code.
I also want key storage abstracted
My first implementation may use a PEM file.
Later I may have a TPM.
I don't want my MQTT code to know the difference.
Instead I want something conceptually like:
IdentityKey.generate()
IdentityKey.public_key(ref)
IdentityKey.create_csr(ref, identity)
IdentityKey.sign(ref, data)
IdentityKey.destroy(ref)
Today:
ref -> /data/identity/key.pem
Tomorrow:
ref -> TPM handle
That architectural boundary makes hardware-backed identity an evolution instead of a rewrite.
Power loss changes how I design credential rotation
A desktop/server developer can easily forget this.
Imagine:
write new key
write new certificate
set active=true
Power disappears after step two.
What does the device do next?
I want explicit identity generations:
current
pending
previous
The workflow becomes:
generate pending
↓
persist pending
↓
validate pending
↓
test pending
↓
atomically promote
↓
retain previous temporarily
↓
clean up
Boot code understands incomplete transitions.
The same pattern applies to:
firmware
configuration
certificates
keys
This is a general embedded architecture principle, not merely PKI.
Firmware trust should remain separate
There is another temptation:
If the customer owns the CA, should they also build the firmware?
Not necessarily.
These are different trust decisions.
I can separate:
who writes code
who builds code
who approves a release
who signs a release
who hosts it
what the gateway accepts
For example:
source
↓
my CI/build
↓
Nerves firmware
↓
hash + SBOM + provenance
↓
security/release approval
↓
authorized signature
↓
firmware repository
↓
gateway
In a very strict environment, the final build or signing can happen in a separately controlled system.
For maximum assurance I could eventually add reproducible builds.
But I do not need to conflate the firmware-signing authority with the operational Device CA.
The full lifecycle I want
Putting it all together:
MANUFACTURE
|
+-- secure boot
+-- unique device ID
+-- unique bootstrap key/cert
|
v
AUTHORIZED FOR ENROLLMENT
|
v
FIRST BOOT
|
+-- bootstrap HTTPS mTLS
+-- generate K1
+-- CSR1
|
v
CA ISSUES CERT1
|
v
ACTIVE
|
+------------------------------+
| |
| before expiry | offline too long
v v
ROTATE EXPIRED
| |
generate K2 |
CSR2 |
Cert2 |
test |
promote |
| |
v v
ACTIVE AUTHORIZED RECOVERY
|
bootstrap mTLS
|
generate K3
|
CSR3
|
Cert3
|
v
ACTIVE
Eventually:
ACTIVE
|
v
DECOMMISSIONED
with operational certificates revoked and recovery/enrollment disabled.
What I would store on the Nerves gateway
I would separate metadata from cryptographic state.
Small, infrequently changed identity metadata can fit well in Nerves.Runtime.KV, for example:
device_uid
hardware_revision
provisioning_state
For richer persistent state I want dedicated writable storage:
active certificate
pending certificate
certificate chain
signed configuration
rotation metadata
Private keys deserve their own abstraction and eventually hardware-backed storage.
I don't want Nerves.Runtime.KV to become a miniature database just because it is convenient.
What I would build on the server
My provisioning service would probably have roughly these concepts:
Device
ManufacturingBatch
CertificateInstance
ProvisioningEvent
RecoveryAuthorization
ConfigurationVersion
And narrow device operations:
enroll
rotate certificate/key
recover
get configuration
get status
Administrative operations include:
import manufacturing batch
assign device
authorize enrollment
authorize recovery
suspend
revoke
decommission
Every important transition is audited.
I want to be able to open one gateway ten years later and reconstruct:
when manufactured
when provisioned
which certificates it had
when keys rotated
why recovery happened
who authorized recovery
which firmware/configuration was active
when it was decommissioned
That is part of security too.
The questions I now ask when designing IoT security
Instead of asking only:
Does it support TLS?
I want to ask:
Where is each private key generated?
Can it ever be exported?
Which CA is trusted for which purpose?
What can a manufacturing credential actually access?
Who authorizes first enrollment?
Who validates CSR identity?
What happens when a certificate expires?
What happens if the device is offline longer than certificate lifetime?
What happens if power fails during key rotation?
How is a stolen device revoked?
How is an RMA device recovered?
What if the clock is wrong?
Who signs firmware?
Can old firmware be replayed?
What happens if the provisioning service is compromised?
What is the blast radius if ONE gateway key is extracted?
These questions expose architecture problems much earlier than choosing TLS cipher suites does.
The principle I keep coming back to
For me, the most useful way to think about a serious IoT platform is:
Identity is a lifecycle, not a file.
device.crt is not the architecture.
The architecture is:
manufacture
↓
establish provenance
↓
authorize
↓
enroll
↓
operate
↓
rotate
↓
recover
↓
revoke
↓
decommission
And the Nerves application should understand that lifecycle as explicitly as the cloud side does.
If I build the system this way for my own highest-security environment, commercial deployment becomes a matter of deciding which controls a particular threat model actually requires—not trying to bolt missing lifecycle security onto a fleet that is already deployed.
That is the kind of IoT architecture I want to build: not merely secure on first boot, but designed to remain manageable and trustworthy years later.