Key Rotation
Rotate JWT signing keys and other secrets without downtime or user disruption.
Key Rotation
Rotating cryptographic keys limits the impact of a key compromise. HelloJohn supports zero-downtime key rotation for JWT signing keys, refresh token secrets, and webhook signing secrets.
JWT signing key rotation
HelloJohn uses Ed25519 key pairs to sign JWTs. The JWKS endpoint (/.well-known/jwks.json) serves all currently valid public keys, so token verification continues to work during and after rotation.
Rotation strategy
HelloJohn uses a dual-key rotation model:
- Generate a new key pair
- Add the new private key as the signing key (new tokens use this key)
- Keep the old private key in verification-only mode (existing tokens remain valid)
- After all old tokens expire (max 15 minutes for access tokens), remove the old key
This means there is no downtime and no need to revoke existing sessions.
Step 1: Generate a new Ed25519 key pair
openssl genpkey -algorithm ed25519 -out new_private.pem
openssl pkey -in new_private.pem -pubout -out new_public.pemStep 2: Add the new key
POST /v1/system/jwt-keys
Authorization: Bearer <system_admin_api_key>
Content-Type: application/json
{
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
"public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
"set_as_signing_key": true // new tokens will use this key
}Response:
{
"key_id": "kid_01HXXXX",
"algorithm": "EdDSA",
"created_at": "2024-01-15T00:00:00Z",
"is_signing_key": true,
"status": "active"
}At this point:
- New JWTs are signed with the new key
- Old JWTs (signed with the old key) are still valid — JWKS serves both public keys
Step 3: Retire the old key (after TTL expires)
Wait for the access token TTL to expire (default 15 minutes), then remove the old key:
DELETE /v1/system/jwt-keys/{old_key_id}
Authorization: Bearer <system_admin_api_key>The old public key is removed from JWKS. Any remaining JWTs signed with the old key will now fail verification — but since the TTL has passed, there should be none.
Listing active keys
GET /v1/system/jwt-keys
Authorization: Bearer <system_admin_api_key>{
"keys": [
{
"key_id": "kid_01HNEW",
"algorithm": "EdDSA",
"is_signing_key": true,
"created_at": "2024-01-15T00:00:00Z",
"status": "active"
},
{
"key_id": "kid_01HOLD",
"algorithm": "EdDSA",
"is_signing_key": false,
"created_at": "2023-07-01T00:00:00Z",
"status": "retiring"
}
]
}Automated rotation script
#!/bin/bash
# rotate-jwt-keys.sh
set -e
HJ_URL="https://auth.yourdomain.com"
HJ_ADMIN_KEY="${HJ_SYSTEM_ADMIN_KEY}"
# Generate new key pair
openssl genpkey -algorithm ed25519 -out /tmp/new_private.pem 2>/dev/null
openssl pkey -in /tmp/new_private.pem -pubout -out /tmp/new_public.pem 2>/dev/null
PRIVATE_KEY=$(cat /tmp/new_private.pem | jq -Rsa .)
PUBLIC_KEY=$(cat /tmp/new_public.pem | jq -Rsa .)
# Add new key as signing key
NEW_KEY=$(curl -s -X POST "${HJ_URL}/v1/system/jwt-keys" \
-H "Authorization: Bearer ${HJ_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d "{\"private_key\": ${PRIVATE_KEY}, \"public_key\": ${PUBLIC_KEY}, \"set_as_signing_key\": true}")
echo "New signing key created: $(echo $NEW_KEY | jq -r .key_id)"
# Wait for old tokens to expire
echo "Waiting for access token TTL (15 minutes)..."
sleep 900
# Get old keys (not signing key) and retire them
OLD_KEYS=$(curl -s "${HJ_URL}/v1/system/jwt-keys" \
-H "Authorization: Bearer ${HJ_ADMIN_KEY}" | \
jq -r '.keys[] | select(.is_signing_key == false) | .key_id')
for KEY_ID in $OLD_KEYS; do
echo "Retiring key: ${KEY_ID}"
curl -s -X DELETE "${HJ_URL}/v1/system/jwt-keys/${KEY_ID}" \
-H "Authorization: Bearer ${HJ_ADMIN_KEY}"
done
# Clean up
rm /tmp/new_private.pem /tmp/new_public.pem
echo "Key rotation complete."Schedule this script (e.g., quarterly with cron).
Refresh token secret rotation
Refresh tokens are hashed with SHA-256 before storage — they don't depend on the JWT signing key. Rotating the JWT signing key does not invalidate refresh tokens.
If you need to revoke all refresh tokens (e.g., after a suspected breach):
# Revoke all sessions globally (signs everyone out)
POST /v1/system/sessions/revoke-all
Authorization: Bearer <system_admin_api_key>
Content-Type: application/json
{
"reason": "Security incident — forced re-authentication",
"notify_users": true // sends an email notification
}Webhook signing secret rotation
Rotate webhook signing secrets per endpoint without interruption:
- Add a second signing secret to the endpoint
- Your handler accepts signatures from either secret
- After confirming delivery with the new secret, remove the old one
# Step 1: Add new secret
PATCH /v1/webhooks/{webhook_id}
{
"secret": "new-secret",
"rotate_secret": true // old secret remains valid during transition
}
# Step 2: Remove old secret (after confirming new one works)
PATCH /v1/webhooks/{webhook_id}
{
"finalize_rotation": true
}SMTP credential rotation
Update SMTP credentials without downtime:
# Update via environment variable and restart
SMTP_PASS=new-smtp-password
# Or via API (Cloud)
PATCH /v1/system/config/smtp
{
"host": "smtp.yourdomain.com",
"port": 587,
"user": "noreply@yourdomain.com",
"password": "new-smtp-password"
}Rotation schedule recommendations
| Secret | Recommended rotation | Priority |
|---|---|---|
| JWT signing key | Every 90 days | High |
| Admin API keys | Every 90 days | High |
| Webhook secrets | Annually or on compromise | Medium |
| SMTP credentials | On provider rotation | Medium |
| Database password | Every 180 days | High |
| Redis password | Every 180 days | Medium |