semaphoreui/semaphore · critical
encryption key id not found in keyset (the key encrypting…
Error message
encryption key id %q not found in keyset (the key encrypting this value is missing)
What it means
keyset.decrypt parses the stored envelope for a key id and looks the id up in its key map. When the envelope carries a key id that is absent from the keyset, decryption cannot proceed and this error is returned. It is the keyring's way of saying: this value was encrypted by a key that is no longer configured.
Solutions
- Add the missing key (the id is named in the error) back to the keyset configuration so the value can be decrypted.
- Run the rekey flow once the old key is restored to move values onto the current primary key.
- Audit key env/config parity between environments that share a database.
Example fix
// before // keyset: [key-2]; stored envelope: v1:key-1:... // -> "encryption key id \"key-1\" not found" // after // keyset: [key-1 (retained for decryption), key-2 (primary)] // then rekey values so envelopes reference key-2
Defensive patterns
Strategy: validation
Validate before calling
id, _, hasID := util.ParseEnvelope(stored)
if hasID && !keyset.HasID(id) {
return fmt.Errorf("key %q missing from keyset; restore it before decrypting", id)
} Type guard
func hasKeyFor(stored string, ids map[string][]byte) bool {
id, _, hasID := util.ParseEnvelope(stored)
return !hasID || (ids != nil && ids[id] != nil)
} Try / catch
plaintext, err := keysetDecrypt(stored)
if err != nil && strings.Contains(err.Error(), "not found in keyset") {
// extract id from message, restore that key to config, then retry
return fmt.Errorf("restore the named key to the keyset before proceeding: %w", err)
} Prevention
- Retain retired keys in the keyset until all stored envelopes have been rekeyed to the current primary.
- Keep key configuration identical across environments sharing a database.
- After restores/migrations, audit envelope key ids against the configured keyset.
- Rekey stored values promptly after adding a new primary key.
When it happens
Trigger: Any keyring decrypt (options, JWT signing key, etc.) where the stored envelope's key id ("v1:<id>:<ciphertext>" form) is not present in k.byID - keys rotated/removed from config, or data copied between environments with different keysets.
Common situations: After key rotation without rekeying stored values; restoring a DB dump into an environment that lacks the original encryption keys; sharing a database between Semaphore instances configured with different key sets.
Related errors
- cannot decrypt access key, perhaps encryption key was…
- jwt: decrypt signing key for rekey
- jwt: decrypt signing key
- jwt: re-encrypt signing key
AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07).
Data as JSON: /api/errors/e55b2c294fb81aa1.
Report an issue: GitHub.
Appendix: source
Thrown at util/keyring.go:133
// fallback for values written before the option/access split.
func (conf *ConfigType) DecryptOption(stored string) ([]byte, error) {
ks := conf.currentKeyset()
return ks.decrypt(stored, ks.legacyOptionCandidates())
}
// DecryptAccessSecretWithKey decrypts a stored secret with a single explicit key,
// stripping any id prefix. Used by the rekey `--old-key` path.
func (conf *ConfigType) DecryptAccessSecretWithKey(stored, key string) ([]byte, error) {
_, ct, _ := parseEnvelope(stored)
return DecryptAESGCM(ct, key)
}
func (k *keyset) decrypt(stored string, legacy []string) ([]byte, error) {
id, ct, hasID := parseEnvelope(stored)
if hasID {
material, ok := k.byID[id]
if !ok {
return nil, fmt.Errorf("encryption key id %q not found in keyset (the key encrypting this value is missing)", id)
}
return DecryptAESGCM(ct, material)
}
return decryptWithKeys(ct, legacy)
}
// legacyAccessCandidates returns the keys to trial-decrypt an un-prefixed access
// secret: the flat access key first, then every registry key. The empty
// (passthrough) key is excluded unless there are no real keys at all, so a real
// ciphertext is never "successfully" decrypted to garbage by the empty key.
func (k *keyset) legacyAccessCandidates() []string {
return k.legacyCandidates(k.legacyAccess)
}
// legacyOptionCandidates is like legacyAccessCandidates but tries the flat option
// key, then the flat access key, then the registry.
func (k *keyset) legacyOptionCandidates() []string {
return k.legacyCandidates(k.legacyOption, k.legacyAccess)View on GitHub (pinned to 1774ccb71a)