A Raspberry Pi camera takes a photo every three hours and uploads it to S3, and iot.rrjamal.ca shows them. From July 2025 until October 2026 it did that with an AWS access key. This post replaces the key with a certificate and IAM Roles Anywhere. The short version: the credentials the Pi uses now expire every hour, but someone who steals the Pi can still upload photos until I cut it off.
The same view at noon, four times over.
The problem with an access key on a device
The first version was the quick one: Terraform created
an IAM user, pi-photo-uploader, and an access key, and output the secret so I could copy it into
~/.aws/credentials on the Pi. It had the usual problems. The key never expires, it works from any
machine it's copied to (off the SD card, out of a backup), and rotating it is a manual chore that's
easy to put off. It also sat in Terraform state, so anyone who could read that had it.
What Roles Anywhere does instead
Roles Anywhere lets a machine outside AWS trade an X.509 certificate for temporary credentials, the same kind an EC2 instance gets. You register your own certificate authority (CA) as a trust anchor and say which IAM role its certificates may assume.
On the device, AWS's signing helper does the exchange. It plugs into the standard
credential_process setting in ~/.aws/config, so boto3 and the AWS CLI use it with no code
changes:
sequenceDiagram
participant Script as Capture script
participant Helper as Signing helper
participant RA as Roles Anywhere
participant S3
Script->>Helper: credential_process
Helper->>RA: CreateSession, signed<br/>with the Pi's private key
Note over Helper,RA: Checks the certificate is from the<br/>trust anchor and named photo-pi
RA-->>Helper: Credentials, valid 1 hour
Helper-->>Script: Credentials
Script->>S3: PutObject under photos/
S3-->>Script: 200 OK
Disabling the trust anchor or the profile cuts it off. At the same time I narrowed the permissions
to s3:PutObject under photos/. The old key could also read and list photos, but that was my
policy, not the key's fault: an access key can be scoped just as tightly.
1. A private certificate authority
OpenSSL is enough for one device; there's no need for AWS Private CA. This makes a CA that lasts ten years and a certificate for the Pi that lasts two:
# The CA: becomes the trust anchor. Keep ca.key private (offline if you can).
openssl ecparam -name prime256v1 -genkey -noout -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 -subj "/CN=iot-reviewer photo CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.pem
# The Pi's certificate, signed by the CA, for client authentication only.
openssl ecparam -name prime256v1 -genkey -noout -out pi.key
openssl req -new -key pi.key -subj "/CN=photo-pi" -out pi.csr
printf "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n" > pi.ext
openssl x509 -req -in pi.csr -CA ca.pem -CAkey ca.key -CAcreateserial -sha256 -days 730 \
-extfile pi.ext -out pi.pem
The role checks the common name, photo-pi, so another certificate from the same CA can't
assume it.
2. The AWS side, in Terraform
The trust anchor holds the CA certificate:
resource "aws_rolesanywhere_trust_anchor" "pi" {
name = "iot-reviewer-pi"
enabled = true
source {
source_type = "CERTIFICATE_BUNDLE"
source_data {
x509_certificate_data = file("pi-certs/ca.pem")
}
}
}
The role trusts Roles Anywhere only for this trust anchor and the photo-pi name:
resource "aws_iam_role" "pi" {
name = "iot-reviewer-pi-uploader"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "rolesanywhere.amazonaws.com" }
Action = ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"]
Condition = {
ArnEquals = { "aws:SourceArn" = aws_rolesanywhere_trust_anchor.pi.arn }
StringEquals = { "aws:PrincipalTag/x509Subject/CN" = "photo-pi" }
}
}]
})
}
Its only permission is uploading photos:
resource "aws_iam_role_policy" "pi" {
name = "upload-photos"
role = aws_iam_role.pi.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.photos.arn}/photos/*"
}]
})
}
The profile sets how long each credential lasts:
resource "aws_rolesanywhere_profile" "pi" {
name = "iot-reviewer-pi"
enabled = true
role_arns = [aws_iam_role.pi.arn]
duration_seconds = 3600
}
3. The Pi
Copy pi.pem and pi.key over, install the signing helper, and add a profile to ~/.aws/config:
[profile photo-uploader]
region = us-east-2
credential_process = /home/pi/photo-uploader/aws_signing_helper credential-process --certificate /home/pi/photo-uploader/pi.pem --private-key /home/pi/photo-uploader/pi.key --trust-anchor-arn <trust anchor ARN> --profile-arn <profile ARN> --role-arn <role ARN>
Cron runs the capture script with AWS_PROFILE=photo-uploader. The script itself didn't change.
The snag: there's no helper for 32-bit Raspberry Pi OS
AWS publishes the signing helper for Linux on x86_64 and aarch64. My Pi runs 32-bit Raspberry
Pi OS (armv7l). The helper is open source and written in Go, so I built it in an emulated ARM
container on my laptop:
docker run --rm --platform linux/arm/v7 -v "$PWD/build:/out" -w /src \
-e GOTOOLCHAIN=auto -e CGO_ENABLED=1 golang:1.24-bullseye sh -c \
'git clone -q --depth 1 --branch v1.8.5 https://github.com/aws/rolesanywhere-credential-helper.git . \
&& go build -trimpath -ldflags "-s -w" -o /out/aws_signing_helper .'
- Build on Debian bullseye. The helper links against glibc, and bullseye's older one means the binary runs on both bullseye and bookworm.
GOTOOLCHAIN=autolets Go download the newer toolchain the helper needs.
It takes a few minutes under emulation. Then scp the binary to the Pi.
What this doesn't fix
If someone steals the Pi or its SD card, they get pi.key and pi.pem, and can request fresh
credentials from any machine until I disable the trust anchor or revoke the certificate. Against
theft, that's barely better than an access key.
What does change is smaller:
| Access key | Certificate and Roles Anywhere | |
|---|---|---|
| The Pi or SD card is stolen | Works until the key is deleted | Works until the trust anchor is disabled or the certificate revoked |
| The credentials in use leak (a log, a crash dump) | Work until the key is deleted | Expire within the hour |
| Everyone forgets it exists | Works forever | Stops when the certificate expires (two years here) |
| Ten devices | Ten IAM users, ten keys to rotate | Ten certificates from one CA, one role |
| A key that can't be copied off the device | Not possible | Possible, with a TPM |
The last row closes the theft gap. The signing helper can use a private key held in a TPM, which is created inside the chip and never leaves it, so a copied SD card is useless. A Raspberry Pi has no TPM built in, but add-on modules exist. That's the next step, not something this does yet.
So for one camera on a shelf, a tightly scoped access key would be nearly as good. Certificates win because the credentials in use expire, nothing works forever by accident, and the setup scales past one device.
Things to know
- The Pi's clock matters. Requests are signed with the current time, so leave NTP on.
- Protect the CA key. Anyone holding it can mint certificates the trust anchor accepts. Keep it off the Pi, and ideally offline.
- The certificate expires. Put the renewal date in a calendar.
- Delete the old access key from the Pi and from IAM once uploads work.
The same pattern fits anything outside AWS that needs to call it, such as a home server or an on-premises job. For GitHub Actions, use GitHub's OIDC provider instead.



