semaphoreui/semaphore · error

jwt: decrypt signing key for rekey

Error message

jwt: decrypt signing key for rekey: %w

What it means

During a JWT signing key rekey, the stored ciphertext must be decrypted before re-encryption. RekeyJWTSigningKey first tries Config.DecryptOption (option keyset, then access-key fallback); if that fails and oldKey was supplied (legacy `vault rekey --old-key`), it also tries AES-GCM with oldKey. This error means every available key failed to decrypt the stored value.

Solutions

  1. Re-run with the legacy key: pass the previous value to oldKey (`vault rekey --old-key <previous-key>`) so the legacy ciphertext can be decrypted.
  2. Restore the keyset entry (key id in the envelope) that originally encrypted the option - check keyring configuration/env vars.
  3. If the plaintext is unrecoverable, delete the stored option so a fresh JWT signing key is generated on next load, accepting invalidation of issued tokens.

Example fix

// before
err := util.RekeyJWTSigningKey(store, "") // legacy-encrypted value, no old key
// after
err := util.RekeyJWTSigningKey(store, oldKey) // supply legacy key from `vault rekey --old-key`
Defensive patterns

Strategy: fallback

Validate before calling

// verify at least one key can decrypt before rekeying
if _, err := util.Config.DecryptOption(stored); err != nil && oldKey == "" {
    return errors.New("cannot decrypt stored key: supply --old-key for legacy values")
}

Try / catch

if err := util.RekeyJWTSigningKey(store, oldKey); err != nil {
    if strings.Contains(err.Error(), "decrypt signing key for rekey") {
        return fmt.Errorf("no available key decrypts the stored signing key; re-run with the legacy --old-key or restore the original keyset: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: Calling RekeyJWTSigningKey when: the option keyset and access keyring were rotated and no longer contain the encrypting key, the stored ciphertext is corrupted/truncated, or decryption fails and no oldKey was passed for a value still encrypted with the legacy key.

Common situations: Operator rekeys after rotating SEMAPHORE_OPTION_KEY / access key without the old key; legacy installation encrypted with the access key but rekey run without --old-key; ciphertext damaged by manual DB edits or failed writes.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


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

Appendix: source

Thrown at util/jwt.go:137

// option key (stamping its id). It decrypts via the option keyset, the access
// fallback, and — when supplied — oldKey (the legacy `vault rekey --old-key`
// flow). No-op when no key is stored or the ciphertext is unchanged.
func RekeyJWTSigningKey(store OptionStore, oldKey string) error {
	stored, err := store.GetOption(jwtSigningKeyOption)
	if err != nil {
		return fmt.Errorf("read jwt signing key option: %w", err)
	}
	if stored == "" {
		return nil
	}

	pemBytes, err := Config.DecryptOption(stored)
	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

View on GitHub (pinned to 1774ccb71a)