top of page

LoRaWAN Security Explained: Why "AES-128 Encrypted" Does Not Mean Authenticated End to End, and When You Need Application-Layer AES-CMAC

Writer: Srihari Maddula
Srihari Maddula
6 hours ago
16 min read
Close-up of a dark blue circuit board
Photo: Vishnu Mohanan / Unsplash

"LoRaWAN is secure, it uses AES-128." Almost everyone who has deployed a LoRa sensor network has said this sentence or nodded through it in a meeting. The first half is true. The second half is a statement about a cipher, and it answers a different question from the one a security reviewer asks.

The belief worth dismantling is this: that AES-128 in the LoRaWAN stack means your sensor readings are confidential and tamper-proof from the sensor to the application that acts on them. It means something narrower. It means two specific keys protect two specific things between the device and two specific servers, and whether that is enough depends on who runs those servers, which LoRaWAN version you are on, how you provisioned the device, and what a forged or replayed reading could cost you.

This post takes the belief apart piece by piece, shows exactly where the gaps sit, and then shows the fix that has worked on private industrial networks: a small application-layer authentication tag using AES-CMAC, costing about eight bytes per uplink. The corrected mental model, in one line: LoRaWAN secures the radio link and the network join; it does not, on its own, give your application proof that a reading came from a specific sensor, unmodified, exactly once.


What the Belief Gets Right

LoRaWAN's security is real engineering, not decoration. Every device carries a root key that never crosses the air interface. In an over-the-air activation (OTAA) join, the device and the network derive fresh session keys from that root key plus nonces exchanged in the join request and accept. Session keys, not the root key, protect traffic. Every uplink carries a 32-bit message integrity code (MIC) computed with AES-CMAC, and payloads are encrypted with AES-128 in a counter-style mode. A frame counter increments with every message, and servers reject counters that do not move forward.

For a consumer-grade deployment, on a managed public network, carrying low-stakes readings, that combination is a reasonable baseline and far better than the unauthenticated sub-GHz protocols it displaced. The argument below is not that LoRaWAN is weak. It is that the properties people assume are present are not all present, and the missing ones matter more as the stakes of a reading rise from "room temperature" to "pump trip", "valve position", or "perimeter alarm".


The Two Keys, and Who Holds Them

In the widely deployed 1.0.x family, a joined device holds two session keys:

Key

Protects

Who uses it

NwkSKey

Frame integrity (the MIC), MAC commands

Device and network server

AppSKey

Application payload encryption

Device and application server

The split is the design's central idea. The network operator should be able to route and authenticate frames without being able to read the payload, and the application owner reads payloads without running radio infrastructure.

Here is the first crack. In 1.0.x OTAA, the join procedure is handled by the network server, which derives both session keys from the root key, then hands the application session key onward to the application server. The operator's server participates in creating the key that is supposed to keep the operator out. On a public network run by a third party, that is a trust decision, whether or not anyone made it deliberately. LoRaWAN 1.1 addresses it by introducing a separate join server and splitting the network session key into several keys with distinct jobs, so that the network server never holds the application session key.

Property

1.0.x (typical)

1.1

Root keys

One AppKey

Separate NwkKey and AppKey

Join handling

Network server derives both session keys

Join server derives; network server does not see AppSKey

Network session keys

Single NwkSKey

FNwkSIntKey, SNwkSIntKey, NwkSEncKey

Counters

Frame counters

Separate counters for uplink and downlink, join nonces tracked

Rejoin

Not defined

Rejoin requests for refreshing session keys

If your deployment runs 1.0.x on infrastructure someone else operates, the application payload is confidential from eavesdroppers on the radio link, and not necessarily confidential from the network operator.

Encryption answers "who can read this." It says nothing about "who can change this" or "did this arrive twice." Those are separate properties and need separate mechanisms.

Gap One: Integrity Belongs to the Network, Not to Your Application

