Renew certificates
Leaves are deliberately short-lived. A workload renews unattended by proving it still holds the current key, then confirms that it reloaded the new credential before the old one is retired.
When to renew
Every issued certificate carries a server-computed renewal_hint.renew_after: not_before plus two-thirds of the validity period, with ±5% random jitter. For a 24-hour leaf that is about 16 hours after issue, spread over roughly ±72 minutes so a fleet does not renew in lockstep. Renew at or after that time and well before not_after.
| Moment | Offset from not_before |
|---|---|
Issued (not_before is backdated 30 s) | 0 h |
renew_after (two-thirds, ±5%) | about 14.8 h to 17.2 h |
not_after | 24 h (or the grant expiry, if earlier) |
Renew with the CLI
mt renew # renews only if renew_after has passed
mt renew --force # renew now
mt renew --watch # stay running; renew each time the window opens--watch sleeps until renew_after (re-checking at least hourly, never more often than every 10 seconds). On failure it retries with exponential backoff from 5 seconds up to 5 minutes, plus up to 1 second of random jitter. Each renewal generates a new key pair; the current key authorizes it.
Reload the server that uses the certificate
The new key, certificate, chain and identity.json are committed as one unit (staged, synced, journaled, then renamed into place), so a reader never sees a key next to the wrong certificate. Then the reload hook runs, and only when it succeeds does mt send the reload confirmation signed by the new key. If the hook fails, the new files stay in place, the confirmation is held back and the next run retries both.
mt renew --watch --on-renew "systemctl reload nginx" # executed directly, no shell
mt renew --watch --signal HUP --pid-file /run/haproxy.pid # signal a process (Linux, macOS)The hook receives MT_CERT_FILE, MT_CHAIN_FILE, MT_KEY_FILE, MT_ROOT_FILE, MT_CERTIFICATE_ID and MT_SERIAL. --hook-timeout bounds it (default 60 s).
Run renewal as a service
sudo mt service install renew -- --on-renew "systemctl reload nginx" # Linux: systemd unit
mt service install renew # macOS: LaunchAgent (LaunchDaemon as root)
mt service install renew # Windows (elevated): Windows serviceThe .deb and .rpm packages also ship an mt-renew unit that renews /var/lib/mutualtrust and reads MT_ON_RENEW from /etc/default/mt. See mt service.
The renewal request
Renewal is authorized by key continuity, not by a session or bearer token. Build a CSR for the new key, then sign this exact string with the current certificate’s private key:
mt-renew-v1:<current certificate_id>:<csr_sha256_hex>:<unix_seconds>
mt-renew-v1:crt_01jcqb5r0d2e4f6g8h0j2k4m6n:9b74c9897bac770ffc029102a200c5de2a6d3b1f9d5e2c8a1b7f4e6d3c9a0b15:1731641400csr_sha256_hexis the lowercase hex SHA-256 of the new CSR’s DER bytes.unix_secondsis the current time; it must be within ±300 seconds of the server clock.- The signature is ECDSA P-256 / SHA-256, raw
r‖s, base64url (the same encoding as the enrollment proof).
POST https://api.mutualtrust.io/v1/certificates/crt_01jcqb5r0d2e4f6g8h0j2k4m6n/renew
Content-Type: application/json
{
"csr": "MIIBDzCBtgIBADAWMRQwEgYDVQQDDAttdC13b3JrbG9hZDBZ…",
"proof": "kq2u…base64url…",
"timestamp": 1731641400
}The response has the same shape as an enrollment: state: "issued" with the new certificate_id, PEMs and a new renewal_hint, or state: "pending" with an issuance_request_id to retry later. The successor records the old certificate as its predecessor.
Renewal is idempotent: repeating it with the same predecessor and the same CSR returns the certificate already issued for that pair instead of issuing another.
openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out next-key.pem
openssl req -new -key next-key.pem -subj "/CN=mt-workload" -outform DER -out next.der
DIGEST=$(openssl dgst -sha256 -r next.der | cut -d' ' -f1)
TS=$(date +%s)
node -e '
const { sign, createPrivateKey } = require("node:crypto");
const key = createPrivateKey(require("node:fs").readFileSync("key.pem")); // CURRENT key
process.stdout.write(sign("sha256", Buffer.from(process.argv[1]), { key, dsaEncoding: "ieee-p1363" }).toString("base64url"));
' "mt-renew-v1:crt_01jcqb5r0d2e4f6g8h0j2k4m6n:$DIGEST:$TS"Confirm the reload
After your process has loaded the new certificate and key, confirm it with a proof signed by the new key. This records reload success before the predecessor is retired, and is what marks a renewal as completed unattended.
mt-reload-v1:<new certificate_id>:<unix_seconds>POST https://api.mutualtrust.io/v1/certificates/crt_01jcqk8w3p5q7r9s1t3v5w7x9y/reloaded
Content-Type: application/json
{ "proof": "Mf0c…base64url…", "timestamp": 1731641405 }{
"certificate_id": "crt_01jcqk8w3p5q7r9s1t3v5w7x9y",
"reload_confirmed": true,
"predecessor_retirable": "crt_01jcqb5r0d2e4f6g8h0j2k4m6n"
}predecessor_retirable names the certificate you may now stop using (null after a first enrollment). Renewal does not revoke the predecessor; it simply expires. If you want it denied immediately, revoke it with reason superseded.
When renewal is refused
| Status | Code | What to do |
|---|---|---|
| 404 | not_found | The proof did not verify with the current certificate’s key (or the certificate ID is wrong). The response deliberately does not say which. |
| 409 | certificate_revoked | The current certificate was revoked. Re-enroll if your grant is still active. |
| 409 | certificate_expired | Too late for key continuity. Re-enroll with a fresh attestation. |
| 409 | grant_not_active | The grant was suspended, revoked or expired. Renewal is blocked until the API owner restores it. |
| 422 | validation_failed | Timestamp outside ±5 minutes (check the workload clock), or the CSR was rejected. |
| 503 | signer_unavailable | Retry with backoff. Your current certificate keeps working until it expires. |
Alert on renewal runway