Sign IoT Device Certificates with Step CA

This post sets up a separate workstation to sign IoT device (client) certificates and return a single leaf PEM per device.

The previous post installs and hardens the CA server only:
Prerequisite: Deploying Step CA: Your Own Certificate Authority for Internal Services


What you’ll build

  • A template that sets SAN=CN; the device generates an EC P-256 key.
  • A workstation that holds a JWK provisioner private key, mints a short-lived JWT (OTT), and exchanges a device CSR for one file only: <DEVICE_ID>.crt.pem.
  • A device that generates its own key + CSR (private key never leaves the device).

  1. Workstation — Generate the JWK first

This machine is not your CA; it only mints short-lived tokens and calls the CA.

# Install tools
sudo apt-get update && sudo apt-get install -y --no-install-recommends curl vim gpg ca-certificates
 sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/trusted.gpg.d/smallstep.asc
echo 'deb [signed-by=/etc/apt/trusted.gpg.d/smallstep.asc] https://packages.smallstep.com/stable/debian debs main' \
  | sudo tee /etc/apt/sources.list.d/smallstep.list 
sudo apt-get update && sudo apt-get -y install step-cli

# Create the JWK keypair (demo: --insecure; for production, omit and use a passphrase/HSM)
mkdir -p ~/enroll && cd ~/enroll
step crypto jwk create enroll.pub.jwk enroll.priv.jwk --insecure
chmod 600 enroll.priv.jwk

# Copy the PUBLIC key to the CA host (adjust user/host)
scp ~/enroll/enroll.pub.jwk administrator@stepca.maksonlee.com:/tmp/

  1. CA server — Template + Provisioner (uses the workstation’s public JWK)

Run this on the CA host.

# 1) Create the IoT client template (SAN = CN)
sudo tee /etc/step-ca/templates/iot-device.tpl >/dev/null <<'TPL'
{
  "subject": {
    "country": "TW",
    "province": "Taipei",
    "locality": "Taipei",
    "organization": "maksonlee.com",
    "organizationalUnit": "Devices",
    "commonName": "{{ .Subject.CommonName }}"
  },
  "dnsNames": ["{{ .Subject.CommonName }}"],
  "keyUsage": ["digitalSignature"],
  "extKeyUsage": ["clientAuth"],
  "validity": "26280h"
}
TPL
sudo chown step:step /etc/step-ca/templates/iot-device.tpl

# 2) Place the workstation public JWK
sudo mkdir -p /etc/step-ca/provisioners
sudo mv /tmp/enroll.pub.jwk /etc/step-ca/provisioners/

# 3) Add (or update) the JWK provisioner named "enroll"
sudo step ca provisioner add enroll \
  --type JWK \
  --public-key /etc/step-ca/provisioners/enroll.pub.jwk \
  --x509-template /etc/step-ca/templates/iot-device.tpl \
  --disable-renewal \
  --ca-url https://stepca.maksonlee.com \
  --root /etc/step-ca/certs/root_ca.crt \
  --ca-config /etc/step-ca/config/ca.json || \
sudo step ca provisioner update enroll \
  --public-key /etc/step-ca/provisioners/enroll.pub.jwk \
  --x509-template /etc/step-ca/templates/iot-device.tpl \
  --disable-renewal \
  --ca-config /etc/step-ca/config/ca.json \
  --ca-url https://stepca.maksonlee.com \
  --root /etc/step-ca/certs/root_ca.crt

# 4) Reload the CA
sudo systemctl restart step-ca

  1. Workstation — Trust the CA + install the leaf-only helper
# On the CA, get the Root fingerprint:
#   sudo step certificate fingerprint /etc/step-ca/certs/root_ca.crt

# On the workstation, bootstrap with that fingerprint:
step ca bootstrap \
  --ca-url https://stepca.maksonlee.com \
  --fingerprint "<PASTE_ROOT_FINGERPRINT>"

# Install a minimal helper that returns only <CN>.crt.pem
sudo tee /usr/local/bin/ws-enroll-lite >/dev/null <<'SH'
#!/usr/bin/env bash
# ws-enroll-lite: sign a CSR via Step CA and output only <CN>.crt.pem
# Requires: step-cli, openssl (and you've already run `step ca bootstrap`)

set -euo pipefail

CA_URL="${CA_URL:-https://stepca.maksonlee.com}"   # not strictly needed if bootstrapped
PROV="${PROV:-enroll}"
JWK="${JWK:-$HOME/enroll/enroll.priv.jwk}"
# If your JWK has a passphrase and you want non-interactive use, add:
# PWFILE="${PWFILE:-$HOME/enroll/jwk.pw}"

CSR="${1:-}"; [ -f "$CSR" ] || { echo "Usage: ws-enroll-lite /path/to/DEVICE.csr"; exit 1; }

# Extract CN for the output filename
CN="$(openssl req -in "$CSR" -noout -subject -nameopt RFC2253 | sed -n 's/^subject=.*CN=\([^,]*\).*/\1/p')"
[ -n "$CN" ] || { echo "CSR has no CN"; exit 2; }

# Mint a short-lived token with the JWK provisioner
# (Step sets the correct audience automatically after bootstrap)
TOKEN="$(step ca token "$CN" --key "$JWK" --issuer "$PROV")"
# If using a passphrase file:
# TOKEN="$(step ca token "$CN" --key "$JWK" --issuer "$PROV" --password-file "$PWFILE")"

# Sign CSR -> write **leaf-only** PEM (no chain)
step ca sign "$CSR" "${CN}.crt.pem" --token "$TOKEN"

echo "leaf cert -> $(pwd)/${CN}.crt.pem"
SH
sudo chmod +x /usr/local/bin/ws-enroll-lite

  1. Device — Generate key & CSR (2 commands)

Run these on the device (the private key stays on the device).

openssl ecparam -name prime256v1 -genkey -noout -out device.key
chmod 600 device.key

CN=SN-000001
openssl req -new -key device.key \
  -subj "/C=TW/ST=Taipei/L=Taipei/O=maksonlee.com/OU=Devices/CN=$CN" \
  -out "$CN.csr"

Transfer SN-000001.csr to the workstation.


  1. Workstation — Enroll (leaf-only output)
ws-enroll-lite SN-000001.csr
# Output: SN-000001.crt.pem

Return SN-000001.crt.pem to the device (it already has device.key).
Done. No full chain or JSON files are kept.


(Optional) Sanity checks

# CN and SAN should both show the device ID
step certificate inspect SN-000001.crt.pem --format json \
  | jq -r '.subject.common_name[0], (.extensions.subject_alt_name.dns_names[]?)'

# Public key curve should be prime256v1 (P-256)
openssl x509 -in SN-000001.crt.pem -noout -text \
  | sed -n '/Subject Public Key Info/,+3p'

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top