The MIC authenticates a frame to the network server. It is computed with the network session key, and it is verified by the party that holds that key. By the time a payload reaches your application, the check has already happened somewhere you may not control, and the application server receives the decrypted result with no proof of its own that the bytes are what the device sent.

Counter-mode encryption adds a subtlety. The ciphertext is the plaintext XORed with a keystream. Flip a bit in the ciphertext and exactly that bit flips in the decrypted plaintext, which is the definition of malleability. An outsider on the radio link cannot exploit this against a LoRaWAN network, because modifying the ciphertext breaks the MIC and the network server drops the frame. Anyone who holds the network session key can, though: a malicious or compromised network server, or a misconfigured shared server in a multi-tenant hosted setup, can modify ciphertext, recompute the MIC, and forward a frame whose decrypted value is exactly what the attacker chose, without ever learning the application key.

For a low-stakes reading, that is a theoretical complaint. For a reading that closes a valve or clears an alarm, it is a legitimate design question: if the network layer is compromised, can the application tell? Without an application-layer tag, it cannot.



Gap Two: The 32-Bit Tag and the Frame Counter

A 32-bit MIC means an attacker guessing blindly succeeds with probability 2 to the power of minus 32 per attempt, about one in 4.3 billion. Against a network that rate-limits and logs failures, that is acceptable. It is thinner than the 64 to 128-bit tags used elsewhere, and it gives no margin if an attacker has a way to submit many forged frames to a gateway cheaply. Designers chose a short tag to keep frames small; it is a trade made for the radio, not a property to rely on for high-consequence data.

The frame counter protects against replay: a captured frame resent later carries an old counter and gets dropped. This works only if the counter is trusted and monotonic, and several common deployment habits quietly break one or both:

  • ABP devices. Devices activated by personalisation have static session keys burned in at manufacture and no join handshake. When such a device reboots and its counter restarts at zero, the network server either rejects everything until the counter catches up or, in the common workaround, is configured to "reset frame counters" and accept any value. That setting disables replay protection, and it is often left on after commissioning.

  • Counter storage on battery nodes. A counter held only in RAM resets on every power cycle. Nodes that persist the counter to flash every transmission wear out the flash; nodes that persist it every N transmissions lose up to N counts on a brownout, which turns into a lockout on the server.

  • 16-bit counters. Some stacks and servers use 16-bit counters with a rollover assumption. A node transmitting every minute rolls a 16-bit counter in about 45 days, and a server that guesses the rollover wrongly rejects a healthy device.

  • Join nonces. Earlier 1.0.x revisions used random 16-bit device nonces that a server must remember to reject reuse; later revisions moved to counters. A server with a short memory can be tricked into accepting a replayed join request.

None of these is a flaw in the cipher. They are interaction effects between the protocol, the firmware, and the network server's configuration, and they show up in the field as either mysterious lockouts or silent security holes.

A fleet-wide root key turns one stolen node into a stolen fleet.

Gap Three: Where the Keys Live on the Device

An AES key is only as secret as the flash page it sits in. On a typical low-cost LoRa node, the root key and session keys are stored in ordinary flash or EEPROM, readable by anyone with a debug probe and a screwdriver, unless read-out protection is enabled and the debug port is locked. Two frequent practices compound the problem:

  • One AppKey for the whole fleet. Provisioning shortcuts that burn the same root key into every unit mean a single stolen device compromises every device's join.

  • Keys in the firmware image. Keys compiled into the binary appear in every release artifact, every CI log that prints a build, and every update file.

A fielded sensor on an unattended site is physically accessible. If your threat model includes someone with five minutes and a probe, the secure element or protected key storage on the device matters more than anything above the radio. That is an argument for per-device keys, locked debug ports, and ideally a secure element holding the root key, regardless of which protocol runs on top.


Why This Rarely Gets Fixed

