Home › Features › Zero-Knowledge Encryption
Available since v0.3.0
Zero-knowledge encryption (AES-256-GCM) encrypts cached data client-side. The backend never sees plaintext values. Perfect for sensitive data (PII, credentials, health info).
@cache.secure(ttl=300, master_key=secret_key) # AES-256-GCM encryption
def get_user_ssn(user_id):
return db.get_ssn(user_id) # Encrypted in Redis, decrypted in-app (illustrative)Enable encryption with single decorator:
from cachekit import cache
# Key comes from CACHEKIT_MASTER_KEY (64 hex chars) when master_key is omitted.
@cache.secure(ttl=300) # AES-256-GCM enabled
def get_sensitive_data(user_id):
return db.query(SensitiveData).filter_by(id=user_id).first() # illustrative - db not defined
data = get_sensitive_data(123) # Encrypted in RedisLater examples pass the key explicitly as master_key=secret_key: your 64-hex-char key string,
loaded from a secret store. Never a literal in source. A bytes key raises TypeError: pass
key.hex() (details).
@cache.secureneeds a backend.backend=None(L1-only) stores raw Python objects, which cannot be ciphertext, so the combination is refused at decoration time with aConfigurationError. With a backend configured, L1 does stay on — it holds the same ciphertext L2 does.
Encryption pipeline (works with any allowed serializer, see Serializer requirement):
Python object (plaintext)
↓
Serialize (MessagePack/JSON/Arrow - your choice)
↓
AES-256-GCM encryption
↓
Derive per-tenant key (optional)
↓
Storage backend (ciphertext values; the key stays cleartext - Redis/HTTP/Custom)
↓
On cache hit:
↓
Decrypt with master key
↓
Deserialize (MessagePack/JSON/Arrow)
↓
Python object (plaintext, in-app only)
Key insight: Encryption is orthogonal to serialization. You can encrypt MessagePack, JSON (OrjsonSerializer), or DataFrames (ArrowSerializer) for true zero-knowledge caching of any data type.
Security properties:
- AES-256-GCM: Authenticated encryption, 256-bit key
- Client-side: Encryption happens in Python, before Redis
- Master key: CACHEKIT_MASTER_KEY environment variable
- Per-tenant key derivation: Optional, and not a tenancy boundary on its own (see Multi-Tenant Isolation)
- Nonce uniqueness: Counter-based, prevents nonce reuse
- Authentication: GCM mode prevents tampering
Compliance scenario: Caching sensitive data (PII, health info, credentials).
Regulations:
- GDPR: Requires encryption of personal data in transit and at rest
- HIPAA: Requires encryption of health information
- PCI-DSS: Requires encryption of payment card data
Benefits:
# Without encryption:
# Redis memory dump → attacker reads plaintext SSNs
# Redis backup → attacker reads plaintext emails
# Network intercept → attacker reads plaintext credentials
# With @cache.secure:
# Redis memory dump → attacker sees ciphertext values
# Redis backup → attacker sees ciphertext values
# Network intercept → attacker sees ciphertext values
# (cache keys stay cleartext in all three)
# Encryption key in environment → separate from dataScenarios where encryption overhead matters:
- No sensitive data: Public caching (prices, menus)
- High-volume, low-margin: every cache read and write pays a decrypt or encrypt step (see Performance Impact)
- Already encrypted at transport: TLS + encryption is redundant
Mitigation: state encryption=False for non-sensitive data:
@cache(ttl=300, encryption=False, backend=None) # Explicit plaintext, faster
def get_public_prices(item_id):
return db.get_price(item_id) # illustrative - db not defined
@cache.secure(ttl=300, master_key=secret_key) # Encryption, slower, for sensitive data
def get_user_ssn(user_id):
return db.get_ssn(user_id) # illustrative - db not definedEncryption turns on only where the code says so — @cache.secure(...), or an explicit
encryption option on another preset (exact spellings below). CACHEKIT_MASTER_KEY supplies
the key for those spellings and decrypts stale ciphertext on read (not in an interop cache —
see the encryption=False row). A master key present with no stated intent is an error at
construction, not a default. Contract: protocol/spec/intent-presets.md § Encryption Activation.
| Call site | No key (neither master_key= nor CACHEKIT_MASTER_KEY) |
Key available (master_key= or CACHEKIT_MASTER_KEY) |
|---|---|---|
@cache.secure(...) |
Fails closed — ValueError at decoration |
Encrypts |
@cache(encryption=True, single_tenant_mode=True); on a preset encryption=EncryptionConfig(enabled=True, single_tenant_mode=True) |
Fails closed — ConfigurationError at decoration |
Encrypts |
encryption=False |
Plaintext | Plaintext; stale ciphertext is still decrypted on read (each stale key logs one config-drift warning and counts on cachekit_config_drift_reads_total until it expires — expected after switching to plaintext). Not in an interop cache: its stale entries are never decrypted |
No encryption intent — no encryption=, or an EncryptionConfig without enabled= — on @cache, .minimal, .production, .io, .dev, .test, including backend=None and caches with a tenant extractor (flat tenant_extractor= on bare @cache, inside EncryptionConfig on a preset) |
Plaintext | Raises ConfigurationError at decoration, naming the explicit spellings. @cache.local never encrypts and never raises. |
The raising row replaces the earlier "fleet-wide convenience" rule, under which the key's
presence auto-enabled encryption wherever no master_key= or tenant_extractor= was passed
(deprecated with a warning in 0.20.0; those two stayed plaintext, and backend=None caches
stored raw objects). It went because a
call site's encryption state was unreadable from the code — it depended on which pod
carried which variable — and a pod missing the variable wrote plaintext to the backend
with no error (issue #128). Migrate by writing the intent. Both explicit spellings fail closed
on a missing key (.secure → ValueError, the encryption option → ConfigurationError);
every other row can store plaintext, and the compliance argument below holds only on an
explicit path.
The key is read when the decorator is applied — at import, for a module-level function —
not at call time. A key that arrives later (load_dotenv() in main(), a startup hook that
fetches it from a vault) is never seen by a function decorated before it: @cache.secure
without master_key= has already raised ValueError, and a cache with no encryption= stays
plaintext. It works the other way too: unsetting the variable later does not turn a
@cache.secure cache off, because it keeps the key it read. Load the key before the modules
that define cached functions are imported.
Important
Failing closed on a missing key is not failing closed on a bad entry. A decrypt
failure at read time — an AES-GCM tag mismatch from a tampered entry or the wrong key — is
governed by a separate setting, fail_closed. It defers to CACHEKIT_ENCRYPTION_FAIL_CLOSED,
which defaults to off, so even under @cache.secure such an entry is evicted and the function
recomputes unless you opt in. See
Corruption vs Tamper.
An interop cache (interop=) never decrypts stale ciphertext after encryption=False. Its entries
carry no header, so the reader decodes the ciphertext as MessagePack. Most stale entries fail to
decode and are recomputed, but a rare small one decodes cleanly and is served as a wrong value.
Encryption is also part of the shared-entry contract:
every SDK that binds the operation must agree on it.
So move the operation to a new namespace in the same change, in every SDK that binds it. Old and
new writers then use different keys, so no plaintext reader decodes the old ciphertext — on any
backend, in L1, or mid-rollout. Old-version processes keep reading and writing the old namespace,
encrypted, until the rollout drains them.
Once the last old-version process is gone, the old keys are never read again, but they are not deleted: they retire only by TTL (never, if none
was set) and stay decryptable while the key is. If they hold personal data, delete them once the last
old writer is gone — on Redis, SCAN for <old-namespace>:<operation>:* and UNLINK the matches;
the File backend can only clear its whole cache_dir; Memcached and CachekitIO retire entries only by
TTL. Never FLUSHDB a shared database.
Warning
cache.secure requires a master key: master_key= or CACHEKIT_MASTER_KEY. With neither, it raises a ValueError at decoration time, not at call time.
# No master_key= and CACHEKIT_MASTER_KEY unset
@cache.secure(ttl=300)
def operation(x):
return sensitive_data(x) # illustrative - sensitive_data not defined
# Error: "cache.secure requires master_key parameter or CACHEKIT_MASTER_KEY environment variable"
# Solution: Set CACHEKIT_MASTER_KEY env var, or pass master_key= explicitlyexport CACHEKIT_MASTER_KEY="not_hex" # Invalid
# Error: "CACHEKIT_MASTER_KEY must be hex-encoded: ..."
# Solution: Use 64-char hex string
export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32)@cache.secure resolves its backend the way every preset does (Backend Resolution
Priority), so without an explicit backend or a
set_default_backend() default, the environment decides where the ciphertext goes. With REDIS_URL set and CACHEKIT_API_KEY unset,
@cache.secure encrypts to Redis, not to the SaaS. The values are still ciphertext; what changes
is which system holds them.
When a particular backend is a requirement, pass it explicitly:
# notest: CachekitIOBackend needs the network and CACHEKIT_API_KEY
from cachekit import cache
from cachekit.backends.cachekitio import CachekitIOBackend
@cache.secure(master_key=secret_key, backend=CachekitIOBackend(), ttl=3600)
def get_patient_record(patient_id: str):
return fetch_phi(patient_id) # illustrative - fetch_phi not definedKeeping a retiring key decrypt-only makes its entries readable; it does not make a one-deploy key swap zero-miss. See Key Rotation Pattern for the keyring configuration, and follow the key rotation runbook — including its Before You Rotate checks — for the rotation itself.
When you turn encryption on over a cache that already holds plaintext entries, those
entries are rejected, never read: the entry raises a SerializationError internally, the
caller treats it as a miss, evicts the stale entry, recomputes, and re-stores the value
encrypted, whatever fail_closed says. Migration is therefore lazy and self-healing:
read plaintext entry → SerializationError (rejected, never deserialized) → evict → recompute → re-store encrypted
That holds for CK-framed entries. An interop cache stores no header, so a
plaintext entry there reaches the decrypt step and fails authentication: a miss that recomputes
by default, but with fail_closed=True every read of it raises DecryptionAuthenticationError
until it expires. Turn encryption on in an interop cache by moving the operation to a new
namespace, as in Turning Encryption Off in an Interop Cache.
There is deliberately no opt-in flag to let an encryption-enabled reader accept
plaintext entries. The frame header's encrypted flag is not authenticated, so a
plaintext entry forged by an attacker with backend write access is indistinguishable
from a legacy one — any "accept plaintext" escape hatch would reintroduce the
encryption-downgrade attack the downgrade-protected read path exists to prevent. If you need to
read plaintext entries, use a handler with encryption=False (which never had keys to
protect).
For large caches, choose between lazy migration and eager eviction based on your
workload: lazy migration spreads recomputation over reads (each legacy entry pays one
recompute on first access), while an eager flush concentrates it into a cold-start miss
wave — throttle or batch the eviction if the recompute cost is high. Either way, scope
eviction to cachekit's keys so unrelated data in the same Redis database survives: run the
scan_iter + unlink script under Option 3: Data corruption in
Decryption failed, once per
tenant and per namespace or function, and evict functions with a custom key= as it describes.
FLUSHDB is only safe when the database is dedicated to cachekit. Then deploy the encrypting
decorator (@cache.secure or an explicit encryption= option), with CACHEKIT_MASTER_KEY set
if it supplies the key.
@cache.secure(ttl=300, master_key=secret_key) # Encryption + L1 cache (stores encrypted bytes)
def get_sensitive_data():
# L1 cache enabled: stores encrypted bytes (~50ns hits vs 2-7ms Redis)
# Encryption is orthogonal: wraps any allowed serializer, applies to both L1 and L2
# Both layers store encrypted bytes (encrypt-at-rest everywhere)
return fetch_sensitive_data() # illustrative - fetch_sensitive_data not defined# Generate secure master key
export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32)from cachekit import cache
@cache.secure(ttl=3600) # AES-256-GCM with MessagePack, key from CACHEKIT_MASTER_KEY
def get_user_profile(user_id):
return db.get_profile(user_id) # illustrative - db not defined
profile = get_user_profile(123)
# Data encrypted in Redis, decrypted in-appfrom cachekit import cache
from cachekit.serializers import OrjsonSerializer
# Encrypt JSON API responses (webhooks, sessions, API keys)
@cache.secure(master_key=secret_key, serializer=OrjsonSerializer())
def get_api_keys(tenant_id: str):
return {
"api_key": "sk_live_abcdef123456",
"webhook_secret": "whsec_xyz789",
"tenant_id": tenant_id
}
keys = get_api_keys("customer-123")
# JSON encrypted client-side, backend never sees plaintext values (illustrative)from cachekit import cache
from cachekit.serializers import ArrowSerializer
import pandas as pd
# Encrypt DataFrames with patient data, ML features, analytics
@cache.secure(master_key=secret_key, serializer=ArrowSerializer())
def get_patient_records(hospital_id: int):
# illustrative - conn not defined
return pd.read_sql(
"SELECT patient_id, diagnosis, risk_score FROM patients WHERE hospital_id = ?",
conn,
params=[hospital_id]
)
df = get_patient_records(42)
# DataFrame encrypted client-side, zero-knowledge storageEncryption takes only a serializer whose class declares cross_sdk_compatible = True: StandardSerializer
(the default), OrjsonSerializer, ArrowSerializer, or a custom serializer that sets the flag. Anything
else, AutoSerializer included, raises ConfigurationError in the decorators and in a directly built
EncryptionWrapper (details). The reason is the
protocol's rule that the step after decryption is chosen by the reader's configured serializer, never by
inspecting the decrypted bytes, on every backend. AutoSerializer picks its decoder by inspecting them.
Caution
tenant_extractor is not a tenancy boundary. Cache keys carry no tenant
component — the key is ns:{ns}:func:{mod.fn}:args:{hash}:{flags} — so tenants
calling with identical arguments address the same entry. Give each tenant its own
namespace or its own deployment, or make the tenant id a keyword argument of the
cached function so it is part of the args hash.
tenant_extractor requires an object implementing .extract(args, kwargs), such as
ArgumentNameExtractor or ContextVarExtractor, and tenant ids must be valid UUIDs.
ContextVarExtractor.set_tenant_id() rejects a non-UUID id with ValueError. With
ArgumentNameExtractor a non-UUID id fails at store time, and a bare lambda fails on
every call; either way the failure is logged and the result is not written to the cache.
On an encrypted cache, a shared entry is never decrypted for the wrong tenant. A read
decrypts as the caller's tenant, resolved by tenant_extractor exactly as on the write.
It refuses a backend entry encrypted for any other tenant as auth_tamper (see Corruption
vs Tamper below). By default that read is a miss: the function runs and its result
overwrites the entry, so tenants that share a key keep evicting each other. With
fail_closed=True the read raises DecryptionAuthenticationError (its subclass
TenantMismatchError) instead, until the entry expires or is invalidated. That lets
whichever tenant calls a shared-key function first block every other tenant for the
entry's TTL, and seed it again once it expires. In L1 such an entry is only a miss (see
Cache Key Binding). A read whose tenant cannot be resolved decrypts nothing and is a plain miss
that keeps the entry. Either way, keep tenants on separate keys as the caution above says.
The keyring has one current master key
(CACHEKIT_MASTER_KEY, encrypts and decrypts) plus up to 3 decrypt-only
previous keys (CACHEKIT_PREVIOUS_MASTER_KEYS, comma-separated hex, same
per-key requirements as the master key). CK-framed entries carry the
fingerprint of their HKDF-derived per-tenant encryption key, so reads select
the exact keyring entry that wrote them — never trial decryption. The keyring
alone does not make a single-deploy swap zero-miss: use the three-phase key
rotation runbook for scheduled
rotation, including its Before You
Rotate
checks. Entries without a TTL, and entries whose expiry reads extend
(refresh_ttl_on_get=True or refresh_ttl), keep the retiring key in use
indefinitely; the runbook's Phase 3 drain window, not a fixed TTL, decides when
the old key can be removed.
# Phase 2 state only — <new-key-hex> is the key phase 1 distributed
# decrypt-only fleet-wide; <old-key-hex> is the master key it replaces.
# Pseudocode — both placeholders are 64-character hex (32-byte) values.
# Non-hex or short values are rejected at load.
export CACHEKIT_MASTER_KEY=<new-key-hex>
export CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>Rules enforced at config load or cache build — rejected, never truncated or silently fixed:
- Cap: at most 3 decrypt-only keys.
- Per-key validation: identical to
CACHEKIT_MASTER_KEY(hex-encoded, at least 32 bytes; use exactly 32). - Current key not in the list: the current master key must not re-appear in
the decrypt-only list — the detectable signature of re-promoting a retired key.
Keys compare as decoded bytes, so hex case makes no difference. A
CACHEKIT_MASTER_KEYis checked when settings load (pydantic.ValidationError). Amaster_key=given to@cache.secure, to anEncryptionConfigor to an encrypting@cacheorCacheSerializationHandleris checked when the cache is built, against the previous keys as they stand then, before any backend call (ConfigurationError).
Operator rule, which no SDK detects: never re-promote a key that has encrypted, including by rolling back a Phase 2 deploy. Rolling back restores the old key as current with the new key decrypt-only — a legal configuration that passes load and silently resumes the old key's used AES-GCM nonce budget, risking catastrophic nonce reuse. Back out a rotation by rotating forward to a fresh key.
For a suspected key compromise, do not use the scheduled rotation. Follow the
runbook's Compromise
Response:
deploy a fresh key and unset CACHEKIT_PREVIOUS_MASTER_KEYS (code that builds
an EncryptionWrapper directly must pass an explicit previous_master_keys=[] —
omitting it falls back to the environment variable), flush encrypted namespaces at cut-over, and flush again once the last
instance writing under the old key has stopped. Ciphertext under the compromised
key stays readable to whoever holds that key until it is deleted.
Interop-mode entries store no per-entry key fingerprint (no CK frame), so rotation there attempts keyring keys sequentially — current key first, identical AAD per attempt — instead of fingerprint selection. Same environment variables, same rotation window, same fail policy on exhaustion.
Key size: 256 bits (32 bytes)
Nonce size: 96 bits (12 bytes, randomly generated)
Authentication: 128 bits (16 bytes, computed by GCM)
Encryption:
Plaintext + Additional Authenticated Data (AAD) → Ciphertext + AuthTag
AuthTag protects against tampering (any bit change fails)
Decryption:
Ciphertext + AuthTag + AAD → Plaintext or ERROR
If AuthTag doesn't match → raise error (don't return plaintext)
Master key: CACHEKIT_MASTER_KEY
Tenant ID: tenant_extractor.extract(args, kwargs) (or the single-tenant id below)
Per-tenant key = HKDF(master_key, tenant_id)
[Key Derivation Function, cryptographically secure]
Single-tenant mode (no tenant_extractor):
tenant_id = deployment_uuid | CACHEKIT_DEPLOYMENT_UUID | "default"
"default" is the protocol literal every SDK derives from (intent-presets.md
§ Master Key Input, rule 5), used identically for HKDF and AAD — one master
key is enough for py, rs and ts to share ciphertext.
Properties of the derivation itself:
- Tenant A's key ≠ Tenant B's key
- Derived keys are unique per tenant
This is not a tenancy boundary: the cache key carries no tenant component, so
tenants calling with identical arguments address the same entry. See Multi-Tenant
Isolation above.
Problem: If same nonce used with same key, encryption breaks
Solution: Counter-based nonce generation
Nonce = [counter_high_64bits][counter_low_32bits][random_32bits]
└─ Increments per encryption
Prevents nonce reuse even across reboots
The AAD binds the cache key, so ciphertext moved to another key fails authentication: a
backend-write attacker cannot serve one entry's value at another entry's key. The key bound is
the one the backend is handed, with the backend's key_prefix in front of it: the namespace is
already part of the key, a MemcachedBackend reports its configured key_prefix, and the
tenant-scoped Redis backend (env auto-detection, RedisBackendProvider; t:default: with no
tenant set) reports the calling tenant's t:{tenant}:. An entry copied from app-a: to app-b:, or from t:acme: to
t:globex:, is refused. A custom backend that prefixes keys must expose that prefix as
key_prefix for it to be bound. Backend encodings of the key (the File backend's hashed file
name, CachekitIO's percent-encoded URL path) are not part of it, and neither is the tenant
scoping the CachekitIO server applies.
There is one AAD per read. A read never retries with another form of the key, such as the key without its prefix, so a failed authentication is final.
L1 is shared by every function in a namespace and keyed by the bare cache key, so it can hold
an entry bound to another prefix: another tenant's behind the tenant-scoped Redis backend, or
another function's behind a different Memcached key_prefix. On a
backend with a key prefix that read is an L1 miss and goes on to the backend, never an
auth_tamper. An L1 entry encrypted for another tenant (tenant_extractor) is a miss on any
backend. Two encrypted functions that share a namespace and a cache key but not a backend
prefix (one on a prefixing backend, one on an unprefixed one) read each other's L1 entries as
failed authentication on the unprefixed side; give them separate namespaces.
Upgrading. Releases before this binding left the backend's prefix out of the AAD. Their
encrypted entries written through a prefixing backend fail authentication after the upgrade:
MemcachedBackend with a key_prefix, the tenant-scoped Redis backend, which includes the
default Redis backend env auto-detection builds (t:default: with no tenant set), and any custom
backend with a non-empty key_prefix. By default each such
entry is read once as a miss, counted as auth_tamper with a WARNING, recomputed and
overwritten. With fail_closed=True, every read of one raises DecryptionAuthenticationError
until it expires or is deleted. So delete those entries, or move the cache to a fresh key space
(a new namespace, or a new Memcached key_prefix), rather than wait out the TTL. During a
rolling upgrade, processes on the earlier release and on this one each read the other's writes
as failed authentication, and deleting entries does not help while both run. Give the upgraded
release a fresh key space, or stop every earlier-release process first. Unaffected: a
RedisBackend you construct yourself, FileBackend, CachekitIOBackend, and interop mode.
The CK frame header — the JSON envelope carrying encrypted, tenant_id, format,
and the serializer name — is plaintext, so a reader can parse it before it has a key.
Its JSON bytes are not what the AES-GCM tag covers; the tag covers the ciphertext and
the AAD. AAD v0x03 is built from the tenant, the cache key (with any
key prefix the backend adds), and the header's wire format,
compression flag and (when set) original type, so a change to one of those header
values that alters the AAD fails authentication. The encrypted flag is not an
AAD input: nothing authenticates it.
An attacker with backend write access (the threat actor in the protocol's threat
model) could exploit that unauthenticated flag by planting a frame whose header claims
encrypted: false plus an arbitrary plaintext payload — a classic encryption
downgrade (CWE-757). cachekit therefore never lets header metadata select the read
path when encryption is configured:
Handler configured with encryption:
entry header claims encrypted → authenticated decrypt (AAD + GCM tag verified)
entry header claims plaintext → SerializationError (plaintext never returned; miss + evict, whatever `fail_closed` says)
The plaintext deserializer is unreachable on an encryption-enabled handler, regardless of what the stored frame claims. Configuration decides the read path; stored (i.e. attacker-writable) data never does.
Encrypted entries expose three fields in the plaintext header: tenant_id,
encryption_algorithm, and key_fingerprint. This exposure is deliberate and
accepted:
tenant_id— the tenant the entry was encrypted for, needed before decryption; moving it inside the ciphertext is a chicken-and-egg problem. It is an opaque identifier, not secret material, and it is tamper-protected. A cache with atenant_extractorderives the per-tenant key (HKDF) from the caller's tenant and refuses an entry whosetenant_iddiffers (auth_tamper) before attempting to decrypt. A cache without one derives the key from the header'stenant_id, so a modified value selects a different key and the read fails authentication (auth_tamper) — a key-fingerprint mismatch under fail-closed, a GCM tag failure otherwise.key_fingerprint— a one-way fingerprint of the derived key, used only for clearer diagnostics during key rotation. It reveals nothing about key material.encryption_algorithm— public information (AES-256-GCM); hiding the algorithm adds no security (Kerckhoffs's principle).
Relocating these fields would be a cross-SDK wire-format change owned by the protocol spec; the Python SDK documents the exposure rather than diverging from the shared frame format.
The cache key is cleartext too. By default it carries the namespace (when set), the function's
module.qualname and an unkeyed, unsalted blake2b-256 hash of the arguments
([ns:{ns}:]func:{mod.fn}:args:{64-hex}:{flags}), so over a small or guessable argument space the
hash can be enumerated offline. A custom key= function is not hashed: its return value becomes
the key verbatim, after the namespace ({namespace}:{value}, with default when none is set), so
never return raw identifiers or personal data from it — derive them with an HMAC whose key never
reaches the backend.
Whoever operates the backend can therefore learn which record was read or written, when and how
often, without decrypting anything. On the CachekitIO backend the key travels percent-encoded in
the URL path (/v1/cache/{key}), so it also lands in access logs along the request path and stays
there for their retention period, not the cache TTL. Ciphertext length reveals the approximate
plaintext size, and because the default serializer compresses before encrypting, it also tracks
how compressible the content is. Encryption protects values, not access patterns: keep secrets
out of namespaces, function names and key= return values, and count argument-identifiable
access as metadata exposure in your threat model.
Four failure classes surface on the decrypt read path, and cachekit distinguishes them (cachekit-py#170):
auth_tamper— the entry failed authentication: the ciphertext was modified, the key is wrong (rotation/misconfiguration), the AAD didn't match (ciphertext moved between cache keys or key prefixes), or, on a cache with atenant_extractor, a backend entry was encrypted for a tenant other than the caller's (TenantMismatchError, refused before any decrypt attempt). In L1, another tenant's entry, or behind a prefixing backend one bound to another prefix, is a plain miss instead (Cache Key Binding). The plaintext frame header fields built into the AAD (format,compressed,original_type) are unencrypted, but the AAD built from them is authenticated by the tag: a header change that produces different AAD bytes also fails here. (The tag authenticates the constructed AAD, not the header's JSON bytes.) Raised asDecryptionAuthenticationError. This is the signal an active attack would produce.suspicious_envelope— the unauthenticated envelope is inconsistent with the handler's configuration: a plaintext claim under an encryption-enabled handler (the CWE-757 downgrade guard) or a missingtenant_id. Benign during a lazy plaintext→encrypted migration; a spike outside a migration window is suspect. Always fails open (miss + evict) so migration keeps working — even in fail-closed mode.envelope_shape— an entry nothing verified decoded to the shape of a ByteStorage envelope and was refused. Either a rotted integrity-on envelope or a legitimate top-level 4-element list that merely looks like one; the read path cannot tell them apart, so this is not reliable corruption evidence and is kept out ofcorruption. Always fails open. An integrity-offAutoSerializerrefuses to write the second case, so the same redacted key repeating in the WARNING log is another writer still storing it (see Deserialization failed in error-codes.md).corruption— everything else: checksum mismatch, truncated/malformed frame, serializer mismatch, a deserialize failure on already-authenticated plaintext (including plaintext in a container other than the one the serializer writes, which is refused, never decoded: Arrow IPC without its checksum prefix, or, for an integrity-offStandardSerializer, a legacy-layout envelope like thoseAutoSerializersealed undercompressed=Falsebefore 0.12.0, when it declares at most 256 KiB; a larger one comes back as its fields), or a non-string or non-UTF-8-encodableoriginal_type, or a non-UTF-8-encodablecompressed, in the frame header. Such a value cannot be built into the AAD at all, so no tag check runs — the read is corruption-class, and the entry is evicted and recomputed even in fail-closed mode. Storage rot and bugs, not evidence of tampering.
All are counted on the Prometheus counter
cachekit_decrypt_failures_total{reason, tier="l1"|"l2"} — alert on
reason="auth_tamper" specifically; a nonzero rate there is a security event, not
noise. Baseline suspicious_envelope around migration windows; a flat, steady
envelope_shape rate is a writer still storing a value the shape rule refuses, not rot.
Default (fail open): a decrypt failure of any class logs a warning, evicts the poisoned entry, and recomputes the value. Availability-first — a tampered cache entry degrades to a cache miss. The tampering is visible only in logs and the metric.
Fail closed (opt-in): auth_tamper failures raise
DecryptionAuthenticationError to your caller instead of silently recomputing, and
a key-fingerprint mismatch refuses to even attempt decryption. The poisoned L2
entry is deliberately not evicted (it is evidence); a poisoned L1 copy is
invalidated so remediating L2 immediately clears every process. Other classes still
fail open — only authentication failures escalate. Enable it fleet-wide or
per-function:
# Fleet-wide (all decorators, overridable per-function)
export CACHEKIT_ENCRYPTION_FAIL_CLOSED=1# Per-function (overrides the env setting in either direction)
@cache.secure(master_key=secret_key, fail_closed=True)
def get_payment_token(user_id: int): ...
# Or via explicit EncryptionConfig
from cachekit.config.nested import EncryptionConfig
config = EncryptionConfig(enabled=True, master_key=secret_key,
single_tenant_mode=True, fail_closed=True)Keyring configuration faults are not a decrypt-failure class. EncryptionWrapper
raises KeyringConfigurationError (a ValueError subclass, exported from
cachekit.serializers) when the decrypt-only keyring is unusable: a previous master key
passed directly that is not exactly 32 bytes, more than three previous keys, or the current
key repeated among them. Settings check all three at load and refuse an assignment that breaks
them, leaving the settings as they were (previous_master_keys is a tuple, so a change has to
be an assignment), and an encrypting cache checks its current key against the previous keys when
it is built. Behind the decorators this surfaces on a config-drift read (below), or when a
previous key assigned to the settings repeats the master_key= of a cache already built: the
settings never see that key, so its next wrapper build refuses it. Outside config-drift reads, the
fault never evicts and is not counted on cachekit_decrypt_failures_total. Direct EncryptionWrapper users
and callers of the CacheOperationHandler read and write methods receive it in both fail modes. Behind
the @cache decorators, a read of an existing encrypted entry raises it too, from L1 or L2 and
from the re-read after a distributed-lock wait, so the function does not run and no
circuit-breaker failure is counted. A key with no entry yet reads as a miss before the keyring
is built, so the fault surfaces at the write instead, and the write raises it too, sync or async:
the function has already run, but its result is neither cached nor returned, and no
circuit-breaker failure is counted. Two cases take other paths: a missing current master key,
or one of the wrong length, raises EncryptionError, and an encryption-disabled handler reading an entry that
claims encryption treats the fault as corruption (miss + evict), because only the
unauthenticated header sent it down the decrypt path.
A wrapper keeps the keyring it built. A cache builds one per tenant, on that tenant's first use and
after LRU eviction, so a key change in the settings reaches only wrappers built after it: their
previous keys, and their current key too unless the cache was given master_key=. One multi-tenant
cache can therefore run the old keyring for some tenants and the new one for others. To apply a key
change everywhere at once, change the environment and restart the process.
⚠️ Key rotation under fail-closed: withfail_closedenabled there is no silent self-heal — rotatingCACHEKIT_MASTER_KEYwithout retaining the old key inCACHEKIT_PREVIOUS_MASTER_KEYSmakes every pre-rotation entry raiseDecryptionAuthenticationErroron read (the fingerprint matches no keyring entry, decryption is refused, and the entry is retained, not evicted). Follow the keyring rotation pattern above: keep the retiring key decrypt-only until the runbook's Phase 3 drain window has closed. This is the deliberate cost of failing closed; the default fail-open mode treats keyless entries as ordinary misses.
Note the boundary with the integrity checksum: the ByteStorage xxHash3-64 checksum
is corruption detection only — it is not cryptographic and an attacker who can write
to the backend can trivially forge a valid checksum for arbitrary bytes. On the
plaintext @cache path the stored bytes are therefore attacker-forgeable; tamper
resistance exists only under encryption, where AES-256-GCM authenticates every
byte. fail_closed governs the authenticated path — it cannot add tamper resistance
to plaintext caching.
Config-drift reads: if a handler has encryption disabled but reads a stale
encrypted entry (e.g. encryption was recently turned off), cachekit decrypts it via
the globally configured master key — the same signature appears under
misconfiguration or a planted entry, so every occurrence increments
cachekit_config_drift_reads_total{reason="encryption_disabled"} and the first read
of each key logs a warning (once per key, so a hot key can't flood the logs). If you
didn't recently disable encryption for that function, investigate.
Important
Client-side encryption may reduce GDPR, HIPAA or PCI DSS scope, subject to assessment and your other controls, and only on an explicit path (see Activation); it is not a compliance guarantee. Encryption covers values only: the cache key is cleartext (and, on CachekitIO, lands in access logs; see Cleartext Cache Key).
- ✅ Encryption supports the "processing security" requirement
- ✅ Client-side encryption supports the "technical measures" requirement
⚠️ Key management still required (rotation, access control)
- ✅ AES-256-GCM supports the encryption requirement
⚠️ Audit logging required (access to decrypted data)⚠️ Key management plan required
- ✅ Encryption supports the "encryption at rest" requirement
⚠️ Key management plan required⚠️ Regular key rotation required
Caution
NOT legal advice. Consult your compliance team before making claims about regulatory compliance.
Evidence-based benchmarks (P95 latency, roundtrip serialize + deserialize):
| Serializer | Plain | Encrypted | Overhead | Relative |
|---|---|---|---|---|
| JSON (OrjsonSerializer) | 0.75 μs | 4.25 μs | +3.50 μs | +467% |
| MessagePack (StandardSerializer) | 3.21 μs | 6.54 μs | +3.33 μs | +104% |
| DataFrames (ArrowSerializer, 1000 rows) | 731.67 μs | 749.75 μs | +18.08 μs | +2.5% |
Key insights:
-
Small data (JSON/MessagePack): Encryption adds 3-5 μs absolute
- Relative overhead looks high because baseline is fast
- Absolute cost <10 μs is negligible vs network latency (1-10 ms)
-
Large data (DataFrames): Encryption overhead virtually disappears
- Serialization dominates (731 μs for 1000-row DataFrame)
- Encryption only 18 μs = 2.5% overhead
- Zero-knowledge DataFrame caching is 97.5% free
-
Production implications:
- API caching: 5 μs encryption < network jitter
- ML features: 2.5% overhead = rounding error
- Zero-knowledge caching overhead is negligible in the benchmarked scenarios (synthetic API payloads, user profiles, and 1,000-row DataFrames)
Run benchmarks: pytest tests/performance/test_encryption_overhead.py -v -s
Through the decorator, a wall-clock comparison of an L1 hit of a 23.5KB dict with and without encryption is inconclusive: the difference stayed inside run-to-run noise. See Not Measured Here in the Performance Guide.
Per-tenant key derivation: 50-100μs (HKDF operation)
Cached after first use: No additional overhead
Encryption + Circuit Breaker:
@cache.secure(ttl=300, master_key=secret_key) # Both enabled
def get_data():
# Decrypt or integrity failure on read → cache miss, entry evicted, function runs.
# It does NOT count toward the circuit breaker.
return fetch_data() # illustrative - fetch_data not definedA decrypt or integrity failure says nothing about backend health, so the breaker ignores it. For
fail-open reads, the warning log and the failure metric,
cache_operations_total{operation="cache_get_deserialize", success="False"}, still fire. This keeps a
lazy plaintext→encrypted migration, where every pre-encryption entry is refused once, from opening
the breaker. With fail_closed=True, an authentication failure raises DecryptionAuthenticationError
to the caller instead of recomputing. It emits cachekit_decrypt_failures_total and the
authentication error log, but not the operation="cache_get_deserialize" sample or warning log. It does not
count toward the breaker either.
Encryption + L1 Cache:
@cache.secure(ttl=300, master_key=secret_key)
def get_data():
# L1 cache enabled: stores encrypted bytes (security + performance)
# No plaintext in memory: encryption at rest in both L1 and L2
# Decryption only at read time (< 1ms exposure)
return fetch_data() # illustrative - fetch_data not definedQ: "Decryption failed: authentication tag verification failed" A: Key mismatch or data corruption. Check CACHEKIT_MASTER_KEY hasn't changed.
Q: Key rotation failing
A: Check CACHEKIT_PREVIOUS_MASTER_KEYS — comma-separated hex, each key subject to
the same rules as CACHEKIT_MASTER_KEY (at least 32 bytes; use exactly 32), at most 3 entries, and the
current CACHEKIT_MASTER_KEY must not appear in the list. Follow the keyring
rotation pattern above: keep the retiring key decrypt-only until the runbook's
Phase 3
drain window has closed.
Q: Performance degraded after enabling encryption A: Expected 100-500μs overhead. Profile to confirm acceptable.
Use case: Building a caching system where the backend never sees plaintext values.
# Client application (user's infrastructure)
from cachekit import cache
from cachekit.backends.cachekitio import CachekitIOBackend
from cachekit.serializers import OrjsonSerializer
# An HTTP API backend: CachekitIOBackend talks to cachekit.io over HTTPS
@cache.secure(master_key=secret_key, serializer=OrjsonSerializer(), backend=CachekitIOBackend())
def get_api_secrets(tenant_id: str):
return {"api_key": "sk_live_...", "secret": "..."} # illustrative
# Data flow:
# 1. Function executes (cache miss)
# 2. Serialize to JSON (OrjsonSerializer)
# 3. Encrypt with client's master key (AES-256-GCM)
# 4. Send encrypted blob to backend
# 5. Backend stores opaque ciphertext (zero knowledge)
# 6. Client retrieves and decrypts locally// Example HTTP API backend
export default {
async fetch(request: Request) {
const { key, value } = await request.json();
// Backend receives encrypted blob
// NEVER sees plaintext values (no decryption key); the key is cleartext
await KV.put(key, value);
return new Response("OK");
}
}Benefits:
- ✅ Backend compromise doesn't expose cached values
- ✅ Supports a compliance scope-reduction argument on an explicit path (see Compliance Implications)
- ✅ Works with any data type (JSON, MessagePack, DataFrames)
- Comparison Guide - Only cachekit has zero-knowledge encryption
- Security Policy
- Multi-Tenant Isolation - why per-tenant keys are not a tenancy boundary
- Serializer Guide - Encryption with custom serializers
- Performance Benchmarks - Evidence-based overhead measurements