I have been working with several different IoT solutions over the years. One thing they all have in common is the need for trusted certificates that can be used both to establish connection but also device identities. Devices and servers need to trust each other to establish secure communication, ensure data integrity, and prevent malicious attacks. A important part of this trust is the Public Key Infrastructure (PKI), where certificates and Certificate Authorities (CAs) play vital roles, there is also a need to manage a large amount of certificates in an easy way.
In some project we have been rolling our own internal PKI solution, this does come with complexity and security requirements. In other we have been using SaaS solutions, like DigiCert IoT Trust Manager, AWS IoT Core and Amazon Private CA.
However, when it came to development, and sometime even test environments, it was not uncommon that we used some sort of self signed certificates, with an easy self service portal to create different certificates.
In this post, which is the first part of two, we will introduce some basics around certificates and PKI. We will also start to create the foundations for Serverless API that can be used to create a self-service portal for generation of certificates.
WARNING
The solution I build in this series of posts is NOT suited for a production setup. This is purely meant for development environments and for learning purpose!
In a production environment we need:
- Continuous monitoring and automated renewal of certificates.
- Integration with hardware security modules (HSMs) for key storage.
- Compliance with security standards.
For these needs, managed services like AWS Private CA, DigiCert IoT Trust Manager, and Let’s Encrypt are ideal.
In this first version of the API we do not using any form of Authorization! This will be added in the next part!
Why Build a Self-Service API?
As said, in an IoT system, thousands of devices need to interact with servers, IoT brokers, and each other. Each of these interactions must be secure, meaning:
- Servers must authenticate devices to ensure they’re legitimate.
- Devices must authenticate servers to ensure they’re connecting to a trusted source.
Certificates are therefor issued to servers and devices, creating a chain of trust anchored at a Root CA.
A self-service API for certificate management allows:
- Automation: Devices and servers can request and renew certificates programmatically.
- Scalability: As our IoT environment grows, the API can handle the increasing demand for certificates.
- Learning and Testing: Before adopting a managed service, building your own certificate system helps you understand how PKI works.
Overview of Certificates, CAs, and Trust
What Is a Certificate Authority (CA)?
A CA is a trusted entity responsible for issuing digital certificates. These certificates bind a public key to an identity (e.g., a server, device, or user), enabling trust between entities.
CAs are structured in a hierarchy:
Root CA
- The ultimate trust anchor.
- Self-signed and highly secure.
- Should never be used to issue end-entity certificates directly.
Intermediate CA
- Issued and signed by the Root CA.
- Delegates the responsibility of issuing certificates to end entities (e.g., devices and servers).
- Limits the exposure of the Root CA.
End-Entity, leaf, Certificates
- Certificates for servers, IoT devices, or users.
- Issued by an Intermediate CA and used in client-server communication or mutual authentication.
Certificate Chains and Trust
A certificate chain is a sequence of certificates, where each certificate in the chain is signed by the subsequent certificate. It represents the hierarchical relationship between a certificate and its issuer.
A chain can consist of one or several intermediate certificates. In the image above the chain is illustrated with two intermediate CAs.
During a TLS handshake
Grossly simplified the handshake would be
- The server presents its certificate to the client.
- The client validates the server certificate by tracing the chain back to a trusted Root CA in its certificate store. When using a self-signed Root CA you need to ensure the bundle is present in the trust store or included in the connection attempt.
In a scenario when mutual authentication is required, the server performs the same process for the client certificate.
Trust across several Intermediate CAs
In an IoT setup, it’s common to have one Intermediate CA issuing server certificates (e.g., for IoT brokers), and another Intermediate CA issuing client certificates (e.g., for devices).
For a client certificate (signed by one Intermediate CA) to trust a server certificate (signed by a different Intermediate CA), both must:
Be part of the same trust hierarchy.
- Both Intermediate CAs must be signed by the same Root CA.
- The Root CA is the common trust anchor.
Be validated against the chain.
- The client verifies the server’s certificate chain, tracing it back to the Root CA.
- The server verifies the client’s certificate chain similarly.
This setup ensures scalability and separation of responsibilities. The Server Intermediate CA focuses on servers and brokers. The Device Intermediate CA focuses on IoT devices.
Implementation
Now let's start to implement this self service API. We will build this completely serverless using Amazon API Gateway and Lambda functions, with storage of the certificates in S3 and Certificate Manager. This first part in the blog series will only create the first very basic part of the API, which we will extend in the second part. You can find all of the source code on Serverless-Handbook Self Service IoT Certificate management
We will setup an API Gateway with three endpoints for creating our Root CA, Intermediate CA, and Server cert. Everything will be stored in an S3 bucket, and the server certificate will also be imported to Certificate Manager.
REST API
First look at the REST API and the structure.
Endpoint | Method | Description |
---|---|---|
/certificates/root |
POST |
Create a new Root CA. |
/certificates/intermediate |
POST |
Create a new Intermediate CA. |
/certificates/server |
POST |
Create a new server certificate. |
I decided to use separate paths (e.g., /certificates/root, /certificates/intermediate, /certificates/server) rather than a single endpoint with a type parameter (e.g., /certificates with type as input) as I feel this aligns better with REST principles and improves the API’s readability and usability.
It's easier to extend the API, with a new certificate type (e.g., device), as we can create a new path like /certificates/device without impacting existing paths. Separate paths naturally segment resources, reducing the need for filtering on the client side. I feel that retrieving all Root CAs is simpler with GET /certificates/root than GET /certificates?type=root.
Common infrastructure
First of all, let's deploy the common infrastructure, in this case it's just the S3 bucket, we will add more things in this template later.
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Certificate Self Service Common Infrastructure
Parameters:
ApplicationName:
Type: String
Description: Name of owning application
Default: image-moderation
Resources:
StorageBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub ${ApplicationName}-storage-bucket
Outputs:
StorageBucketName:
Description: The name of the certificate bucket
Value: !Ref StorageBucket
Export:
Name: !Sub ${AWS::StackName}:certificate-bucket-name
Next we can setup the endpoints using SAM and AWS::Serverless::Api
and the three Lambda function backing the API.
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Create the API for self service certificate management
Parameters:
ApplicationName:
Type: String
Description: Name of owning application
CommonInfraStackName:
Type: String
Description: The name of the common stack that contains the EventBridge Bus and more
Globals:
Function:
Timeout: 30
MemorySize: 2048
Runtime: python3.12
Environment:
Variables:
CERTIFICATE_BUCKET_NAME:
Fn::ImportValue: !Sub "${CommonInfraStackName}:certificate-bucket-name"
Resources:
LambdaGenerateRootCA:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/API/GenerateRootCA
Handler: handler.handler
Policies:
- S3FullAccessPolicy:
BucketName:
Fn::ImportValue: !Sub "${CommonInfraStackName}:certificate-bucket-name"
Events:
CreateRootCAApi:
Type: Api
Properties:
Path: /certificates/root
Method: post
RestApiId: !Ref GenerateCertificatesApi
LambdaGenerateIntermediateCA:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/API/GenerateIntermediateCA
Handler: handler.handler
Policies:
- S3FullAccessPolicy:
BucketName:
Fn::ImportValue: !Sub "${CommonInfraStackName}:certificate-bucket-name"
Events:
CreateIntermediateCAApi:
Type: Api
Properties:
Path: /certificates/intermediate
Method: post
RestApiId: !Ref GenerateCertificatesApi
LambdaGenerateDeviceCertificate:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/API/GenerateDeviceCert
Handler: handler.handler
Policies:
- S3FullAccessPolicy:
BucketName:
Fn::ImportValue: !Sub "${CommonInfraStackName}:certificate-bucket-name"
LambdaGenerateServerCertificate:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/API/GenerateServerCert
Handler: handler.handler
Policies:
- S3FullAccessPolicy:
BucketName:
Fn::ImportValue: !Sub "${CommonInfraStackName}:certificate-bucket-name"
- Version: "2012-10-17"
Statement:
Action:
- acm:*
Effect: Allow
Resource: "*"
Events:
CreateServerCAApi:
Type: Api
Properties:
Path: /certificates/server
Method: post
RestApiId: !Ref GenerateCertificatesApi
GenerateCertificatesApi:
Type: AWS::Serverless::Api
Properties:
Description: API for creating and managing certificates
Name: !Sub ${ApplicationName}-api
StageName: prod
OpenApiVersion: '3.0.1'
AlwaysDeploy: true
EndpointConfiguration: REGIONAL
Our Lambda functions is in Python and we use the cryptography library to create the certificates
, below is the implementation for creating a server certificate. For a full implementation visit Serverless-Handbook Self Service IoT Certificate management.
import boto3
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization, hashes
import datetime
import os
import json
s3_client = boto3.client("s3")
acm_client = boto3.client("acm")
def create_server_certificate(
intermediate_private_key_pem,
intermediate_cert_pem,
fqdn,
country,
state,
organization,
validity_days,
):
# Load Intermediate CA private key and certificate
intermediate_private_key = serialization.load_pem_private_key(
intermediate_private_key_pem, password=None
)
intermediate_cert = x509.load_pem_x509_certificate(intermediate_cert_pem)
# Generate a private key for the server certificate
server_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_private_key_pem = server_private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
subject = x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, fqdn),
]
)
server_certificate = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(intermediate_cert.subject) # Signed by Intermediate CA
.public_key(server_private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=validity_days)
)
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(fqdn)]),
critical=False,
)
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
encipher_only=False,
decipher_only=False,
key_agreement=False,
),
critical=True,
)
.sign(intermediate_private_key, hashes.SHA256())
)
server_cert_pem = server_certificate.public_bytes(serialization.Encoding.PEM)
return server_private_key_pem, server_cert_pem
def handler(event, context):
body = json.loads(event["body"])
bucket_name = os.environ.get("CERTIFICATE_BUCKET_NAME")
# Fetch Intermediate CA private key and certificate from S3
intermediate_private_key = s3_client.get_object(
Bucket=bucket_name, Key="intermediate_ca/private_key.pem"
)["Body"].read()
intermediate_cert = s3_client.get_object(
Bucket=bucket_name, Key="intermediate_ca/certificate.pem"
)["Body"].read()
# Fetch the certificate chain from S3
cert_chain_pem = s3_client.get_object(
Bucket=bucket_name, Key="intermediate_ca/certificate_chain.pem"
)["Body"].read()
# Create Server Certificate
server_private_key_pem, server_cert_pem = create_server_certificate(
intermediate_private_key,
intermediate_cert,
body["fqdn"],
body["country"],
body["state"],
body["organization"],
body["validity_days"],
)
s3_folder = f"server_certificates/{body["fqdn"]}/"
# Upload Server Certificate, Private Key to S3
s3_client.put_object(
Bucket=bucket_name,
Key=f"{s3_folder}private_key.pem",
Body=server_private_key_pem,
)
s3_client.put_object(
Bucket=bucket_name, Key=f"{s3_folder}certificate.pem", Body=server_cert_pem
)
# Import the certificate to ACM
response = acm_client.import_certificate(
Certificate=server_cert_pem,
PrivateKey=server_private_key_pem,
CertificateChain=cert_chain_pem,
)
return {
"statusCode": 200,
"body": json.dumps(
{
"message": "Server certificate created, uploaded to S3, and imported to ACM.",
"fqdn": body["fqdn"],
"s3_folder": s3_folder,
"acm_certificate_arn": response["CertificateArn"],
}
),
}
Conclusion
This blog covered everything from certificate chains to validating trust in IoT systems. Building your own self-service API is a good way learn about certificates and PKI. In production, however, always opt for managed solutions that offer automation, compliance, and scalability.
Certificates Are the Foundation of IoT Security
Certificates and CAs ensure secure, authenticated communication in IoT ecosystems.
The Role of Intermediate CAs
Delegating certificate issuance to Intermediate CAs improves scalability and limits exposure.
Trust Is Built on Hierarchies
Trust between devices and servers relies on shared Root CAs and validated certificate chains.
Build for Learning, Use Managed Services for Production
While this API is a great learning tool, services like AWS Private CA or Let’s Encrypt are better suited for production.
To get the full source code and deploy it your self, visit Serverless-Handbook Self Service IoT Certificate management
Final Words
Don't forget to follow me on LinkedIn and X for more content, and read rest of my Blogs
As Werner says! Now Go Build!
Top comments (0)