Skip to content

Key Rotation

Procedures for rotating cryptographic keys and certificates in Mantis.

Key TypePurposeRecommended Interval
JWT signing keyAPI authentication90 days
Encryption master keyData at rest365 days
TLS certificatesTransport security365 days
Agent certificatesmTLS authentication365 days
CA certificateCertificate authority5-10 years
TriggerActionUrgency
Scheduled rotationPlan and executeNormal
Key compromiseImmediate rotationCritical
Personnel changeReview and rotateHigh
Certificate expiryRenew before expiryHigh
Compliance auditRotate as requiredNormal

For HMAC-based JWT:

Step-by-step:

  1. Generate new key:
Terminal window
NEW_JWT_SECRET=$(openssl rand -base64 32)
  1. Set the new key, keeping the old one for verification. Supply these as environment variables, not TOML — MANDIBLE__JWT__* overrides the file, so editing [jwt] while the env var is set leaves the old secret in force and the rotation silently does nothing:
/etc/mantis/mandible.env
MANDIBLE__JWT__SECRET="$NEW_JWT_SECRET"
MANDIBLE__JWT__KEY_ID="k2" # new id for the new key
MANDIBLE__JWT__PREVIOUS_SECRET="<old secret>"
MANDIBLE__JWT__PREVIOUS_KEY_ID="k1" # required, or the old key is dropped
  1. Restart Mandible:
Terminal window
systemctl restart mandible
  1. Wait for old tokens to expire. Access tokens last 1 hour by default; wait at least that long so no live token is still signed with k1.

  2. Remove the old key:

Terminal window
MANDIBLE__JWT__SECRET="$NEW_JWT_SECRET"
MANDIBLE__JWT__KEY_ID="k2"
# PREVIOUS_SECRET and PREVIOUS_KEY_ID removed
  1. Restart again:
Terminal window
systemctl restart mandible

For RSA-based JWT:

  1. Generate new key pair:
Terminal window
openssl genrsa -out /etc/mantis/jwt/private-new.pem 2048
openssl rsa -in /etc/mantis/jwt/private-new.pem \
-pubout -out /etc/mantis/jwt/public-new.pem
  1. Point at the new pair and keep the old public key for verification. As with HS256, key_id must change and the outgoing id must move to previous_key_id:
Terminal window
MANDIBLE__JWT__PRIVATE_KEY_PATH="/etc/mantis/jwt/private-new.pem"
MANDIBLE__JWT__PUBLIC_KEY_PATH="/etc/mantis/jwt/public-new.pem"
MANDIBLE__JWT__KEY_ID="k2" # new
MANDIBLE__JWT__PREVIOUS_PUBLIC_KEY_PATH="/etc/mantis/jwt/public-old.pem"
MANDIBLE__JWT__PREVIOUS_KEY_ID="k1" # outgoing

Leaving key_id at k1 while also setting previous_key_id = "k1" resolves pre-rotation tokens to the new key, so they all fail verification — the opposite of the overlap this procedure exists to provide.

  1. Follow the same restart and cleanup process as HS256

For high-availability deployments:

Rotating the encryption master key requires re-encrypting all data:

Step-by-step:

  1. Generate new master key:
Terminal window
NEW_MASTER_KEY=$(openssl rand -base64 32)
  1. Stop Mandible, the only service that holds the key:
Terminal window
systemctl stop mandible
  1. Run the rotation. mantisctl key rotate connects directly to the database and reads the connection string from DATABASE_URL (or the config file) — there is no --database-url flag, so export it first:
Terminal window
export DATABASE_URL="postgres://mantis:...@db-host:5432/mantis"
# Both keys come from the environment; passed as arguments they would be visible
# in the process list for the whole rotation. `--old-key` is required, so omitting
# MANTIS_ENCRYPTION_KEY aborts the command.
export MANTIS_ENCRYPTION_KEY="$OLD_MASTER_KEY"
export MANTIS_NEW_ENCRYPTION_KEY="$NEW_MASTER_KEY"
mantisctl key rotate --batch-size 100
  1. Monitor progress:
