semaphoreui/semaphore · error

jwt: persist re-encrypted signing key

Error message

jwt: persist re-encrypted signing key: %w

What it means

The final step of RekeyJWTSigningKey writes the re-encrypted ciphertext back with store.SetOption(jwtSigningKeyOption, reEncrypted). This error wraps any failure of that write; the key was already decrypted and re-encrypted, but the new ciphertext never reached the store.

Solutions

  1. Inspect the wrapped cause (%w) and fix the store write failure (connectivity, read-only mode, permissions).
  2. Re-run RekeyJWTSigningKey once the store is writable - it is idempotent and returns nil if the ciphertext is unchanged.
  3. Verify the stored value decrypts with the new keyset after a successful rekey.

Example fix

// before
if err := store.SetOption(util.JWTSigningKeyOption, reEncrypted); err != nil {
    return fmt.Errorf("jwt: persist re-encrypted signing key: %w", err)
}
// after (caller side)
if err := util.RekeyJWTSigningKey(store, oldKey); err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        // retry: operation is idempotent
        err = util.RekeyJWTSigningKey(store, oldKey)
    }
    return err
}
Defensive patterns

Strategy: retry

Validate before calling

if err := store.GetOption(util.JWTSigningKeyOption); err != nil {
    return fmt.Errorf("store not writable/available: %w", err)
}

Try / catch

err := util.RekeyJWTSigningKey(store, oldKey)
for i := 0; err != nil && strings.Contains(err.Error(), "persist re-encrypted signing key") && i < 3; i++ {
    time.Sleep(time.Duration(1<<i) * time.Second) // idempotent, safe to retry
    err = util.RekeyJWTSigningKey(store, oldKey)
}

Prevention

When it happens

Trigger: Calling RekeyJWTSigningKey when the OptionStore rejects SetOption - database down or read-only, connection dropped mid-write, constraint violations, or a store implementation with write disabled.

Common situations: DB disk full or in read-only mode during `vault rekey`; permissions revoked on the options table; transient network failure between app and database.

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/cb1fade112e5eaef. Report an issue: GitHub.

Appendix: source

Thrown at util/jwt.go:150

	if err != nil && oldKey != "" {
		_, ct, _ := parseEnvelope(stored)
		pemBytes, err = DecryptAESGCM(ct, oldKey)
	}
	if err != nil {
		return fmt.Errorf("jwt: decrypt signing key for rekey: %w", err)
	}

	reEncrypted, err := Config.EncryptOption(pemBytes)
	if err != nil {
		return fmt.Errorf("jwt: re-encrypt signing key: %w", err)
	}

	if reEncrypted == stored {
		return nil
	}

	if err := store.SetOption(jwtSigningKeyOption, reEncrypted); err != nil {
		return fmt.Errorf("jwt: persist re-encrypted signing key: %w", err)
	}
	return nil
}

// encryptJWTKey encrypts pemBytes using the option keyring primary key (which
// falls back to the access key when no separate option key is configured).
func encryptJWTKey(pemBytes []byte) (string, error) {
	return Config.EncryptOption(pemBytes)
}

// decryptJWTKey reverses encryptJWTKey. It tries the option keyring and then
// the access keyring as a migration fallback, so a key written before the
// option/access split (encrypted with the access key) still loads.
func decryptJWTKey(stored string) ([]byte, error) {
	plaintext, err := Config.DecryptOption(stored)
	if err != nil {
		return nil, fmt.Errorf("jwt: decrypt signing key: %w", err)
	}

View on GitHub (pinned to 1774ccb71a)