The Microcontroller That Signs Its Own AWS Requests

BootBoots is the cat monitoring system I've been building. The short version: an ESP32-S3 sits above the food bowls, takes photos when it detects motion, and posts them to AWS for inference. If the model decides it's looking at Boots — the black cat who isn't supposed to be there — it triggers a deterrent.

The original design had a Lambda function in the middle. The camera would post a photo to the Lambda, the Lambda would sign a request and forward it to the inference endpoint. Clean. Simple. One more moving part.
I took the Lambda out. The ESP32 signs its own AWS requests now.
This is a post about what that actually involves.
Why bother?
The honest answer is that a Lambda proxy works fine and I'd have had less to debug. The slightly more principled answer is that the proxy is a failure point: if the Lambda is cold, you pay for a cold start; if it's misconfigured, you get a cryptic 502; if you want to understand what's happening end-to-end, you're reading two sets of logs. A device that authenticates directly to AWS removes a layer of indirection and all the failure modes that come with it.
The less principled answer is that I wanted to know whether you could do it.
The credential chain
Before a single SigV4 header gets generated, the device needs AWS credentials. Not long-lived access keys — those don't belong in firmware. The solution is IoT Core credential chaining, which is one of the more underappreciated parts of AWS IoT.
The device has an X.509 certificate and private key baked into its secrets header at build time. Those are its device identity — they prove it's a legitimate BootBoots device to AWS IoT Core. What they don't do directly is authorise it to call other AWS services.
For that, there's a credentials endpoint. The device calls it over mTLS (presenting its X.509 cert as the client certificate), specifies a role alias, and gets back temporary AWS credentials — access key, secret key, session token — valid for an hour:
String url = "https://" + credentialsEndpointHost +
"/role-aliases/" + String(roleAlias) + "/credentials";
http.addHeader("x-amzn-iot-thingname", "BootBootsThing");
int httpResponseCode = http.GET();
The response is straightforward JSON:
{
"credentials": {
"accessKeyId": "ASIA...",
"secretAccessKey": "...",
"sessionToken": "...",
"expiration": "2026-05-15T12:00:00Z"
}
}
The AWSAuth class parses these and stores them. It tracks their expiry and refreshes automatically when they're within five minutes of running out.
So the trust chain is: X.509 device cert → IoT Thing → role alias → IAM role → temporary STS credentials. The device never has long-lived AWS credentials. If the device is compromised, the cert can be revoked in IoT Core and the credential chain breaks immediately.