Terminal window
# Key Rotation
#
# Counting records to scan...
# command_queue: 0 records
# storages_s3: 120 records
# storages_git_auth: 40 records
# identity_providers: 5 records
# ...
# Total: 165 records to scan
#
# Rotating command_queue...
# ✓ command_queue: 0 processed, 0 successful, 0 failed
# Rotating storages_s3...
# ✓ storages_s3: 120 processed, 120 successful, 0 failed
# Rotating storages_git_auth...
# ✓ storages_git_auth: 40 processed, 40 successful, 0 failed
# ...
# ✓ 165 records rotated successfully!
# Checkpoint removed.

Rotation covers the platform root and any tenant certificate authority alongside the other encrypted columns, so the signing keys move to the new encryption key with everything else.

  1. Set the new key in Mandible’s environment:
Terminal window
# In the systemd unit env file, not just this shell
MANTIS_ENCRYPTION_KEY="$NEW_MASTER_KEY"
  1. Start Mandible:
Terminal window
systemctl start mandible
  1. Verify:
Terminal window
mantisctl key verify --key "$NEW_MASTER_KEY"
# ✓ command_queue: 0 records OK
# ✓ storage_credentials: 120 records OK
# ✓ git_credentials: 40 records OK
# ✓ identity_providers: 5 records OK
# ...
# ✓ 165 records verified successfully!

If rotation fails mid-process, it writes a checkpoint. Resume from where it stopped by re-running the same command with --resume:

Terminal window
mantisctl key rotate --resume

For Mandible and Thorax server certificates:

  1. Generate new certificate:
Terminal window
mantisctl cert generate-server \
--name mantis --sans mantis.example.com --output-dir /etc/mantis/certs \
--ca-cert /etc/mantis/ca/ca.crt --ca-key /etc/mantis/ca/ca.key

--ca-cert and --ca-key default to ./certs/ca-cert.pem and ./certs/ca-key.pem, so pass them explicitly unless you are running from a directory laid out that way.

By hand, the SAN is required — rustls verifies the hostname against it, and a certificate without one is rejected:

Terminal window
openssl req -new -key /etc/mantis/certs/server.key \
-out /etc/mantis/certs/server-new.csr \
-subj "/CN=mantis.example.com"
cat > server.ext <<'EOF'
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:mantis.example.com
EOF
openssl x509 -req -in server-new.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out /etc/mantis/certs/server-new.crt \
-days 365 -sha256 -extfile server.ext
  1. Verify new certificate:
Terminal window
openssl verify -CAfile ca.crt /etc/mantis/certs/server-new.crt
  1. Replace certificate:
Terminal window
cp /etc/mantis/certs/server-new.crt /etc/mantis/certs/server.crt
  1. Restart the service (certificates are read at startup; there is no reload):
Terminal window
systemctl restart mandible

With load balancer:

rotate-cert.sh
#!/bin/bash
SERVERS="mandible1 mandible2"
for server in $SERVERS; do
echo "Rotating certificate on $server..."
# Drain from load balancer
curl -X POST "http://lb/api/drain/$server"
sleep 10
# Update certificate
scp server-new.crt "$server:/etc/mantis/certs/server.crt"
ssh "$server" "systemctl restart mandible"
# Wait for health check
sleep 5
# Re-enable in load balancer
curl -X POST "http://lb/api/enable/$server"
echo "$server rotated successfully"
done

For Tarsus agent certificates:

Thumbprint mode:

Use tarsus rotate-certificate. It swaps the certificate in place and keeps the agent’s registration and approved status, so the agent never returns to the pending queue. Before it asks, it connects to Mandible with the new pair and refuses the rotation if the server will not accept it: the server retires the old certificate when it commits, so a replacement from the wrong CA would otherwise leave the agent with no identity it can use.

Terminal window
# On the agent: issue the replacement (both EKUs + SAN, see Certificate Rotation)
mantisctl cert generate-server \
--name "$(hostname)" \
--sans "$(hostname -f)" \
--output-dir /tmp/rotate \
--ca-cert /etc/mantis/ca/ca.crt \
--ca-key /etc/mantis/ca/ca.key
tarsus rotate-certificate \
--new-cert /tmp/rotate/$(hostname)-cert.pem \
--new-key /tmp/rotate/$(hostname)-key.pem \
--reason "scheduled rotation"
systemctl restart tarsus@<instance>

