If you run a home automation setup with LoRaWAN sensors and AWS IoT Core, you will eventually want to manage your sensors as infrastructure code using AWS CDK. The idea is appealing: your sensor list lives in a Python file, is versioned in git, and is deployed with a single cdk deploy. The reality, however, requires a bit more care than just reaching for CfnWirelessDevice.

This article covers three practical topics:

  1. Why the native CloudFormation resource breaks sensor sessions and how to fix it with a custom resource.
  2. How to store LoRaWAN credentials securely in SSM Parameter Store without paying for Secrets Manager.
  3. What you gain by managing sensors as code.

The Problem with CfnWirelessDevice

AWS CDK exposes aws_cdk.aws_iotwireless.CfnWirelessDevice, which maps directly to the CloudFormation resource AWS::IoTWireless::WirelessDevice. At first glance this looks like the right tool: you declare your sensor, run cdk deploy, and it appears in AWS IoT Core.

The problem surfaces the moment you destroy the stack or the resource is replaced. The CloudFormation resource AWS::IoTWireless::WirelessDevice does not support a DeletionPolicy attribute. When CloudFormation removes it, AWS deletes the wireless device registration on the IoT Core side. The consequence is not just an API call, it is a broken network session.

In LoRaWAN, a device that uses Over-The-Air Activation (OTAA) negotiates a session with the network server during a procedure called a Join. AWS IoT Core stores that session. When you delete the CfnWirelessDevice resource and recreate it, AWS creates a fresh registration with no session. The sensor still has its old session keys and keeps sending frames that AWS can no longer decrypt. To recover, the sensor must perform a new Join, which on many battery-powered devices requires a physical restart or pressing a button.

For a sensor mounted on a gas meter in a basement or glued to a water pipe, this is not acceptable.

The Fix: A Custom Resource

The solution is to bypass CfnWirelessDevice entirely and use an AWS CDK custom resource backed by a Lambda function. The Lambda calls the iotwireless boto3 client directly and, critically, controls what happens on each lifecycle event.

The key behavior to implement:

  • Create: before calling create_wireless_device, check whether a device with this DevEUI already exists. If it does, call the update path instead of creating a new one. This makes the resource idempotent and safe to redeploy on a fresh stack.
  • Update: call update_wireless_device to change mutable attributes such as the name, description, or destination. The session is never touched.
  • Delete: by default, do nothing. The sensor keeps its registration. Only delete the AWS registration if the device is explicitly flagged with delete_on_delete=True.

Here is the simplified handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def handler(event: dict, _: LambdaContext) -> dict | None:
    request_type = event["RequestType"]
    event["ResourceProperties"].pop("ServiceToken")
    iot_sensor_property = IotSensorCRProperties(**event["ResourceProperties"])

    match request_type:
        case "Create":
            if check_if_wireless_device_exist(dev_eui=iot_sensor_property.dev_eui):
                return on_update(iot_sensor_property=iot_sensor_property)
            else:
                return on_create(iot_sensor_property=iot_sensor_property)
        case "Update":
            return on_update(iot_sensor_property=iot_sensor_property)
        case "Delete":
            return on_delete(iot_sensor_property=iot_sensor_property)

The on_delete function checks the delete_on_delete flag before doing anything:

1
2
3
4
def on_delete(iot_sensor_property: IotSensorCRProperties) -> None:
    if iot_sensor_property.delete_on_delete is True:
        sensor_id = get_sensor_resource_id(dev_eui=iot_sensor_property.dev_eui)
        iot_wireless_client.delete_wireless_device(Id=sensor_id)

The CDK stack creates a Provider that wraps this Lambda and then uses a CustomResource construct for each sensor:

1
2
3
4
5
6
7
CustomResource(
    self,
    id=f"IotWirelessCR{lora_device.dev_eui}",
    service_token=props.cr_iot_sensor_provider_service_token,
    resource_type="Custom::IotSensor",
    properties=iot_sensor_cr_properties.to_dict(),
)

With this pattern, destroying and recreating the CDK stack leaves the AWS IoT Core registration and the sensor session completely untouched.

Storing Credentials Securely in SSM Parameter Store

Each LoRaWAN sensor that uses OTAA has an AppKey, a 128-bit secret used during the Join procedure. You must not store this value in plaintext in your CDK code or in your git repository.

The obvious AWS-native option is Secrets Manager, but it costs $0.40 per secret per month. For a home setup with a dozen sensors, the LoRaWAN credentials never rotate (if you change LoRaWAN provider you will rejoin anyway), and $5 per month for static secrets is hard to justify. SSM Parameter Store with SecureString type encrypts the value using KMS and costs nothing for standard parameters.

The sensor credentials are stored as a JSON string at the path /iot_core/lora/sensor/{dev_eui}. A single parameter holds all the fields for a given sensor:

1
2
3
4
5
6
7
8
{
  "DevEui": "A84041D558600A75",
  "AppEui": "A840410000000101",
  "AppKey": "...",
  "model": "LTH52",
  "SN": "HT526294132",
  "name": "HT_1"
}

Storing additional metadata such as the serial number and the AT pin is useful: if you ever need to reconfigure the sensor over USB or switch LoRaWAN provider, everything you need is in SSM.

A small Python script handles the initial population of these parameters. You run it once when you receive a new sensor, passing the credentials you either read from the device sticker or generated yourself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def store_device_parameter_in_ssm(dev_eui, app_eui, app_key, others=None):
    parameter_name = f"{SSM_PREFIX}{dev_eui.lower()}"
    otaa_values = {"DevEui": dev_eui, "AppEui": app_eui, "AppKey": app_key}
    if others is not None:
        otaa_values = otaa_values | others
    ssm_client.put_parameter(
        Name=parameter_name,
        Value=json.dumps(otaa_values),
        Type="SecureString",
        Overwrite=True,
    )

Calling it for a new sensor looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
store_device_parameter_in_ssm(
    dev_eui="A84041D558600A75",
    app_eui="A840410000000101",
    app_key="<your-app-key>",
    others={
        "model": "LTH52",
        "SN": "HT526294132",
        "name": "HT_1",
    },
)

The CDK code and the git repository never see the AppKey. In OtaaV10x, you set app_key_in_ssm=True, and the custom resource Lambda resolves the value from SSM at deploy time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@dataclass
class OtaaV10x:
    app_eui: str
    app_key: str | None = None
    app_key_in_ssm: bool = False

    def get_app_key(self, scope, dev_eui: str) -> str | dict:
        if self.app_key_in_ssm:
            return vars(IotSSMParameterValue(
                parameter_name=f"{SSM_PREFIX}{dev_eui.lower()}",
                is_secure_string=True,
            ))
        return self.app_key

The Lambda has an IAM policy that allows ssm:GetParameter on the /iot_core/lora/sensor/* prefix and kms:Decrypt on the SSM default key. Nothing else can read those parameters.

Declaring Sensors in CDK

Once the custom resource and the SSM script are in place, adding a new sensor is a one-liner in the device list:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
lorawan_devices_1 = [
    LoraDevice(
        dev_eui="a84041d558600a75",
        identification_property=OtaaV10x(
            app_eui="A840410000000101",
            app_key_in_ssm=True,
        ),
        device_type=DeviceType.DRAGINO_LTH52_HT,
        device_mqtt_topic=IoTCoreMQTTTopic.HT_DRAGINO_LTH52,
        name="HT_1 Dragino Sous-Evier",
        description="Temperature and humidity sensor under the sink",
        disconnection_alarm_enabled=True,
    ),
    DraginoLTH52HTSensor(
        dev_eui="a8404121d7600a73",
        name="HT_2 Dragino Living Room",
        description="",
        disconnection_alarm_enabled=False,
    ),
]

Subclasses like DraginoLTH52HTSensor pre-fill the app_eui, device_type, and device_mqtt_topic so you only specify what varies between sensors of the same model.

What You Gain by Managing Sensors as Code

Renaming is trivial. Changing the name field triggers an update in the custom resource, which calls update_wireless_device. The sensor session is untouched, only the display name in the AWS console changes.

DynamoDB as a configuration store. For each sensor, the stack can write a configuration record to a DynamoDB table at deploy time. This record holds device type, reporting interval, calibration offsets, or any other value your processing Lambda needs. The processing Lambda reads this at runtime. Adding a new sensor also provisions its configuration row.

Git is your audit log. Every sensor you ever owned is in the git history. If a sensor was assigned to a different room three months ago, git log will show it. If you swap a broken sensor with a replacement and reassign the DevEUI, the commit message explains why.

CloudWatch alarms per sensor. The DevicesInstantiationNestedStack creates a CloudWatch alarm for each sensor where disconnection_alarm_enabled=True. The alarm fires if no message is received within disconnection_delay_minutes. You get an SNS email when a sensor stops reporting, without writing any monitoring code by hand.

Battery alarms. For sensors that report battery level, the stack creates a second alarm that triggers when the battery drops below a configurable threshold. Again, this comes for free from the CDK declaration.

Conclusion

The native CfnWirelessDevice resource works for a first experiment but is not suitable for production use because it offers no way to prevent deletion from breaking a live sensor session. A custom resource Lambda solves this by decoupling the CloudFormation lifecycle from the IoT Core registration lifecycle.

SSM Parameter Store with SecureString is a practical and cost-effective alternative to Secrets Manager for LoRaWAN credentials that never rotate. A small Python bootstrap script populates the parameters before the first cdk deploy, and the Lambda resolves them at runtime.

Beyond infrastructure provisioning, managing sensors as CDK code brings concrete operational benefits: free renaming, a DynamoDB configuration row per sensor, per-sensor CloudWatch alarms, and a git history that tells the full story of your sensor fleet.