Quick take — A reusable Terraform module for AWS IoT Core that provisions an IoT Thing, a scoped IoT policy, and an X.509 certificate attachment so each fleet device gets least-privilege MQTT access by default. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "iot_core" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iot-core?ref=v1.0.0"
thing_name = "..." # Name of the IoT Thing; also the required MQTT client ID…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS IoT Core is the managed message broker and device registry that sits between physical devices and the rest of AWS. Devices authenticate with X.509 certificates, connect over MQTT (or MQTT-over-WebSocket / HTTPS), and publish and subscribe to topics that are governed by an IoT policy — a JSON policy attached to the certificate that decides which topics a device may Connect, Publish, Subscribe, and Receive on.
The two primitives you provision per device are an aws_iot_thing (the entry in the device registry, optionally typed and described by attributes) and an aws_iot_policy (the permission boundary). The hard part is not creating them — it is getting the policy scoped correctly. A copy-pasted iot:* on arn:aws:iot:*:*:* is the single most common IoT Core security mistake: it lets any one compromised device impersonate every other device, subscribe to fleet-wide topics, and update the registry.
This module wraps that pattern into a reusable unit. It creates the Thing, builds an IoT policy whose statements are templated to the device’s own thing name (using the ${iot:Connection.Thing.ThingName} policy variable so the broker enforces it at connect time), and optionally attaches an existing certificate ARN to both the Thing and the policy in one shot. The result: every device you stamp out gets a registry entry plus a least-privilege MQTT boundary by construction, instead of by code-review luck.
When to use it
- You are onboarding a fleet of devices (sensors, gateways, vehicles, point-of-sale terminals) and want each one to have an identical, auditable IoT identity created from a loop or a
for_each. - You want per-thing topic isolation — device
floor3-sensor-014should only be able to publish todt/floor3-sensor-014/...and never to another device’s topic — enforced by the broker, not by application code. - You provision certificates out-of-band (CSR signing on the device, a CI job, or AWS IoT certificate creation elsewhere) and just need to bind an existing certificate ARN to a new Thing + policy atomically.
- You want IoT identity changes (a widened topic, a new device type) to go through pull-request review and
terraform planrather than ad-hoc console clicks.
Reach for the raw resources directly only when you are doing one-off experimentation, or when you need fleet provisioning templates / just-in-time provisioning, which are a different (claim-certificate) workflow than static registration.
Module structure
terraform-module-aws-iot-core/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Region/account-aware ARN prefixes used to scope the policy to this device only.
client_arn = "arn:${data.aws_partition.current.partition}:iot:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:client"
topic_arn = "arn:${data.aws_partition.current.partition}:iot:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:topic"
topicfilter_arn = "arn:${data.aws_partition.current.partition}:iot:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:topicfilter"
# A device may only use a client ID equal to its own thing name, and may only
# touch topics namespaced under that thing name. ${iot:Connection.Thing.ThingName}
# is resolved by the broker from the attached certificate at connect time.
publish_resources = [for t in var.publish_topics : "${local.topic_arn}/${t}"]
subscribe_resources = [for t in var.subscribe_topics : "${local.topicfilter_arn}/${t}"]
receive_resources = [for t in var.subscribe_topics : "${local.topic_arn}/${t}"]
}
data "aws_partition" "current" {}
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
resource "aws_iot_thing" "this" {
name = var.thing_name
thing_type_name = var.thing_type_name
attributes = var.attributes
}
data "aws_iam_policy_document" "device" {
# Connect only with a client ID equal to this thing's name.
statement {
sid = "Connect"
effect = "Allow"
actions = ["iot:Connect"]
resources = ["${local.client_arn}/$${iot:Connection.Thing.ThingName}"]
}
dynamic "statement" {
for_each = length(local.publish_resources) > 0 ? [1] : []
content {
sid = "Publish"
effect = "Allow"
actions = ["iot:Publish"]
resources = local.publish_resources
}
}
dynamic "statement" {
for_each = length(local.subscribe_resources) > 0 ? [1] : []
content {
sid = "Subscribe"
effect = "Allow"
actions = ["iot:Subscribe"]
resources = local.subscribe_resources
}
}
dynamic "statement" {
for_each = length(local.receive_resources) > 0 ? [1] : []
content {
sid = "Receive"
effect = "Allow"
actions = ["iot:Receive"]
resources = local.receive_resources
}
}
}
resource "aws_iot_policy" "this" {
name = var.policy_name != null ? var.policy_name : "${var.thing_name}-policy"
policy = data.aws_iam_policy_document.device.json
tags = var.tags
}
# Optionally bind an existing certificate to both the policy and the thing.
resource "aws_iot_policy_attachment" "this" {
count = var.certificate_arn != null ? 1 : 0
policy = aws_iot_policy.this.name
target = var.certificate_arn
}
resource "aws_iot_thing_principal_attachment" "this" {
count = var.certificate_arn != null ? 1 : 0
thing = aws_iot_thing.this.name
principal = var.certificate_arn
}
variables.tf
variable "thing_name" {
description = "Name of the IoT Thing (also used as the required MQTT client ID)."
type = string
validation {
# IoT Thing names: letters, numbers, and :_- ; up to 128 chars.
condition = can(regex("^[a-zA-Z0-9:_-]{1,128}$", var.thing_name))
error_message = "thing_name must be 1-128 chars of [a-zA-Z0-9:_-]."
}
}
variable "thing_type_name" {
description = "Optional IoT Thing Type to associate the thing with. Must already exist."
type = string
default = null
}
variable "attributes" {
description = "Searchable key/value attributes stored on the thing (e.g. firmware, site)."
type = map(string)
default = {}
validation {
condition = length(var.attributes) <= 3
error_message = "AWS IoT supports a maximum of 3 attributes per thing."
}
}
variable "policy_name" {
description = "Name of the IoT policy. Defaults to \"<thing_name>-policy\" when null."
type = string
default = null
}
variable "publish_topics" {
description = <<-EOT
Topic patterns this device may PUBLISH to (no leading slash). Use the
$${iot:Connection.Thing.ThingName} policy variable to keep them per-device,
e.g. ["dt/$${iot:Connection.Thing.ThingName}/telemetry"].
EOT
type = list(string)
default = []
}
variable "subscribe_topics" {
description = <<-EOT
Topic-filter patterns this device may SUBSCRIBE to and RECEIVE on, e.g.
["cmd/$${iot:Connection.Thing.ThingName}/#"]. MQTT wildcards + and # are allowed.
EOT
type = list(string)
default = []
}
variable "certificate_arn" {
description = "Optional existing X.509 certificate ARN to attach to the thing and policy."
type = string
default = null
validation {
condition = var.certificate_arn == null || can(regex("^arn:aws[a-z-]*:iot:[a-z0-9-]+:[0-9]{12}:cert/[0-9a-f]{64}$", var.certificate_arn))
error_message = "certificate_arn must be a valid IoT certificate ARN (…:cert/<64 hex>)."
}
}
variable "tags" {
description = "Tags applied to taggable resources (the IoT policy)."
type = map(string)
default = {}
}
outputs.tf
output "thing_name" {
description = "Name of the IoT Thing (use as the MQTT client ID)."
value = aws_iot_thing.this.name
}
output "thing_arn" {
description = "ARN of the IoT Thing."
value = aws_iot_thing.this.arn
}
output "thing_id" {
description = "Internal ID of the IoT Thing."
value = aws_iot_thing.this.id
}
output "default_client_id" {
description = "The client ID the device must use to connect (equals thing_name)."
value = aws_iot_thing.this.default_client_id
}
output "policy_name" {
description = "Name of the attached IoT policy."
value = aws_iot_policy.this.name
}
output "policy_arn" {
description = "ARN of the IoT policy."
value = aws_iot_policy.this.arn
}
output "certificate_attached" {
description = "Whether a certificate ARN was attached to the thing and policy."
value = var.certificate_arn != null
}
How to use it
Provision a temperature sensor that may only publish its own telemetry and receive its own commands, then feed its policy into an IoT Rule and its telemetry topic into a downstream subscription.
module "iot_core" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iot-core?ref=v1.0.0"
thing_name = "floor3-sensor-014"
thing_type_name = "temperature-sensor"
attributes = {
site = "blr-campus-1"
firmware = "2.4.1"
}
# Per-device topic isolation enforced by the broker via the policy variable.
publish_topics = [
"dt/$${iot:Connection.Thing.ThingName}/telemetry",
]
subscribe_topics = [
"cmd/$${iot:Connection.Thing.ThingName}/#",
]
# Certificate created out-of-band (device CSR signed by a CI job).
certificate_arn = "arn:aws:iot:ap-south-1:123456789012:cert/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
tags = {
environment = "production"
fleet = "hvac-sensors"
}
}
# Downstream: route this device's telemetry into a Kinesis stream via an IoT Rule,
# referencing the thing name output to build the topic filter.
resource "aws_iot_topic_rule" "telemetry_to_kinesis" {
name = "route_${replace(module.iot_core.thing_name, "-", "_")}_telemetry"
enabled = true
sql = "SELECT * FROM 'dt/${module.iot_core.thing_name}/telemetry'"
sql_version = "2016-03-23"
kinesis {
role_arn = aws_iam_role.iot_rule.arn
stream_name = "telemetry-ingest"
partition_key = "$${newuuid()}"
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/iot_core/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iot-core?ref=v1.0.0"
}
inputs = {
thing_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/iot_core && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
thing_name |
string |
— | Yes | Name of the IoT Thing; also the required MQTT client ID. 1-128 chars of [a-zA-Z0-9:_-]. |
thing_type_name |
string |
null |
No | Existing IoT Thing Type to associate with the thing. |
attributes |
map(string) |
{} |
No | Searchable key/value attributes on the thing (max 3, enforced by validation). |
policy_name |
string |
null |
No | IoT policy name; defaults to <thing_name>-policy. |
publish_topics |
list(string) |
[] |
No | Topic patterns the device may publish to (no leading slash). |
subscribe_topics |
list(string) |
[] |
No | Topic-filter patterns the device may subscribe to and receive on. |
certificate_arn |
string |
null |
No | Existing X.509 certificate ARN to attach to the thing and policy. Validated against the IoT cert ARN format. |
tags |
map(string) |
{} |
No | Tags applied to the IoT policy. |
Outputs
| Name | Description |
|---|---|
thing_name |
Name of the IoT Thing (use as the MQTT client ID). |
thing_arn |
ARN of the IoT Thing. |
thing_id |
Internal ID of the IoT Thing. |
default_client_id |
Client ID the device must connect with (equals thing_name). |
policy_name |
Name of the attached IoT policy. |
policy_arn |
ARN of the IoT policy. |
certificate_attached |
Whether a certificate ARN was attached to the thing and policy. |
Enterprise scenario
A facilities-management company runs HVAC and air-quality sensors across 40 office campuses, each campus a separate AWS account in an Organization. A CI pipeline reads the asset register, and for every newly commissioned sensor it calls this module in a for_each over the device list, passing each device’s pre-signed certificate ARN and a publish_topics of dt/${iot:Connection.Thing.ThingName}/telemetry. Because the policy is templated to each thing’s own name, a sensor that is later compromised in a maintenance closet can only spoof its own telemetry topic — it cannot subscribe to the building-management command channel or impersonate the rooftop chiller controller, which keeps a single device breach from becoming a campus-wide incident.
Best practices
- Never widen past the thing-name policy variable. Keep
${iot:Connection.Thing.ThingName}in every topic and theiot:Connectclient resource; this is what stops a device from publishing to or subscribing on another device’s topics. Audit policies with AWS IoT Device Advisor before fleet rollout. - One certificate, one device. Attach a unique X.509 certificate per thing (this module’s
certificate_arn) rather than sharing one cert across a fleet — shared certs make rotation and revocation all-or-nothing. Enable AWS IoT Device Defender to flag certs used from multiple client IDs. - Prefer Subscribe + Receive scoping over Connect-only. Granting
iot:Subscribewithout scopingiot:Receiveto the same topics lets a device receive on a wildcard it merely subscribed to; this module scopes both fromsubscribe_topicsso the two stay aligned. - Control cost at the topic and message level. IoT Core bills per million messages and per connection-minute, so design topics so devices publish batched telemetry on a sane interval instead of one message per reading, and use Basic Ingest (
$aws/rules/...) to skip message-broker charges when you only need to hit a rule. - Name things by physical identity, not sequence. Use stable, location-anchored names like
floor3-sensor-014(which become the client ID and topic namespace) so logs, billing tags, and the registry all correlate to the real asset — renaming later orphans certificates and rules. - Pin the module by tag and tag the policy. Reference
?ref=v1.0.0so a fleet-wide policy change is a deliberate version bump, and propagateenvironment/fleettags onto the policy so cost allocation and incident response can slice by device group.