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.

Renewal timeline for a 24-hour leaf
MomentOffset 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_after24 h (or the grant expiry, if earlier)

Renew with the CLI

One-off and continuous renewal
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.

Reload hooks
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

Install the renewal 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 service

The .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:

Key-continuity proof input
mt-renew-v1:<current certificate_id>:<csr_sha256_hex>:<unix_seconds> mt-renew-v1:crt_01jcqb5r0d2e4f6g8h0j2k4m6n:9b74c9897bac770ffc029102a200c5de2a6d3b1f9d5e2c8a1b7f4e6d3c9a0b15:1731641400
  • csr_sha256_hex is the lowercase hex SHA-256 of the new CSR’s DER bytes.
  • unix_seconds is 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 /v1/certificates/{id}/renew
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.

Build the proof with OpenSSL and Node.js
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.

Reload proof input
mt-reload-v1:<new certificate_id>:<unix_seconds>
POST /v1/certificates/{id}/reloaded
POST https://api.mutualtrust.io/v1/certificates/crt_01jcqk8w3p5q7r9s1t3v5w7x9y/reloaded Content-Type: application/json { "proof": "Mf0c…base64url…", "timestamp": 1731641405 }
200 OK
{ "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

Renewal outcomes
StatusCodeWhat to do
404not_foundThe proof did not verify with the current certificate’s key (or the certificate ID is wrong). The response deliberately does not say which.
409certificate_revokedThe current certificate was revoked. Re-enroll if your grant is still active.
409certificate_expiredToo late for key continuity. Re-enroll with a fresh attestation.
409grant_not_activeThe grant was suspended, revoked or expired. Renewal is blocked until the API owner restores it.
422validation_failedTimestamp outside ±5 minutes (check the workload clock), or the CSR was rejected.
503signer_unavailableRetry with backoff. Your current certificate keeps working until it expires.

Alert on renewal runway

The portal overview warns when an active relationship’s certificate expires within 6 hours. Treat a failing renewal as urgent once less than a third of the validity remains.