What SigV4 actually is
Now the device has credentials. Signing is a different operation — it's what you put in the Authorization header of every AWS API call to prove you made the request and that nobody tampered with it in transit.
The SigV4 algorithm has four steps. They're not complicated individually, but the order matters and the formatting requirements are exacting.
Step 1: canonical request. You take your HTTP method, URI, query string, headers, and payload, and you format them into a specific string. Headers must be lowercase and sorted alphabetically. The payload must be SHA256-hashed. One wrong character and the signature won't match what AWS expects.
String AWSAuth::createCanonicalRequest(const String& method, const String& uri,
const String& queryString, const String& headers,
const String& signedHeaders, const String& payloadHash) {
return method + "\n" +
uri + "\n" +
queryString + "\n" +
headers + "\n" +
signedHeaders + "\n" +
payloadHash;
}
Step 2: string to sign. The canonical request is itself SHA256-hashed, then combined with the algorithm name, timestamp, and a credential scope — a string encoding the date, region, service, and signing version:
String AWSAuth::createStringToSign(const String& algorithm, const String& requestDateTime,
const String& credentialScope, const String& canonicalRequestHash) {
return algorithm + "\n" +
requestDateTime + "\n" +
credentialScope + "\n" +
canonicalRequestHash;
}
Step 3: signing key derivation. This is the part that makes SigV4 interesting. You don't sign directly with the secret access key. You derive a signing key through four HMAC-SHA256 operations, chained together:
void AWSAuth::getSigningKey(uint8_t* output, const String& key, const String& dateStamp,
const String& regionName, const String& serviceName) {
// kDate = HMAC("AWS4" + kSecret, Date)
String kSecret = "AWS4" + key;
uint8_t kDate[32];
hmacSha256Raw(kDate, (const uint8_t*)kSecret.c_str(), kSecret.length(),
(const uint8_t*)dateStamp.c_str(), dateStamp.length());
// kRegion = HMAC(kDate, Region)
uint8_t kRegion[32];
hmacSha256Raw(kRegion, kDate, 32,
(const uint8_t*)regionName.c_str(), regionName.length());
// kService = HMAC(kRegion, Service)
uint8_t kService[32];
hmacSha256Raw(kService, kRegion, 32,
(const uint8_t*)serviceName.c_str(), serviceName.length());
// kSigning = HMAC(kService, "aws4_request")
const char* aws4Request = "aws4_request";
hmacSha256Raw(output, kService, 32,
(const uint8_t*)aws4Request, strlen(aws4Request));
}
Each step binds the key to a specific context — date, region, service. The result is a key that's only valid for signing requests to this exact service in this exact region on this exact date.
Step 4: sign and assemble. The string to sign is HMAC-SHA256'd with the derived signing key, giving a hex signature. That signature goes into the Authorization header alongside the credential scope and the list of signed headers.
The assembled header looks like this:
AWS4-HMAC-SHA256 Credential=ASIA.../20260515/eu-west-2/execute-api/aws4_request,
SignedHeaders=content-type;host;x-amz-date;x-amz-security-token,
Signature=a3b4c5...
API Gateway checks that signature on every request. If the timestamp is more than five minutes old, it rejects it. If any signed header was modified in transit, it rejects it. If the wrong credentials were used, it rejects it.
The crypto stack
None of this uses a third-party signing library. The ESP32-S3 ships with mbedTLS as part of the ESP-IDF, and mbedTLS has everything needed: SHA256 and HMAC-SHA256, both available as single-call functions.
void AWSAuth::hmacSha256Raw(uint8_t* output, const uint8_t* key, size_t keyLen,
const uint8_t* data, size_t dataLen) {
const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(md_info, key, keyLen, data, dataLen, output);
}
String AWSAuth::sha256Hash(const String& data) {
uint8_t output[32];
const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md(md_info, (const uint8_t*)data.c_str(), data.length(), output);
return bytesToHex(output, 32);
}
The only subtlety is the hex encoding. SigV4 requires lowercase hex throughout — a3b4c5, not A3B4C5. Get that wrong and the signatures match locally but fail on AWS.
String AWSAuth::bytesToHex(const uint8_t* bytes, size_t len) {
String result = "";
result.reserve(len * 2);
const char hexChars[] = "0123456789abcdef"; // AWS SigV4 requires lowercase hex
for (size_t i = 0; i < len; i++) {
result += hexChars[(bytes[i] >> 4) & 0x0F];
result += hexChars[bytes[i] & 0x0F];
}
return result;
}
Signing a binary payload
The inference endpoint receives JPEG images — binary data. SigV4 requires the SHA256 hash of the entire payload to be included in the canonical request (as X-Amz-Content-Sha256) and also sent as a header. That means hashing the full image before opening the HTTP connection.
SigV4Headers AWSAuth::createSigV4HeadersForBinary(const String& method, const String& uri,
const String& host, const uint8_t* payload,
size_t payloadSize,
const String& contentType) {
String payloadHash = sha256HashBinary(payload, payloadSize);
return createSigV4HeadersInternal(method, uri, host, payloadHash, contentType);
}
A typical inference image is somewhere around 20–60KB. Hashing that in one call is fine. The result goes into the Authorization header as part of the signing process, and also into X-Amz-Content-Sha256 when the actual HTTP request goes out:
client.print("X-Amz-Content-Sha256: ");
client.println(headers.payloadHash);
API Gateway validates both. The payload hash in the Authorization header ensures the signature commits to exactly the bytes that were sent; the X-Amz-Content-Sha256 header lets the service verify the body arrived intact.
Time is not optional
SigV4 signatures include a timestamp. AWS rejects requests where the timestamp is more than five minutes from the server's clock. On a microcontroller that boots fresh every time, this means you need a reliable clock before signing anything.
AWSAuth::initialize() blocks on NTP until the clock is sane — specifically, until it's past November 2023 (a sanity check that rules out the chip defaulting to epoch zero):
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
time_t now = 0;
int attempts = 0;
while (now < 1700000000 && attempts < 20) {
delay(500);
time(&now);
attempts++;
}
If NTP fails, initialization fails and no signing happens. That's the right behaviour — signing with an incorrect timestamp is worse than not signing at all, because you'd get confusing 403s rather than a clear boot failure.
The flow, end to end
At boot, AWSAuth initialises, syncs the clock, and does nothing else. The first time a capture is triggered, CaptureController calls refreshCredentialsIfNeeded(), which hits the IoT credentials endpoint and stores the temporary access key, secret, and session token.
Then, for every image upload:
// Pause MQTT to free SSL memory before making HTTPS request
_awsAuth->pauseMqtt();
if (!_awsAuth->areCredentialsValid()) {
_awsAuth->getCredentialsWithRoleAlias(_roleAlias);
}
CatCamHttpClient httpClient;
response = httpClient.postImage(image, _apiHost, _apiPath, _awsAuth);
_awsAuth->resumeMqtt();
The pause/resume wrapping is necessary because of heap fragmentation — the MQTT connection holds a live TLS session, and the HTTPS upload needs contiguous memory for its own TLS session. That's covered in a separate post. The relevant point here is that pausing MQTT doesn't discard the credentials or any signing state — it just frees the TLS session memory. Everything in AWSAuth survives.
Inside postImage, the signing happens before the connection opens:
SigV4Headers headers = awsAuth->createSigV4HeadersForBinary("POST", actualPath.c_str(), host,
namedImage->image, imageSize, contentType);
Then the HTTP request is assembled manually with the signed headers added:
client.print("X-Amz-Date: ");
client.println(headers.date);
client.print("X-Amz-Security-Token: ");
client.println(headers.securityToken);
client.print("Authorization: ");
client.println(headers.authorization);
client.print("X-Amz-Content-Sha256: ");
client.println(headers.payloadHash);
The session token is necessary because these are temporary credentials — API Gateway needs it to verify the STS chain before it can validate the signature.
What was actually hard
Not the crypto. mbedTLS handles that correctly and the SigV4 algorithm, once you've read the spec carefully, is mechanical.
The fiddly parts were:
Header ordering. The canonical headers must be sorted alphabetically by name, all lowercase. Get this wrong and the signature is wrong, and AWS returns a 403 with a message that tells you the expected signature but not what you got wrong in your input. Comparing hex strings is not a fun debugging experience.
The session token. With temporary credentials from IoT Core, the session token must be included in the canonical headers and signed. Miss it out and every request fails. Include it in the wrong place and it fails differently.
Getting NTP right before signing. On early runs, the device would sometimes sign with a clock that was a few minutes behind real time. The NTP wait loop was added after seeing intermittent 403s that disappeared on retry.
The payload hash has to match the actual bytes. If anything modifies the image buffer between the hash call and the send, the signature won't verify. The signing happens before the connection opens, and the image is read-only from that point.
Is it worth it?
Removing the Lambda proxy means one fewer AWS service to manage, one fewer IAM role to reason about (other than the IoT role alias), and direct observability — the device logs every request and its result, and API Gateway access logs show the signing identity directly.
The downside is that the firmware is now doing real authentication work. That's not free: every inference call involves an HMAC-SHA256 of the full image payload plus four more HMAC-SHA256 operations for key derivation. On the ESP32-S3, this takes well under a second, so it's not a practical concern. On a slower chip it might be.
The more interesting question is: should a device this constrained be doing this kind of work at all? The answer I've settled on is yes, because the alternative — a proxy — doesn't make authentication simpler, it just relocates it. The Lambda still needs credentials, still needs an IAM role, still needs to sign requests to the inference endpoint. The only thing it adds is a translation layer between the device and AWS. That layer has costs.
A camera that signs its own AWS requests is a camera that knows who it is and can prove it. That feels like the right model for a device sitting in my kitchen deciding whether to spray a cat.
What I'd tell myself at the start
Read the SigV4 spec once, slowly. It's in the AWS documentation and it's not long. The algorithm is more mechanical than it looks — the complexity is in getting the string formatting exactly right, not in the cryptography.
Use mbedTLS directly. It's already on the device. Every Arduino-ecosystem SigV4 library I looked at was either incomplete or reimplemented SHA256 from scratch, neither of which is what you want. mbedtls_md_hmac takes five arguments and does exactly what it says.
Test with a fixed timestamp first. When debugging a SigV4 implementation, use a known-good test vector from the AWS documentation with a hardcoded timestamp. Once the test vector passes, plug in the real clock. Testing with a live clock means you can't compare against a reference output.
The session token is not optional. With temporary credentials, it goes in the canonical headers and it must be signed. Document this somewhere you'll actually read it.
Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.
If you find this blog useful, please consider a donation.