Three reasons conspire to keep these gaps open, and none of them is ignorance:

  1. Procurement language. Datasheets and proposals say "AES-128 encryption," which satisfies the line item without describing the property.

  2. Layer confusion. The network team assumes the application team verifies readings; the application team assumes the network authenticated them. Nobody owns the end-to-end check.

  3. The cost looks high. Every byte on LoRa is expensive. The instinct is to avoid adding eight of them.

That last point is testable, so test it.


What Eight Bytes Cost on the Air

Airtime on LoRa is computed from the spreading factor, bandwidth, coding rate, and total frame length, which includes 13 bytes of LoRaWAN overhead around your payload. For a 125 kHz channel with coding rate 4/5, an explicit header, and a CRC, take a 20-byte application payload and add an 8-byte authentication tag.

Data rate

Frame (13 B overhead + payload)

Airtime, 20 B payload

Airtime, 28 B payload

Increase

SF9 / 125 kHz

33 B vs 41 B

about 247 ms

about 288 ms

about +17%

SF12 / 125 kHz

33 B vs 41 B

about 1,811 ms

about 2,138 ms

about +18%

Airtime is only half of the cost. What it does to the battery depends on the transmit current. At a +14 dBm setting drawing around 44 mA, the extra 41 ms at SF9 costs about 0.5 microamp-hours per uplink. A node reporting every five minutes sends 288 uplinks a day, so the additional drain is roughly 0.14 milliamp-hours per day, or about 52 mAh per year. On a 19 Ah lithium thionyl chloride D-cell, that is about a quarter of one percent of capacity per year. The authentication tag is cheap against everything else in the energy budget; the duty-cycle and channel-occupancy consequences are the ones to check against your regulatory band and network capacity.

Payload ceilings matter more than energy. In the Indian 865 to 867 MHz band, the slowest data rates carry on the order of 51 bytes of application payload per uplink, which is plenty for an eight-byte tag and a typical sensor record but leaves little room for a verbose JSON-style body. Binary packing is the answer, and it was the right answer already.


The Fix: An Application-Layer Tag You Control

The principle is simple. The device computes an authentication tag over its own payload plus a counter and its identity, using a key that the network operator never sees. The application server, which holds the same key, recomputes the tag and a sequence check before acting on the reading. The network layer is now untrusted for integrity, whatever it is doing.

The key choice is AES-CMAC (RFC 4493), the same primitive LoRaWAN uses for its MIC, available in every crypto library worth using. If confidentiality at the application layer is also required, use an authenticated-encryption mode such as AES-CCM instead, which gives you both in one pass. The example below uses CMAC with a truncated 8-byte tag over cleartext fields that LoRaWAN already encrypts in transit.

// Device side: build an authenticated application frame (mbedTLS)
#include "mbedtls/cmac.h"

#define TAG_LEN 8

typedef struct __attribute__((packed)) {
    uint32_t seq;          // app-level monotonic counter, persisted
    int16_t  temp_c_x100;
    uint16_t vib_mm_s_x100;
    uint8_t  flags;
} app_payload_t;           // 9 bytes: 4 seq + 5 of sensor data

int build_frame(const uint8_t key[16], const uint8_t dev_eui[8],
                const app_payload_t *p, uint8_t *out, size_t *out_len)
{
    uint8_t msg[8 + sizeof(*p)];
    uint8_t tag_full[16];

    memcpy(msg, dev_eui, 8);                 // bind the tag to this device
    memcpy(msg + 8, p, sizeof(*p));

    const mbedtls_cipher_info_t *ci =
        mbedtls_cipher_info_from_type(MBEDTLS_CIPHER_AES_128_ECB);
    if (mbedtls_cipher_cmac(ci, key, 128, msg, sizeof(msg), tag_full) != 0)
        return -1;

    memcpy(out, p, sizeof(*p));
    memcpy(out + sizeof(*p), tag_full, TAG_LEN);   // truncate to 8 bytes
    *out_len = sizeof(*p) + TAG_LEN;
    return 0;
}

On the server, verification repeats the computation and adds the part that most home-grown schemes forget: a replay window on the sequence number.

