semaphoreui/semaphore · critical

jwt: decrypt signing key

Error message

jwt: decrypt signing key: %w

What it means

decryptJWTKey reverses encryptJWTKey when loading the stored JWT signing key: it decrypts via Config.DecryptOption, which tries the option keyring and then the access keyring as a migration fallback. This error means neither keyring could decrypt the stored key, so the JWT key cannot be loaded.

Solutions

  1. Restore the keyring configuration (option/access keys) that originally encrypted the value.
  2. Run `vault rekey --old-key <previous-key>` with the old key to migrate the stored key to the current keyset.
  3. As a last resort delete the jwt signing key option so a new one is generated - all previously issued JWT tokens become invalid and users must re-login.

Example fix

// before
// rotate key env, restart -> decryptJWTKey fails
// after
// migrate before dropping the old key:
//   semaphore vault rekey --old-key "$OLD_OPTION_KEY"
// then rotate keys and restart
Defensive patterns

Strategy: fallback

Validate before calling

// before startup, confirm the stored option is decryptable with current keys
if stored, err := store.GetOption(util.JWTSigningKeyOption); err == nil && stored != "" {
    if _, err := util.Config.DecryptOption(stored); err != nil {
        // trigger recovery: rekey with old key or regenerate signing key
    }
}

Try / catch

pem, err := loadOrCreateJWTKey()
if err != nil && strings.Contains(err.Error(), "jwt: decrypt signing key") {
    // recovery path: delete the option to force a fresh key (invalidates tokens)
    _ = store.DeleteOption(util.JWTSigningKeyOption)
    pem, err = loadOrCreateJWTKey()
}

Prevention

When it happens

Trigger: Calling loadOrCreateJWTKey (server startup / JWT initialization) when the stored option ciphertext was encrypted with a key that is no longer in the option keyset or the access keyring, or the ciphertext is corrupted.

Common situations: Rotating SEMAPHORE_OPTION_KEY / access key without rekeying first; pointing a deployment at a database written by another instance with different keys; corrupted option row.

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/a3f628d3853b3cd6. Report an issue: GitHub.

Appendix: source

Thrown at util/jwt.go:167

	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)
	}
	return plaintext, nil
}

View on GitHub (pinned to 1774ccb71a)