Encrypted Saved Objects (ESO) in Kibana — registration, AAD attribute choices, partial update safety, model version migrations with createModelVersion, canEncrypt checks, and Serverless constraints. Use when creating, modifying, or working with ESO types.
Sensitive Data Protection: Encrypted Saved Objects protect credentials, API keys, PII, and other secrets stored in Kibana. Incorrect ESO changes can make objects permanently undecryptable.
An Encrypted Saved Object (ESO) is a Saved Object type registered with the ESO Service to specify:
attributesToEncrypt: Attributes containing sensitive data (encrypted at rest)attributesToIncludeInAAD: Attributes used as Additional Authenticated Data (bound to the encrypted data, must match exactly during decryption)The ESO Service encrypts using the xpack.encryptedSavedObjects.encryptionKey Kibana config setting. In development, a static key is auto-configured.
Definitive reference: dev_docs/key_concepts/encrypted_saved_objects.mdx
Only register a Saved Object type as encrypted if it stores genuinely sensitive data:
Most Saved Object types do not need encryption. When in doubt, consult #kibana-security.
savedObjects.registerType({
name: 'my_encrypted_type',
hidden: true,
namespaceType: 'multiple-isolated',
mappings: { /* ... */ },
modelVersions: myModelVersions,
});
encryptedSavedObjects.registerType({
type: 'my_encrypted_type', // must match the Core registration name
attributesToEncrypt: new Set(['secrets']),
attributesToIncludeInAAD: new Set(['connectorType', 'createdAt']),
});
Key rules:
type string must exactly match the name used in Core's savedObjects.registerTypeattributesToEncrypt must not be empty{ key: 'fieldName', dangerouslyExposeValue: true } only when decrypted values must be exposed through standard SO client APIs (e.g. get, find); this requires thorough justification and documentationattributesToEncryptEncrypt any attribute containing sensitive data. By default, encrypted attributes are stripped from responses when accessed via standard Saved Object Client APIs (get, find, etc.). To access decrypted values, use the dedicated ESO Client APIs (getDecryptedAsInternalUser, createPointInTimeFinderDecryptedAsInternalUser).
attributesToIncludeInAADAAD attributes are not encrypted but are cryptographically bound to the encrypted data. If any AAD attribute changes, all encrypted attributes must be re-encrypted.
INCLUDE in AAD — attributes that:
createdAt, createdBy, type identifiers)EXCLUDE from AAD — attributes that:
attributesToEncryptupdatedAt)Be conservative: only include attributes the team is 100% confident should be included. Adding an existing populated attribute to AAD later is not supported in Serverless.
Nested attributes: When an attribute is included in AAD, all of its subfields are inherently included. For more granular control, use dotted keys like rule.apiKeyOwner instead of the entire rule object.
Critical: Partial updates (savedObjectsClient.update or savedObjectsRepository.update) on ESOs must never modify encrypted attributes or AAD-included attributes. Doing so corrupts the object, making it permanently undecryptable.
Required pattern: Create a type-safe partial update helper that strips encrypted and AAD attributes:
export const MyTypeAttributesToEncrypt = ['secrets'];
export const MyTypeAttributesIncludedInAAD = ['connectorType', 'createdAt'];
export type MyTypeAttributesNotPartiallyUpdatable = 'secrets' | 'connectorType' | 'createdAt';
export type PartiallyUpdateableMyTypeAttributes = Partial<
Omit<MyTypeSO, MyTypeAttributesNotPartiallyUpdatable>
>;
export async function partiallyUpdateMyType(
savedObjectsClient: Pick<SavedObjectsClient, 'update'>,
id: string,
attributes: PartiallyUpdateableMyTypeAttributes,
options: SavedObjectsUpdateOptions = {}
): Promise<void> {
const safeAttributes = omit(attributes, [
...MyTypeAttributesToEncrypt,
...MyTypeAttributesIncludedInAAD,
]);
await savedObjectsClient.update('my_encrypted_type', id, safeAttributes, options);
}
Any code that calls savedObjectsClient.update or savedObjectsRepository.update on an ESO type must verify that encrypted and AAD attributes are excluded from the update payload.
Use the dedicated ESO Client for accessing decrypted attributes. These run as the internal Kibana user and should not expose secrets to end users unless absolutely necessary:
// Single object
const decrypted = await encryptedSavedObjectsClient.getDecryptedAsInternalUser<MyType>(
'my_encrypted_type',
objectId,
{ namespace }
);
// Bulk find with decryption
const finder = await encryptedSavedObjectsClient
.createPointInTimeFinderDecryptedAsInternalUser<MyType>({
type: 'my_encrypted_type',
perPage: 100,
});
Decrypted values should not be returned directly in HTTP responses without explicit justification. Calling getDecryptedAsInternalUser with a type that is not registered as encrypted throws at runtime.
canEncryptThe ESO encryption key is optional. Plugins must check canEncrypt and handle the case where encryption is unavailable:
// Setup phase: store the canEncrypt flag
const canEncrypt = plugins.encryptedSavedObjects.canEncrypt;
// Runtime: degrade gracefully or reject operations
if (!canEncrypt) {
// Option 1: Reject the operation with a clear error
throw new Error('Encryption key is not configured. Cannot create encrypted objects.');
// Option 2: Degrade gracefully (e.g., skip encryption-dependent features)
logger.warn('Encryption key not set. Feature X is unavailable.');
}
Any plugin that uses ESO features (registers types, calls getDecryptedAsInternalUser, etc.) without checking canEncrypt or handling the absence of an encryption key has a bug.
When an ESO type's encrypted attributes or AAD-included attributes change, use createModelVersion to wrap the model version definition with automatic decryption/re-encryption:
import type { EncryptedSavedObjectTypeRegistration } from '@kbn/encrypted-saved-objects-plugin/server';
// Previous version's registration (for decryption)
const inputType: EncryptedSavedObjectTypeRegistration = {
type: 'my_encrypted_type',
attributesToEncrypt: new Set(['secrets']),
attributesToIncludeInAAD: new Set(['connectorType']),
};
// New version's registration (for re-encryption)
const outputType: EncryptedSavedObjectTypeRegistration = {
type: 'my_encrypted_type',
attributesToEncrypt: new Set(['secrets']),
attributesToIncludeInAAD: new Set(['connectorType', 'createdAt']),
};
// In the Saved Object type registration:
modelVersions: {
2: plugins.encryptedSavedObjects.createModelVersion({
modelVersion: {
changes: [
{
type: 'data_backfill',
backfillFn: (doc) => ({
attributes: { createdAt: doc.attributes.createdAt ?? new Date().toISOString() },
}),
},
],
schemas: {
forwardCompatibility: mySchemaV2.extends({}, { unknowns: 'ignore' }),
create: mySchemaV2,
},
},
inputType,
outputType,
shouldTransformIfDecryptionFails: true, // optional: proceed even if decryption fails
}),
},
Key rules:
createModelVersion requires at least one change in the changes arrayinputType must match the ESO registration from the previous model versionoutputType must match the ESO registration for the new model versionunsafe_transform, data_backfill, data_removal) are merged into a single decrypt-transform-encrypt passcreateModelVersion is only needed when encrypted or AAD attributes change; purely unencrypted, non-AAD changes can use standard model versionsReference implementation: examples/eso_model_version_example/server/plugin.ts
In Serverless, both the current and previous Kibana versions may run simultaneously. The previous version must be able to decrypt ESOs migrated by the new version without knowledge of the new model version.
Some changes require 2 Serverless releases:
Adding a new AAD attribute:
attributesToIncludeInAAD in the registration. Do NOT populate or use the attribute yet.createModelVersion to backfill and start using the attribute.Removing an attribute (when previous version depends on it):
forwardCompatibility schemaSet unknowns: 'ignore' in the forwardCompatibility schema when the previous version should drop unknown fields. This is helpful if the additional fields are not compatible or problematic in the previous version.
During model version transformation, decryption occurs BEFORE the forwardCompatibility schema is applied. This supports hierarchical AAD — when subfields of an AAD attribute are added or removed, the previous version can still successfully construct AAD, ensuring objects can be decrypted before being adapted for the previous version.
| Change | Encrypted? | In AAD? | Needs createModelVersion? | Serverless stages |
|---|---|---|---|---|
| Add new attribute | No | No | No | 1 (with forwardCompatibility if needed) |
| Add new attribute | No | Yes | Yes | 2 |
| Add new attribute | Yes | N/A | Yes | 1 (with forwardCompatibility if needed) |
| Remove attribute | No | No | No | 1-2 depending on business logic |
| Remove attribute | No | Yes | Yes | 1-2 depending on business logic |
| Remove attribute | Yes | N/A | No | 1-2 depending on business logic |
| Modify attribute (add/remove subfield) | No | No | No | 1-2 depending on business logic |
| Modify attribute (add/remove subfield) | No | Yes | Yes | 1-2 depending on business logic |
| Modify attribute (add/remove subfield) | Yes | N/A | Yes | 1-2 depending on business logic |
| Add existing attribute to AAD | No | No->Yes | Not supported | N/A |
| Remove attribute from AAD | No | Yes->No | Not supported | N/A |
| Change unencrypted to encrypted | No->Yes | Any | Not supported | N/A |
| Change encrypted to unencrypted | Yes->No | N/A | Not supported | N/A |
When working with ESO-related code, verify:
Registration correctness
type matches the Core Saved Object registration nameattributesToEncrypt contains only genuinely sensitive attributesattributesToIncludeInAAD follows the inclusion/exclusion guidelines abovedangerouslyExposeValue is only used with documented justificationPartial update safety
savedObjectsClient.update calls modify encrypted or AAD attributesNotPartiallyUpdatable type is kept in sync with the ESO registrationModel version migrations
createModelVersion is used when encrypted or AAD attributes changeinputType matches the previous version's ESO registrationoutputType matches the new version's ESO registrationforwardCompatibility schema is set with unknowns: 'ignore' when appropriateServerless compatibility
Encryption availability
canEncrypt is checked before using ESO-dependent featuresSecret exposure
getDecryptedAsInternalUser are consumed internally, not exposed in API responsesx-pack/platform/plugins/shared/encrypted_saved_objects/