# Server side: verify tag and enforce a sequence window (Python)
from cryptography.hazmat.primitives.cmac import CMAC
from cryptography.hazmat.primitives.ciphers import algorithms
import hmac, struct

def verify(key: bytes, dev_eui: bytes, frame: bytes, state: dict) -> bool:
    payload, tag = frame[:-8], frame[-8:]
    c = CMAC(algorithms.AES(key))
    c.update(dev_eui + payload)
    expected = c.finalize()[:8]
    if not hmac.compare_digest(expected, tag):
        return False                          # forged or corrupted

    seq = struct.unpack_from("<I", payload, 0)[0]
    last = state.get(dev_eui, -1)
    # accept only forward movement, allow a bounded gap for lost uplinks
    if seq <= last or seq - last > 1000:
        return False                          # replay, or counter desync
    state[dev_eui] = seq
    return True

Four details make this hold up in the field:

  • Use a per-device key. Derive it from a master secret held in a hardware security module or key vault, using a key-derivation function with the device identity as input, and provision the derived key at manufacture. A fleet-wide key turns one stolen node into a fleet compromise.

  • Persist the sequence number safely. Write the counter to flash in blocks (advance a stored high-water mark by 64, write once per 64 uplinks, and resume from the mark plus 64 after a reboot). The server's gap tolerance absorbs the skipped values, and flash wear drops by a factor of 64.

  • Bind the identity into the tag. Including the DevEUI prevents a valid frame from one device being replayed as another's.

  • Compare tags in constant time. The hmac.compare_digest call is not decoration.

Put the check where the decision is made. If the application acts on a reading, the application verifies the reading. Trusting a check performed two hops upstream is trust in whoever operates those hops.

A Private Network, an Auditor, and One Question

A representative engagement, composited and genericised: an industrial operator runs a private LoRaWAN network across a plant site, with sensors reporting vibration and temperature every five minutes to a hosted network server and an on-premises application server. A security reviewer asks a single question during acceptance: "If the hosted network server were compromised, could an attacker make the plant dashboard show a normal vibration reading while a bearing fails?"

The honest answer was yes. The nodes ran 1.0.x with OTAA. The hosted network server held the network session key and, by the join architecture, had also been party to deriving the application session key. A compromised server could alter or fabricate readings and nothing downstream would notice.

The fix took three changes. First, move the device root keys to per-device values derived from a master held on the customer's own key vault, so the hosted operator never held the application-layer key. Second, add the eight-byte CMAC tag and four-byte application sequence number described above, which grew the application payload from 5 bytes of sensor data to 17 bytes on the wire (four-byte sequence number plus eight-byte tag). Third, enable the server-side replay window and alerting on tag failure. On the radio side the cost was the roughly 17 percent increase in airtime shown earlier, a negligible battery change, and a payload comfortably under the data-rate ceiling. On the operations side, tag-failure counts became a monitored metric, and in the first weeks they exposed two nodes with a counter-persistence bug that had been silently dropping readings for months.

The reviewer's question has a different answer now: a compromised network server can drop or delay frames, and can no longer alter or forge them without the application noticing.


Which Attacker Needs Which Gap

Security discussions go vague when nobody says who the attacker is. Three attacker types cover most LoRa deployments, and each one reaches a different gap.

Attacker

Capability

Gaps reachable

Effort

Passer-by with an SDR and a laptop

Records and replays radio frames within range

Replay against ABP devices with counter checks disabled; join replay against servers with short nonce memory

Low: off-the-shelf radio hardware and open-source tools

Insider or contractor with network-server access

Reads server config, exports keys, edits stored frames

Network-layer integrity trust; session key exposure in 1.0.x; counter reset settings

Low once access exists

Person with physical access to one node

Dumps flash, reads keys, clones the device

Fleet-wide root keys; keys in firmware; unlocked debug port

Moderate: a few hours and a probe

Read the table against your own site. A plant with staff on the network server and nobody within radio range of the perimeter has a very different risk shape from a roadside deployment where anyone can park a car near a node. The passer-by is the attacker people imagine and the insider is the one that tends to appear in incident reports. The physical attacker matters most for any product that will be sold in volume, because one compromised unit becomes a template.

Eight bytes of tag cost about 17 percent more airtime and roughly a quarter of one percent of a D-cell per year at five-minute reporting.

Test Your Own Network Before an Auditor Does

Every claim above can be checked against the network you actually run, with a bench node and a packet log. Three tests find most real problems in an afternoon.

Replay test. Capture a valid uplink (from the gateway's packet forwarder log or a bench radio), then retransmit the identical frame after the node has sent several newer ones. A healthy server drops it. If the application sees a second copy of the old reading, replay protection is off somewhere, usually a counter-reset flag.

Tamper test. Take a captured frame and flip one payload bit without recomputing the integrity code. The network server should drop it. Then run the same experiment at the application layer: flip a bit in the application payload on the server-to-application path (a test message injected into your own message queue is enough) and confirm your application rejects it. If the application accepts the modified reading, nothing downstream is checking, which is the gap this post is about.

Reboot test. Power-cycle a node repeatedly while it transmits and watch the server's view of its counters. The goal is to confirm that a reboot never produces a counter value the server has already seen, and never produces a long lockout.

A small automated check on the application-layer scheme keeps regressions out:

# test_frame_auth.py -- run against the verify() function above
import os, struct, pytest

KEY = os.urandom(16)
EUI = bytes.fromhex("70B3D57ED0000001")

def make(seq, temp=2150, vib=130, flags=0, key=KEY):
    payload = struct.pack("<IhHB", seq, temp, vib, flags)
    from cryptography.hazmat.primitives.cmac import CMAC
    from cryptography.hazmat.primitives.ciphers import algorithms
    c = CMAC(algorithms.AES(key)); c.update(EUI + payload)
    return payload + c.finalize()[:8]

def test_accepts_fresh_frame():
    st = {}
    assert verify(KEY, EUI, make(1), st)

def test_rejects_replay():
    st = {}
    f = make(5)
    assert verify(KEY, EUI, f, st)
    assert not verify(KEY, EUI, f, st)

def test_rejects_bit_flip():
    st = {}
    f = bytearray(make(7))
    f[4] ^= 0x01                       # flip a bit in the temperature field
    assert not verify(KEY, EUI, bytes(f), st)

def test_rejects_wrong_device_key():
    st = {}
    assert not verify(os.urandom(16), EUI, make(9), st)

def test_rejects_huge_gap():
    st = {EUI: 10}
    assert not verify(KEY, EUI, make(5000), st)

Run these in CI on every change to the framing code. A scheme that is secure on paper and broken by a refactor is the usual way these systems fail in the second year.

Never deliver a new key under a key you suspect is already compromised.

Rotating Keys Without Bricking the Fleet

Any scheme that cannot rotate its keys is a scheme that will eventually hold a key it should not. Plan rotation from day one, because retrofitting it onto a fleet of fielded nodes is the expensive version.

The usual pattern adds a one-byte key identifier to the frame and lets the server hold two valid keys per device during a transition window. The device switches to the new key only after receiving a signed command that carries it, and the server accepts both identifiers until it sees traffic on the new one.

// Frame header gains one byte: top 2 bits = key id (0..3), low 6 bits = version
typedef struct __attribute__((packed)) {
    uint8_t  ver_keyid;   // [7:6] key id, [5:0] frame version
    uint32_t seq;
    /* ... sensor fields ... */
} app_hdr_t;

// Server-side: try the key the frame claims, fall back only within a rotation window
const uint8_t *pick_key(dev_state_t *d, uint8_t keyid, uint32_t now) {
    if (keyid == d->active_id)  return d->active_key;
    if (keyid == d->pending_id && now < d->rotation_deadline) return d->pending_key;
    return NULL;           // unknown or expired key id -> reject and alert
}

Two rules keep this safe. The command that delivers a new key must itself be authenticated and, ideally, encrypted with the old key, so that an attacker who captures the rotation message gains nothing. And the rotation must be recoverable: if a node never confirms the switch, the server keeps the old key active and flags the device, rather than locking it out.

Downlink capacity makes rotation slow on class A devices, since a node only listens briefly after each uplink. For a fleet of several thousand nodes, expect a rotation campaign to run over days. That is a reason to rehearse it before the first emergency, not during one.


When Application-Layer Authentication Is Not Worth It

Not every deployment needs this, and adding it everywhere is its own form of waste.

  • Low-consequence telemetry on a trusted network. A sensor logging ambient conditions for trend analysis, on a network you operate yourself, with no actuation downstream, gains little from the extra bytes and key-management burden.

  • LoRaWAN 1.1 with a properly separated join server and a network you trust. The architecture already separates the keys; remaining gaps shrink to counters and device key storage.

  • Payload budgets that cannot absorb it. At the slowest data rates, with large payloads and tight regulatory limits on transmit time, an eight-byte tag may be unaffordable. In that case reduce the tag (four bytes still changes the attacker's economics), or send authentication data on a subset of uplinks.

  • Where confidentiality, not integrity, is the concern. If the risk is eavesdropping on readings rather than forging them, application-layer encryption with AES-CCM is the better primitive, and the same key-management discipline applies.

The point of the audit is to decide deliberately. The failure mode is not omitting the tag. It is omitting it without anyone having asked what a forged reading would cost.


A First Week of Work

For a team inheriting a LoRaWAN deployment and a nagging doubt, here is a sequence:

  1. Inventory the facts. Record the LoRaWAN version, activation method (OTAA or ABP), who operates the network server and the join server, and where each key is stored on the device and on the server.

  2. Check the counter settings. Look for any "disable frame counter validation" or "reset counters on join" flags on the server. If any ABP device has one enabled, treat that device as unprotected against replay.

  3. Audit the key provisioning. Count how many devices share a root key. Anything above one is a finding.

  4. Check debug and read-out protection. Confirm that read-out protection is enabled in production firmware and the debug port is locked after manufacture.

  5. Rank the readings by consequence. Mark each uplink type by what happens if it is forged: nothing, a wrong chart, a wrong alarm, a wrong actuation. Only the last two need the full fix.

  6. Prototype the tag on one device. Add the CMAC and sequence number, measure airtime and energy on the bench, and verify the server rejects tampered and replayed frames.

  7. Add monitoring. Alert on tag failures and sequence gaps per device. Both are early signs of attack and of firmware bugs.


Back to the Sentence

"LoRaWAN uses AES-128" is a true statement about the radio and the network. It stops being a security argument the moment a reading triggers something expensive, or the network is run by someone else, or a device sits in a field with a debug header exposed. The application-layer tag is a small piece of code, a few bytes on the air, and an afternoon of key-management decisions. The cost of skipping it is a question you will be asked eventually, probably by someone with a clipboard, and "the cipher was AES-128" will not be a complete answer.


EurthTech is an engineering-first product design and embedded systems company with a development center in Hyderabad. To discuss a project, write to connect@eurthtech.com or see https://www.eurthtech.com/case-studies.

Comments


EurthTech delivers AI-powered embedded systems, IoT product engineering, and smart infrastructure solutions to transform cities, enterprises, and industries with innovation and precision.

Factory:

Plot No: 41,
ALEAP Industrial Estate, Suramapalli,
Vijayawada,

India - 521212.

  • Linkedin
  • Twitter
  • Youtube
  • Facebook
  • Instagram

 

© 2025 by Eurth Techtronics Pvt Ltd.

 

Development Center:

4th Floor, Krishna towers, 100 Feet Rd, Madhapur, Hyderabad, Telangana 500081

Menu

|

Accesibility Statement

bottom of page