HelloJohn / docs
Security

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:

  1. Generate a new key pair
  2. Add the new private key as the signing key (new tokens use this key)
  3. Keep the old private key in verification-only mode (existing tokens remain valid)
  4. 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.pem

Step 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:

  1. Add a second signing secret to the endpoint
  2. Your handler accepts signatures from either secret
  3. 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

SecretRecommended rotationPriority
JWT signing keyEvery 90 daysHigh
Admin API keysEvery 90 daysHigh
Webhook secretsAnnually or on compromiseMedium
SMTP credentialsOn provider rotationMedium
Database passwordEvery 180 daysHigh
Redis passwordEvery 180 daysMedium

On this page