IaC AWS

Terraform Module: AWS IoT Core — register devices and least-privilege MQTT policies as code

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

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 configlive/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 configlive/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

TerraformAWSIoT CoreModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading