Guide · Amazon IoT Core · Protocols · Authentication · IoT Policy Documents

Amazon IoT Core for MCP Servers — Protocols, Authentication, and IoT Policy Fundamentals

MCP server tools that expose IoT device data as resources, dispatch remote commands, or react to sensor telemetry sit squarely in Amazon IoT Core territory. IoT Core is a managed MQTT broker that connects billions of devices to AWS services without managing servers — but its authentication model, IoT-specific policy language, and protocol options are distinct enough from standard AWS SDK patterns that the first integration often causes confusion. This guide covers the three connection protocols and when to use each, registering Things in the IoT registry, discovering your account-specific IoT endpoint, all authentication methods, and the IoT policy document model that is separate from IAM.

TL;DR

Amazon IoT Core connects devices via MQTT, HTTP, or WebSocket. For MCP server tools (server-side AWS SDK callers, not embedded devices), use the AWS IoT Data Plane API over HTTPS with standard IAM credentials — the IoTDataPlaneClient from @aws-sdk/client-iot-data-plane handles all data operations (publish, get/update shadow, list retained messages). Use the IoTClient from @aws-sdk/client-iot for control plane operations (register Things, manage certificates, create rules, manage jobs). IoT policy documents are separate from IAM policies and control what devices can do over MQTT; your MCP server uses IAM policies, not IoT policies. The IoT endpoint is per-account and per-region — retrieve it with DescribeEndpointCommand and cache it; it does not change.

Protocol decision table

IoT Core supports three connection protocols. The choice depends on what is connecting — an embedded device or a server-side MCP tool.

Protocol Use case Authentication Notes
MQTT over TLS (port 8883) Embedded devices with persistent connections, QoS 0 or 1, persistent sessions, retained messages X.509 client certificate (most common for devices) or Cognito (mobile) Bidirectional, event-driven; keeps connection alive with keep-alive pings; ideal for battery-limited or bandwidth-limited hardware
MQTT over WebSocket (port 443) Browser clients, React Native apps, environments where port 8883 is blocked by firewalls Cognito identity or IAM SigV4 (Amplify handles SigV4 automatically) Same MQTT semantics as direct MQTT; wraps MQTT frames in WebSocket framing; works through corporate proxies; higher overhead than raw MQTT
HTTPS (port 443) — IoT Data Plane API Server-side applications, MCP server tools, Lambda functions, any AWS SDK caller Standard IAM credentials (access key / role); SigV4 signing built into SDK Request-response model, not persistent; best for occasional publishes, shadow reads/writes, retained message queries; each call is an independent HTTPS request; no persistent session overhead

MCP server recommendation: use the HTTPS Data Plane API. Your MCP server already has an IAM role or access key; there is no reason to manage X.509 certificates just to call IoT Core from a server. The SDK client handles SigV4 signing, retries, and regional endpoint selection automatically.

Two SDK clients — control plane vs data plane

IoT Core splits into two SDK packages with separate endpoints:

// Control plane — registry, rules, certificates, jobs
// Endpoint: iot.{region}.amazonaws.com
import { IoTClient, DescribeEndpointCommand, CreateThingCommand } from '@aws-sdk/client-iot';

// Data plane — publish messages, read/write device shadows, retained messages
// Endpoint: {accountPrefix}-ats.iot.{region}.amazonaws.com
import { IoTDataPlaneClient, PublishCommand, GetThingShadowCommand } from '@aws-sdk/client-iot-data-plane';

const region = process.env.AWS_REGION ?? 'us-east-1';

// Control plane client — standard regional endpoint
const iotControl = new IoTClient({ region });

// Data plane client — requires the account-specific ATS endpoint
// Retrieve it once and cache; it never changes for the account lifetime
const { endpointAddress } = await iotControl.send(
  new DescribeEndpointCommand({ endpointType: 'iot:Data-ATS' })
);
// endpointAddress looks like: a1b2c3d4e5f678-ats.iot.us-east-1.amazonaws.com

const iotData = new IoTDataPlaneClient({
  region,
  endpoint: `https://${endpointAddress}`,
});

ATS endpoint vs legacy VeriSign endpoint: always use iot:Data-ATS as the endpoint type. The legacy VeriSign-signed endpoint (iot:Data) is deprecated and returns a different hostname. Devices registered against the legacy endpoint will not connect to the ATS endpoint without reconfiguring their trust store. For server-side calls, the ATS endpoint is strictly correct and the SDK validates the TLS certificate against Amazon's CA hierarchy automatically.

Caching the endpoint: DescribeEndpointCommand is a control plane call and costs nothing beyond the API request. Store the returned endpointAddress in an environment variable or configuration file. It is stable — it does not rotate, does not change when you add or remove devices, and does not vary by Thing name.

Registering Things in the IoT registry

An IoT Thing is a logical representation of a physical or virtual device in the registry. Things are not required for MQTT communication — a device can connect to IoT Core without being registered — but the registry enables fleet management, shadow documents, and job targeting by Thing Group. For MCP tools that manage a fleet of devices, the registry is essential.

import {
  IoTClient,
  CreateThingCommand,
  DescribeThingCommand,
  UpdateThingCommand,
  CreateThingGroupCommand,
  AddThingToThingGroupCommand,
} from '@aws-sdk/client-iot';

const iot = new IoTClient({ region: 'us-east-1' });

// Create a Thing — idempotent: re-creating with same name updates attributes
const { thingName, thingArn } = await iot.send(new CreateThingCommand({
  thingName: 'sensor-floor-3-rack-7',
  thingTypeName: 'TemperatureSensor',  // optional; must exist first
  attributePayload: {
    attributes: {
      // String values only; max 3 searchable attributes
      location: 'floor-3-rack-7',
      model: 'TMP-3000',
      firmware: '2.1.4',
    },
    merge: true,  // true = merge with existing; false = replace all attributes
  },
}));

console.log('Thing ARN:', thingArn);
// arn:aws:iot:us-east-1:123456789012:thing/sensor-floor-3-rack-7

// Describe — read the current registration
const thing = await iot.send(new DescribeThingCommand({
  thingName: 'sensor-floor-3-rack-7',
}));
console.log(thing.attributes);  // { location: 'floor-3-rack-7', ... }
console.log(thing.version);     // integer incremented on every update

// Update attributes on an existing Thing
await iot.send(new UpdateThingCommand({
  thingName: 'sensor-floor-3-rack-7',
  attributePayload: {
    attributes: { firmware: '2.2.0' },
    merge: true,  // only update firmware; leave other attributes unchanged
  },
  // Optimistic locking — omit to skip version check
  expectedVersion: thing.version,
}));

// Organize Things into groups (for targeting jobs and rules)
await iot.send(new CreateThingGroupCommand({
  thingGroupName: 'floor-3-sensors',
  thingGroupProperties: {
    thingGroupDescription: 'All temperature sensors on floor 3',
    attributePayload: { attributes: { building: 'HQ', floor: '3' } },
  },
}));

await iot.send(new AddThingToThingGroupCommand({
  thingName: 'sensor-floor-3-rack-7',
  thingGroupName: 'floor-3-sensors',
}));

Thing Types: a Thing Type is a template that defines a set of searchable attributes for Things of that type. Create a Thing Type with CreateThingTypeCommand before referencing it in CreateThingCommand. Thing Types are immutable after creation except for their description. Deprecating a Thing Type with DeprecateThingTypeCommand prevents new Things from being assigned to it but does not affect existing Things.

searchable attributes: IoT Core Fleet Indexing (enabled separately via UpdateIndexingConfigurationCommand) indexes Thing attributes and allows queries like attributes.firmware:2.1.* AND attributes.location:floor-3-*. Without Fleet Indexing, you can still look up individual Things by name but cannot do attribute-based searches across the fleet.

Authentication methods

IoT Core supports four authentication methods. The right choice depends on whether the caller is a physical device or a server-side application.

Method Best for Credential type Key detail
X.509 client certificates Embedded devices (microcontrollers, Raspberry Pi, industrial gateways) RSA or ECC private key + certificate issued by IoT Core or your own CA Certificate is attached to an IoT Policy; must be active and not revoked; certificate rotation requires pushing a new cert to the device
IAM + SigV4 (via HTTPS or WebSocket) Server-side MCP tools, Lambda, EC2, ECS — any AWS SDK caller AWS access key / instance profile / role credentials — the standard AWS credential chain IoT Data Plane API requests are signed with SigV4; the calling IAM principal needs iot:* actions on IoT resource ARNs; no IoT Policy needed
Cognito Identity Pools Mobile apps, browser apps where end-users authenticate with a user pool or social provider Cognito identity ID → temporary IAM credentials Unauthenticated identities can connect with limited permissions; authenticated identities get the IAM role you assigned; uses MQTT over WebSocket
Custom Authorizer (Lambda) Devices that authenticate with bearer tokens, JWT, or username/password rather than X.509 Bearer token or username/password in MQTT CONNECT packet IoT Core invokes a Lambda function to validate the token and return an IAM-style policy; useful for migrating devices from a third-party broker without re-provisioning certificates

