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 the services that hold the key:
Terminal window
systemctl stop mandible thorax
  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"
mantisctl key rotate \
--old-key "$MANTIS_ENCRYPTION_KEY" \
--new-key "$NEW_MASTER_KEY" \
--batch-size 100
  1. Monitor progress:
Terminal window
# Key Rotation
#
# Counting encrypted records...
# storages_s3: 120 records
# storages_git_auth: 40 records
# identity_providers: 5 records
# ...
# Total: 165 records to rotate
#
# 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 the services’ environment:
Terminal window
# In the systemd unit env file, not just this shell
MANTIS_ENCRYPTION_KEY="$NEW_MASTER_KEY"
  1. Start the services:
Terminal window
systemctl start mandible thorax
  1. Verify:
Terminal window
mantisctl key verify --key "$NEW_MASTER_KEY"
# ✓ storages_s3: 120 records OK
# ✓ storages_git_auth: 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 \
--old-key "$MANTIS_ENCRYPTION_KEY" \
--new-key "$NEW_MASTER_KEY" \
--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

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. Reload service:
Terminal window
# Graceful reload (if supported)
systemctl restart mandible
# Or restart
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:

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
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. Until the stored root is replaced, every certificate issued still comes from the old one.

    Replacing it is disruptive by design. mantisctl cert import-ca refuses while a root is stored, and a root with tenant certificate authorities beneath it cannot be removed until those are removed first — the database restricts it, so that removing a root cannot silently strand the tenants that chain to it.

    Terminal window
    # Tenant authorities first, then the root, then the replacement.
    mantisctl cert import-ca --cert /etc/mantis/ca/new-ca.crt \
    --key /etc/mantis/ca/new-ca.key
  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 no agent presents its certificates:

Terminal window
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)
# Update and restart (accepts service interruption)
sed -i "s/secret = .*/secret = \"$NEW_SECRET\"/" /etc/mantis/mandible.toml
systemctl restart mandible
# All existing tokens invalidated
  1. Master encryption key compromise:
Terminal window
# Rotate immediately (may take time for large datasets)
mantisctl key rotate \
--old-key "$COMPROMISED_KEY" \
--new-key "$(openssl rand -base64 32)" \
--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 daysReload
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 '{"username":"test","password":"test"}' \
-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