CA-signed mode:

Terminal window
# Generate new CSR
openssl req -new -key /etc/mantis/certs/tarsus-key.pem \
-out /tmp/agent-new.csr \
-subj "/CN=$(hostname)"
# Submit to CA for signing
# ... CA signing process ...
# Install new certificate
cp agent-new.crt /etc/mantis/certs/tarsus-cert.pem
# Restart agent
systemctl restart tarsus

CA rotation is complex and requires careful planning:

┌─────────────────────────────────────────────────────────────┐
│ CA Rotation Timeline │
├─────────────────────────────────────────────────────────────┤
│ │
│ T-90 days: Generate new CA │
│ T-60 days: Begin issuing certs from new CA │
│ T-30 days: All new certs use new CA │
│ T-0: Old CA expires │
│ │
│ During overlap: │
│ - Thorax trusts both CAs │
│ - New agents get new CA certs │
│ - Old agents continue working │
│ │
└─────────────────────────────────────────────────────────────┘
  1. Generate new CA:
Terminal window
openssl genrsa -out /etc/mantis/ca/new-ca.key 4096
openssl req -x509 -new -nodes \
-key /etc/mantis/ca/new-ca.key \
-out /etc/mantis/ca/new-ca.crt \
-days 3650 \
-subj "/CN=Mantis CA v2"
  1. Configure trust for both CAs:
Terminal window
# Concatenate CA certificates
cat /etc/mantis/ca/ca.crt /etc/mantis/ca/new-ca.crt > \
/etc/mantis/ca/combined-ca.crt
[tls]
ca_cert_path = "/etc/mantis/ca/combined-ca.crt"
  1. Replace the stored root. Mandible signs from the certificate authority stored in its database, not from a file, so copying a new certificate over ca.crt changes what Thorax trusts without changing what Mandible signs with. cert rotate-root replaces it in place: the new authority signs from now on and the outgoing one is marked retiring in the same transaction — still trusted, no longer signing. Nothing is deleted, and no tenant authority has to be removed first.

    The combined trust file from step 2 must already be in place on every host: an agent that renews while the fleet still trusts only the old root presents a certificate nothing accepts.

    Terminal window
    mantisctl cert rotate-root --cert /etc/mantis/ca/new-ca.crt \
    --key /etc/mantis/ca/new-ca.key

    mantisctl cert import-ca is for the first root only — it refuses with CertificateAlreadyExists while one is stored.

  2. Re-enroll every agent. Certificates issued by the old root keep verifying only while Thorax still trusts it, which is what the combined trust file in step 2 is for. Each agent needs a certificate from the new root before that trust is withdrawn.

  3. Withdraw trust in the old CA once nothing still chains to it:

Terminal window
# Gate on this: it counts agents by the authority that signed the certificate
# they present, and reports an unresolvable one as unknown rather than as clear.
mantisctl cert rotation-status
# Marks the retiring root retired, after which Mantis trusts nothing it signed.
# Refused while anything still chains to it.
mantisctl cert withdraw-root
cp /etc/mantis/ca/new-ca.crt /etc/mantis/ca/ca.crt

When a key is suspected compromised:

Immediate actions:

  1. JWT key compromise:
Terminal window
# Generate new key immediately
NEW_SECRET=$(openssl rand -base64 32)
# Set it where the scheduled procedure sets it: MANDIBLE__JWT__* overrides the
# TOML, so editing [jwt] while the env var is set leaves the compromised key live.
sed -i "s|^MANDIBLE__JWT__SECRET=.*|MANDIBLE__JWT__SECRET=\"$NEW_SECRET\"|" \
/etc/mantis/mandible.env
systemctl restart mandible
# Every token signed with the old key now fails to verify. Drop
# MANDIBLE__JWT__PREVIOUS_SECRET/PREVIOUS_KEY_ID too, or the compromised key
# keeps verifying.
  1. Master encryption key compromise:
Terminal window
# Rotate immediately (may take time for large datasets)
export MANTIS_ENCRYPTION_KEY="$COMPROMISED_KEY"
export MANTIS_NEW_ENCRYPTION_KEY="$(openssl rand -base64 32)"
mantisctl key rotate --force
  1. Agent certificate compromise:
Terminal window
# Revoke the compromised registration immediately (by ID or thumbprint prefix)
mantisctl cert revoke "<REGISTRATION_UUID>" \
--reason "Certificate compromised"

After emergency rotation:

  1. Document the incident
  2. Review access logs
  3. Identify root cause
  4. Update security procedures
  5. Conduct security review
check-key-rotation.sh
#!/bin/bash
# Check JWT key age
JWT_KEY_DATE=$(stat -c %Y /etc/mantis/jwt/private.pem 2>/dev/null || echo 0)
NOW=$(date +%s)
AGE_DAYS=$(( (NOW - JWT_KEY_DATE) / 86400 ))
if [ $AGE_DAYS -gt 80 ]; then
echo "WARNING: JWT key is $AGE_DAYS days old (rotation recommended at 90)"
fi
# Check certificate expiry
CERT_EXPIRY=$(openssl x509 -enddate -noout -in /etc/mantis/certs/server.crt | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
if [ $DAYS_LEFT -lt 30 ]; then
echo "WARNING: Server certificate expires in $DAYS_LEFT days"
fi

Using cert-manager (Kubernetes):

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mantis-server
spec:
secretName: mantis-tls
duration: 8760h # 1 year
renewBefore: 720h # 30 days before
issuerRef:
name: mantis-ca
kind: ClusterIssuer
dnsNames:
- mantis.example.com
Key/CertificateRotation FrequencyMethod
JWT signing key90 daysOverlap rotation
Encryption master key365 daysRe-encryption
Server TLS certificates365 daysRestart
Agent certificates365 daysPer-agent
CA certificate5-10 yearsPlanned migration
┌─────────────────────────────────────────────────────────────┐
│ Annual Rotation Calendar │
├─────────────────────────────────────────────────────────────┤
│ │
│ Q1 (Jan-Mar): │
│ - JWT key rotation │
│ - Review certificate expiry dates │
│ │
│ Q2 (Apr-Jun): │
│ - JWT key rotation │
│ - Agent certificate batch renewal │
│ │
│ Q3 (Jul-Sep): │
│ - JWT key rotation │
│ - Encryption master key rotation │
│ │
│ Q4 (Oct-Dec): │
│ - JWT key rotation │
│ - Server certificate renewal │
│ - Annual security review │
│ │
└─────────────────────────────────────────────────────────────┘

After any key rotation:

Terminal window
# Verify JWT authentication
curl -X POST https://mantis.example.com/api/v1/auth/login \
-d '{"email":"user@example.com","password":"your-password"}' \
-H "Content-Type: application/json"
# Verify encryption
mantisctl key verify --key "$MANTIS_ENCRYPTION_KEY"
# Verify TLS
# Point at whatever terminates TLS -- usually the reverse proxy on 443.
# Mandible's own port 3000 is plain HTTP unless [server.tls] is configured.
openssl s_client -connect mantis.example.com:443 < /dev/null
# Verify agent connectivity
mantisctl cert list --status approved

If rotation causes issues:

  1. JWT keys: Restore previous key to secret and restart
  2. Encryption keys: Keep old key, investigate issue
  3. Certificates: Restore from backup, restart
Terminal window
# Example: Restore JWT key
cp /backup/jwt-secret /etc/mantis/secrets/jwt-secret
export MANDIBLE__JWT__SECRET=$(cat /etc/mantis/secrets/jwt-secret)
systemctl restart mandible
  • Schedule rotations during low-traffic periods
  • Notify teams before rotation
  • Have rollback plan ready
  • Test rotation in staging environment
  • Verify application functionality
  • Check monitoring for errors
  • Record rotation date and time
  • Document any issues encountered
  • Update runbooks as needed
  • Watch error rates during rotation
  • Check authentication success rates
  • Monitor agent connectivity
  • Use cert-manager for certificates
  • Implement rotation scripts
  • Set up expiry alerts