MCP server rule: always use IAM + SigV4 for server-side MCP tools. Certificate management and rotation is non-trivial operational overhead that is not necessary when the caller is a server process with an IAM role. The IoT Data Plane API accepts standard SigV4 requests and the SDK handles signing transparently.

IoT Policy documents vs IAM policies

This is the most common source of confusion in IoT Core integrations. There are two separate policy systems:

Your MCP server never needs an IoT policy. IoT policies are for the devices. Your MCP server needs an IAM policy.

// IAM policy for an MCP server that reads device shadows and publishes commands
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "IoTControlPlaneRead",
      "Effect": "Allow",
      "Action": [
        "iot:DescribeThing",
        "iot:ListThings",
        "iot:DescribeEndpoint",
        "iot:SearchIndex"
      ],
      "Resource": "*"
    },
    {
      "Sid": "IoTDataPlane",
      "Effect": "Allow",
      "Action": [
        "iot:Publish",
        "iot:GetThingShadow",
        "iot:UpdateThingShadow",
        "iot:DeleteThingShadow",
        "iot:ListNamedShadowsForThing"
      ],
      "Resource": [
        "arn:aws:iot:us-east-1:123456789012:topic/*",
        "arn:aws:iot:us-east-1:123456789012:thing/*"
      ]
    }
  ]
}

An IoT policy attached to a device certificate looks similar in structure but uses IoT-specific actions and variable substitution:

// IoT policy attached to a device X.509 certificate — NOT for MCP servers
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      // Policy variable ${iot:Connection.Thing.ThingName} prevents one certificate
      // from impersonating another Thing's clientId
      "Resource": "arn:aws:iot:us-east-1:123456789012:client/${iot:Connection.Thing.ThingName}"
    },
    {
      "Effect": "Allow",
      "Action": ["iot:Publish", "iot:Receive"],
      "Resource": [
        "arn:aws:iot:us-east-1:123456789012:topic/devices/${iot:Connection.Thing.ThingName}/*",
        "arn:aws:iot:us-east-1:123456789012:topic/$aws/things/${iot:Connection.Thing.ThingName}/shadow/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "iot:Subscribe",
      "Resource": [
        "arn:aws:iot:us-east-1:123456789012:topicfilter/devices/${iot:Connection.Thing.ThingName}/*",
        "arn:aws:iot:us-east-1:123456789012:topicfilter/$aws/things/${iot:Connection.Thing.ThingName}/shadow/*"
      ]
    }
  ]
}

Policy variable ${iot:Connection.Thing.ThingName}: this substitution binds a certificate to only the Thing it is registered against. Without it, any device with the certificate could connect as any clientId and publish to any Thing's shadow — a security hole in multi-tenant fleets. This variable is only available in IoT policies, not IAM policies.

Connect action vs Publish/Subscribe: iot:Connect governs whether a device can establish an MQTT connection. iot:Publish governs whether it can send messages to a topic. iot:Subscribe governs whether it can register a subscription filter. iot:Receive governs whether messages that match a subscription are delivered. All four must be allowed for a device to function; restricting any one breaks the flow.

Publishing messages from an MCP server tool

An MCP server can publish MQTT messages to IoT Core topics via the HTTP Data Plane API. This is the standard way to send commands to devices — publish to a topic the device subscribes to, and the device receives it over its persistent MQTT connection.

import { IoTDataPlaneClient, PublishCommand } from '@aws-sdk/client-iot-data-plane';

const iotData = new IoTDataPlaneClient({
  region: 'us-east-1',
  endpoint: `https://${process.env.IOT_ENDPOINT}`,
});

// Send a command to a specific device via its command topic
async function sendDeviceCommand(
  thingName: string,
  command: Record
): Promise {
  const topic = `devices/${thingName}/commands`;
  const payload = JSON.stringify({
    ...command,
    timestamp: Date.now(),
    requestId: crypto.randomUUID(),
  });

  await iotData.send(new PublishCommand({
    topic,
    payload: Buffer.from(payload),
    // QoS 0 = fire-and-forget (default); QoS 1 = at-least-once delivery acknowledgment
    // HTTP publish only supports QoS 0 and 1; QoS 2 (exactly-once) is not available
    qos: 1,
    // retain: true — IoT Core stores the last message on this topic and delivers
    // it to new subscribers immediately; use for "last known command" patterns
    retain: false,
  }));
}

// Broadcast to all devices in a topic namespace
async function broadcastFirmwareUpdateNotification(
  firmwareVersion: string,
  downloadUrl: string
): Promise {
  await iotData.send(new PublishCommand({
    topic: 'devices/broadcast/firmware',
    payload: Buffer.from(JSON.stringify({ firmwareVersion, downloadUrl })),
    qos: 0,
  }));
}

QoS semantics: QoS 0 (at-most-once) delivers the message once with no acknowledgment — the device may or may not receive it depending on connection state. QoS 1 (at-least-once) stores the message and retries delivery until the broker receives an acknowledgment from the device. If the device is offline, QoS 1 messages queue until the device reconnects (if the device connected with cleanSession: false). For critical commands (turn off a valve, trigger an alert), use QoS 1. For high-frequency telemetry republishing, QoS 0 is acceptable.

Retained messages: setting retain: true causes IoT Core to store the most recent message on that topic. Any new subscriber receives the retained message immediately on subscription, before any new publishes arrive. Use this for status topics where a new device (or a reconnecting MCP server) should see the last-known state without waiting for the next publish. Only one retained message per topic is stored — each retained publish replaces the previous one.

Listing Things and querying device status

MCP tools frequently need to enumerate devices or look up which devices match certain criteria. Without Fleet Indexing, the only lookup is by exact Thing name or pagination through all Things.

import { ListThingsCommand, SearchIndexCommand } from '@aws-sdk/client-iot';

// Paginate all registered Things — no Fleet Indexing required
async function* listAllThings() {
  let nextToken: string | undefined;
  do {
    const { things, nextToken: next } = await iot.send(new ListThingsCommand({
      maxResults: 250,
      nextToken,
      // Optional filters (can combine both):
      thingTypeName: 'TemperatureSensor',
      attributeName: 'location',
      attributeValue: 'floor-3',  // exact match only without Fleet Indexing
    }));
    for (const thing of things ?? []) yield thing;
    nextToken = next;
  } while (nextToken);
}

// Fleet Indexing — wildcard and range queries across all Things
// Requires: UpdateIndexingConfigurationCommand to enable Thing indexing first
async function findThingsByFirmware(firmwarePrefix: string) {
  const { things } = await iot.send(new SearchIndexCommand({
    indexName: 'AWS_Things',  // the default index; always named AWS_Things
    queryString: `attributes.firmware:${firmwarePrefix}* AND thingTypeName:TemperatureSensor`,
    maxResults: 100,
  }));
  return things ?? [];
}

Fleet Indexing overhead: enabling Fleet Indexing adds a per-Thing cost (approximately $0.25 per million thing-hours indexed) and a per-search cost. For small fleets (under 1,000 Things), the cost is negligible. For large fleets, it is essential — paginating ListThingsCommand across 100,000 Things to find the ones with a specific firmware version would be impractically slow and expensive.

Common errors and their causes

Error / symptom Cause Fix
UnauthorizedException on Data Plane calls The IAM policy does not include the data plane action (iot:Publish, iot:GetThingShadow, etc.) for the targeted resource ARN, or the endpoint URL is wrong (pointing to control plane instead of data plane endpoint) Verify the IAM policy actions and resource ARNs; confirm you retrieved the endpoint with endpointType: 'iot:Data-ATS' and that the IoTDataPlaneClient uses that endpoint, not the standard iot.{region}.amazonaws.com
ResourceNotFoundException on GetThingShadowCommand No shadow document exists for the Thing yet — it is created on the first UpdateThingShadow call, not on Thing registration Catch the error and treat it as an empty/unknown state; create the shadow on the first UpdateThingShadow call when the device first reports its state
Device connects successfully but cannot receive publishes from MCP server The device's IoT policy allows iot:Subscribe but not iot:Receive on the topic filter, or the device subscribes to devices/+/commands but the MCP server publishes to devices/{name}/commands using a different topic format Verify the IoT policy includes both iot:Subscribe (on the topicfilter ARN) and iot:Receive (on the topic ARN); confirm topic naming convention matches between publisher and subscriber
InvalidRequestException: The endpoint type is not valid Passing endpointType: 'iot:Data' (deprecated VeriSign endpoint) instead of iot:Data-ATS' Always use iot:Data-ATS; the legacy VeriSign endpoint is deprecated and returns different CA certificates
Publishes from MCP server never arrive at device The device connected with cleanSession: true and was offline; QoS 0 messages are dropped for offline devices with clean sessions Use QoS 1 for important commands; ensure devices connect with cleanSession: false (persistent session) to receive queued QoS 1 messages on reconnect
ThrottlingException on PublishCommand IoT Core data plane default limit is 20,000 inbound publishes/second per account per region; bulk MCP tools sending commands to many devices simultaneously can exceed this Use exponential backoff with jitter; batch device targets using Thing Groups and IoT Jobs instead of individual publishes; request a limit increase via Service Quotas