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
- Restore the keyring configuration (option/access keys) that originally encrypted the value.
- Run `vault rekey --old-key <previous-key>` with the old key to migrate the stored key to the current keyset.
- 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
- Run `vault rekey --old-key <previous>` whenever rotating option/access keys.
- Never point an instance at a database written with different keyring config without migrating keys first.
- Back up the option store and key configuration together.
- Accept that deleting the signing key invalidates all issued JWTs; warn users of forced re-login.
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
- jwt: decrypt signing key for rekey
- jwt: re-encrypt signing key
- encryption key id not found in keyset (the key encrypting…
- internal error
- You can't use both HTTP redirect address and port at the…
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)