Storage Encryption
Fluree supports transparent encryption of data at rest using AES-256-GCM authenticated encryption. When enabled, all data written to storage is automatically encrypted, and data is decrypted transparently when read.
Overview
Key Features:
- AES-256-GCM: Industry-standard authenticated encryption with integrity protection
- Transparent Operation: Encryption/decryption happens automatically on read/write
- All Storage Backends: Works natively with file, S3, and memory storage (not IPFS)
- Portable Ciphertext: Encrypted data can be moved between storage backends (file ↔ S3)
- Environment Variable Support: Keys can be loaded from environment variables
- Secure Key Handling: Key material in
EncryptionKeyis zeroized on drop
Quick Start
Rust API
#![allow(unused)]
fn main() {
use fluree_db_api::FlureeBuilder;
// Option 1: Direct key (for testing)
let key: [u8; 32] = /* your 32-byte key */;
let fluree = FlureeBuilder::file("/data/fluree")
.build_encrypted(key)?;
// Option 2: Base64-encoded key
let fluree = FlureeBuilder::file("/data/fluree")
.with_encryption_key_base64("your-base64-encoded-32-byte-key")?
.build_encrypted_from_config()?;
// Option 3: From JSON-LD config with env var (nodes are located by @id)
let config = serde_json::json!({
"@context": {
"@base": "https://ns.flur.ee/config/connection/",
"@vocab": "https://ns.flur.ee/system#"
},
"@graph": [
{
"@id": "storage",
"@type": "Storage",
"filePath": "/data/fluree",
"AES256Key": {"envVar": "FLUREE_ENCRYPTION_KEY"}
},
{"@id": "connection", "@type": "Connection", "indexStorage": {"@id": "storage"}}
]
});
let fluree = FlureeBuilder::from_json_ld(&config)?
.build_encrypted_from_config()?;
// Option 4: any build path honours a configured key. This is what the
// server and embedders use; the storage type comes from the config.
let client = FlureeBuilder::from_json_ld(&config)?
.build_client()
.await?;
// Option 5: a key set for rotation, from the Rust API: (key id, key) pairs
// and the id that encrypts new writes.
let fluree = FlureeBuilder::file("/data/fluree")
.with_encryption_keys(vec![(1, old_key), (2, new_key)], 2)?
.build()?;
}
A key set on the builder (with_encryption_key*(), with_encryption_keys(),
or AES256Key / AES256Keys in JSON-LD) is applied by every terminal build method — build(), build_memory(), build_s3(),
build_client() and friends — on every backend. The build_*_encrypted() methods
remain for callers that want the key to be an explicit argument;
build_encrypted(key) replaces any configured key set with that one key, as
id 0. Two build methods are exceptions:
build_ipfs()returns an error when a key is configured. IPFS storage cannot be wrapped for encryption, and it publishes to a content-addressed network, so silently writing plaintext is not an option.build_with()takes a storage you have already composed, and may already have wrapped inEncryptedStorage. It leaves the key to you and logs a warning when one is configured.
Server Configuration
Set the encryption key via environment variable:
# Generate a secure 32-byte key and base64 encode it
export FLUREE_ENCRYPTION_KEY=$(openssl rand -base64 32)
# Start the server with JSON-LD config
fluree server run --connection-config config.jsonld
Configuration
JSON-LD Configuration
The encryption key is specified in the storage configuration using AES256Key,
on the node the connection names as indexStorage:
{
"@context": {
"@base": "https://example.org/config/",
"@vocab": "https://ns.flur.ee/system#"
},
"@graph": [
{
"@id": "indexStorage",
"@type": "Storage",
"filePath": "/var/lib/fluree/data",
"AES256Key": {
"envVar": "FLUREE_ENCRYPTION_KEY"
}
},
{
"@id": "mainConnection",
"@type": "Connection",
"indexStorage": {"@id": "indexStorage"},
"cacheMaxMb": 2000
}
]
}
Configuration Options
| Field | Type | Description |
|---|---|---|
AES256Key | string or object | Base64-encoded 32-byte encryption key. A single key with id 0. |
AES256Key.envVar | string | Environment variable containing the key |
AES256Key.defaultVal | string | Fallback key if env var is not set |
AES256Keys | list | A key set for rotation: each entry a node with keyId (integer) and AES256Key (string or object as above). Every listed key decrypts. |
AES256CurrentKey | integer | The keyId that encrypts new writes. Required with AES256Keys. |
AES256Key and AES256Keys are mutually exclusive. Key ids are written into
every envelope header, so once a key has an id, keep it. The key is standard or
URL-safe base64 and must decode to exactly 32 bytes. keyId and
AES256CurrentKey accept the same envVar indirection as the key itself.
Where the key goes. Put the key on the indexStorage node. With a separate
commitStorage (for example a second S3 bucket), the indexStorage key
encrypts both, and a commitStorage node carrying a different key is rejected
at startup rather than ignored. Storages listed under addressIdentifiers carry
their own keys.
Environment Variable Indirection
You can load the encryption key from an environment variable:
{
"AES256Key": {
"envVar": "FLUREE_ENCRYPTION_KEY"
}
}
If the variable is unset or empty and no defaultVal is given, the connection
config fails to load: Fluree refuses to start a storage unencrypted because a
key did not resolve. javaProp is also accepted and read from the environment
variable of that name.
Or with a fallback default, used when the variable is unset or empty (not recommended for production):
{
"AES256Key": {
"envVar": "FLUREE_ENCRYPTION_KEY",
"defaultVal": "fallback-base64-key-for-dev-only"
}
}
Key Management
Generating Keys
Generate a cryptographically secure 32-byte key:
# Using OpenSSL (recommended)
openssl rand -base64 32
# Using /dev/urandom
head -c 32 /dev/urandom | base64
# Using the Fluree CLI
fluree encryption generate-key
# Example output: "K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols="
Key Storage Best Practices
- Never commit keys to version control
- Use environment variables or secret managers
- Rotate keys periodically (see Key Rotation below)
- Limit access to key material
Recommended secret management solutions:
- HashiCorp Vault
- AWS Secrets Manager
- Kubernetes Secrets
- Docker secrets
Key Rotation
Keys rotate without export and import. Every envelope header records the id of the key that encrypted it, so a storage configured with several keys reads any of them while new writes use the current one. A rotation is then a background sweep that re-envelopes every blob still on the retiring key. Two properties make it safe to run over days while serving traffic:
- Rewrites are in place. Addresses are hashes of plaintext, so a blob’s address does not change when its key does. Each rewrite is one atomic object write, verified by reading it back; a crash between blobs leaves each blob on exactly one key.
- The blobs are the truth. The progress record at
@maintenance/key-rotation.jsonin the same storage caches where the sweep stood; resuming from a stale record only re-reads a few headers. Nothing removes a key automatically. Completion is a verification pass that finds zero blobs on the retiring key, after which an operator drops the key.
Rollout order
Nodes share storage, so the order matters more than the commands:
- Add the new key to every node’s configuration as a decrypt-only entry of
AES256Keysand restart. Every node can now read blobs on either key. - Set
AES256CurrentKeyto the new key on every node and restart. All new writes now use it. - Run the sweep from one node
(
fluree encryption rotate --remote <name> --retire <old>). - Run
fluree encryption verify --remote <name> --retire <old>. When it reports zero remaining, remove the old key fromAES256Keysand restart.
Coming from a single AES256Key. A single key has id 0, and every blob
written under it records id 0. List it under that id when you move to a key
set, then add the new key and retire 0:
"AES256Keys": [
{"keyId": 0, "AES256Key": {"envVar": "FLUREE_KEY_OLD"}},
{"keyId": 1, "AES256Key": {"envVar": "FLUREE_KEY_NEW"}}
],
"AES256CurrentKey": 0
That is step 1; step 2 sets AES256CurrentKey to 1, and steps 3 and 4 retire
0. Giving the old key any other id makes every existing blob fail with
“Unknown encryption key ID: 0”.
A node restarted with the old key already removed cannot read blobs still on it, and the sweep refuses to start or resume unless the process holds both the retiring key and the current one. Nothing checks this order across nodes: each node reports only its own key ids, so following it is the operator’s job (#1952).
{
"@id": "storage",
"@type": "Storage",
"filePath": "/var/lib/fluree",
"AES256Keys": [
{"keyId": 1, "AES256Key": {"envVar": "FLUREE_KEY_1"}},
{"keyId": 2, "AES256Key": {"envVar": "FLUREE_KEY_2"}}
],
"AES256CurrentKey": 2
}
Commands and endpoints
Every fluree encryption subcommand except generate-key needs one of
--remote <name>, to run against a server, or --connection-config <path>, to
run directly against the storage that config describes. --json prints the raw
response. See fluree encryption for flags and output.
| Command | Endpoint | What it does |
|---|---|---|
fluree encryption status | GET /v1/fluree/encryption, GET /v1/fluree/encryption/rotate/status | Held key ids, the current key, and the progress record. Any node answers. |
fluree encryption rotate --retire N [--dry-run] [--ledger L] [--rate 50mb] [--wait] | POST /v1/fluree/encryption/rotate | Start, or resume, the sweep. A dry run counts and writes nothing. --wait polls status until the sweep stops. |
fluree encryption resume [--wait] | POST /v1/fluree/encryption/rotate | Re-issue rotate for the record’s retiring key and scope. |
fluree encryption pause / cancel | POST /v1/fluree/encryption/rotate/pause / cancel | Stop once the blob in progress is done. A paused sweep resumes from its cursor; a cancelled one starts over. |
fluree encryption verify --retire N | POST /v1/fluree/encryption/rotate/verify | Count blobs still on key N across the whole store, by header, and stamp the record. |
fluree encryption generate-key | — | Print a fresh base64 key. |
All six endpoints, the two status reads included, sit behind the admin-token
gate, which is enforced only with --admin-auth-mode required. Under the
default (none) anyone who can reach the server can start, pause, cancel or
verify a rotation, so enable admin auth before exposing a server that encrypts.
Under Raft the writes are forwarded to the leader, which is the only node that runs the sweep. The status reads are answered by whichever node receives them.
Resuming after a restart or leader change
A sweep resumes by itself only in some cases; otherwise
fluree encryption resume continues it.
- A standalone server tries once, at startup, to resume a
runningrecord. It succeeds when the record’s holder is this same process identity (host name and process id, so in practice only when both repeat, as for a container running as pid 1) or when the record’s last checkpoint is more than ten minutes old. After an ordinary restart the record usually showsstalledten minutes later; runresumethen. - Under Raft, a leader that loses leadership cleanly releases the record, and
the new leader takes it over when it starts. The holder is the Raft node id,
so a crashed leader that is elected again continues its own record. If a
different node’s check ran before the release was written, or the old leader
crashed, nobody advances the record: it shows
releasedor, ten minutes after a crash,stalled. Runresumeagainst the leader; a released record is taken over at once. - A throttle set with
--rateis not stored in the record. A sweep resumed any way other thanrotate --retire N --rate …(same key and scope) runs unthrottled (#1954).
Automatic retries are tracked in #1953.
Reading status
The record reports:
state:running,paused,cancelled,failed,sweptorcompleted.- The holder,
dry_run,ledger_scope, and progress:units_doneofunits_total(each ledger branch, then each ledger’s shared dictionaries, then graph sources), the unit in progress and the last address done. - Counters:
scanned,rewritten,already_current,on_other_keys,not_enveloped(lock files and any blob written before encryption was enabled),on_retired(blobs found on the retiring key; in a dry run, the work a real run would do),bytes_rewritten, andfailedwith the first hundred failing addresses. last_error, andcompletion(verified_at,remaining_on_retired) once verification has run.
The status view adds active_here (the sweep runs in the answering process),
seconds_since_update, stalled and released. The sweep checkpoints every
thousand blobs or thirty seconds while it rewrites. stalled is set when a
running record has not checkpointed for ten minutes, and rotate or resume
takes it over. Listing a unit and the closing verification write no checkpoint,
so on a large store a live sweep can show stalled while it lists or verifies:
check the holder first, and do not resume while the holder’s own status shows
active_here: true. released is set when the last holder let go on a
leadership change; see
Resuming after a restart or leader change.
swept means the sweep finished but verification found blobs still on the
retiring key, or could not list the store (last_error says which); resume
retries them.
Only completed with completion.remaining_on_retired equal to 0 licenses
removing the retiring key. A dry run also ends as completed, with
dry_run: true and no completion: it counted, it did not verify, and it is
held only in the memory of the node that ran it (the leader, under Raft).
That node’s status keeps showing the dry run until another sweep starts there,
and resume after a dry run starts a real sweep. Running verify for a
different key id than the record’s replaces the record.
Scope and limits
- Rotation re-encrypts; it does not encrypt. A blob written before encryption was enabled has no envelope, and the sweep counts it as not enveloped and leaves it alone. To encrypt an existing plaintext store, see Enabling Encryption on Existing Data.
--ledgernarrows the sweep, not the verification. A ledger name covers every branch of that ledger, a branch-qualified id only that branch; either way the ledger’s shared dictionaries are included and graph sources are not. The scoped sweep’s closing verification still counts the whole store, so it endssweptwhile anything else is still on the retiring key; an unscopedrotate --retire Nfinishes the rest.- Local runs. With
--connection-configthe sweep runs inside the CLI process, which blocks until it stops,--waitor not.pauseandcancelact on the process running the sweep, so they work only against a server. Interrupting a local sweep leaves the recordrunning;resumetakes it over once it showsstalled. - Storages reached through
addressIdentifiersare not rotated. This node writes and lists only its default storage, so that is what it rotates and whatGET /v1/fluree/encryptionreports. A routed storage is read-only here and has its own key configuration; rotate it from the deployment that writes it. - Cost. The sweep lists each unit’s addresses into memory before walking
them. Verification, which runs at the end of every sweep and on each
verify, lists the whole store at once and reads one envelope header per blob. Budget roughly 100–150 bytes of memory per blob in the store, and one ranged read per blob for each verification pass. Streaming the listing is tracked in #1951.
Encryption Details
Algorithm
- Cipher: AES-256-GCM (Galois/Counter Mode)
- Key Size: 256 bits (32 bytes)
- Nonce Size: 96 bits (12 bytes), randomly generated per write
- Tag Size: 128 bits (16 bytes)
Ciphertext Envelope Format
All encrypted data uses a portable envelope format:
┌──────────────────────────────────────────────────────────────┐
│ Header (22 bytes) │
├──────────┬─────────┬─────────┬──────────┬───────────────────┤
│ Magic │ Version │ Alg │ Key ID │ Nonce │
│ 4 bytes │ 1 byte │ 1 byte │ 4 bytes │ 12 bytes │
│ "FLU\0" │ 0x01 │ 0x01 │ u32 LE │ random │
├──────────┴─────────┴─────────┴──────────┴───────────────────┤
│ Ciphertext (variable length) │
├──────────────────────────────────────────────────────────────┤
│ Authentication Tag (16 bytes) │
└──────────────────────────────────────────────────────────────┘
- Magic bytes:
FLU\0(0x46 0x4C 0x55 0x00) for format detection - Version: Format version (currently 0x01)
- Algorithm: 0x01 = AES-256-GCM
- Key ID: Identifier for key rotation support, little-endian
- Nonce: Randomly generated per encryption operation
- Authentication Tag: GCM integrity tag (authenticates header + ciphertext)
Security Properties
- Confidentiality: AES-256 encryption protects data content
- Integrity: GCM authentication tag detects tampering
- Authenticity: Header is included in AAD (Additional Authenticated Data)
- Non-deterministic: Random nonces mean same plaintext → different ciphertext
Integrity is per blob. The tag detects a modified blob, but the envelope is not bound to its address, so it does not detect one valid blob substituted for another under the same key, nor a rollback of the plaintext nameservice. Encryption protects confidentiality at rest; control write access to storage separately.
Portability
Encrypted data is portable between storage backends:
# Encrypted files can be copied from local storage to S3
aws s3 sync /var/lib/fluree/data s3://my-bucket/fluree/
# And back again
aws s3 sync s3://my-bucket/fluree/ /var/lib/fluree/data
The same encryption key will decrypt data regardless of where it’s stored.
What Stays Plaintext
Encryption covers every blob written through the storage layer: commits, transactions, index roots, branches, leaves, dictionaries and arenas. These are outside it by design:
-
The nameservice. The file nameservice under
ns@v2/and the DynamoDB or S3 storage-backed nameservice hold, in plaintext: ledger and branch names, head commit and index root ids, default-context and config ids, and graph-source definitions including their configuration (for example a BM25 index’s query, or an Iceberg or R2RML source’s settings). They hold no flakes or commit data. Under Raft the same state is also in each node’s Raft log and snapshots under--raft-storage-path; put that directory on an encrypted volume (#1955). -
Nothing else on local disk, once a build is done. Readers keep a read-through disk cache of index artifacts (
$TMPDIR/fluree_binary_cacheby default, orLedgerManagerConfig::cache_dir), and the indexer seeds it with artifacts it just built. With encryption enabled that cache is bypassed entirely: no decrypted leaf, branch, dictionary or vector shard is written outside the encrypted storage, and nothing already in the cache directory is consulted. Fetched artifacts are served from memory instead.Upgrading from an earlier release. Earlier releases did write decrypted index artifacts to this cache when encryption was enabled. This release no longer reads them, but it does not delete them. After upgrading, stop the server and delete the cache directory to remove those plaintext copies from disk. The same applies to a host that held a ledger unencrypted before it was re-imported with a key.
-
Uploaded import files. With
--import-presign-enabled, uploaded.flpackarchives and source files are staged in plaintext under--import-staging-dir(FLUREE_IMPORT_STAGING_DIR, default$TMPDIR/fluree-import-staging) until the restore finishes. Put that directory on an encrypted volume. -
Peers reading through a proxy. A peer in proxy mode fetches artifacts over HTTP from a server. It holds no key, and what it receives is plaintext even when that server encrypts at rest, so the peer’s own disk cache holds plaintext. Encryption at rest covers the server’s storage, not a peer’s local disk. Protect a peer’s cache directory as you would the ledger data itself.
Index build staging
A full index rebuild is an external sort. While it runs it stages sorted
commit runs, dictionaries and leaves in plaintext under per-session
directories, {data_dir}/{ledger}/tmp_import/{session} and
{data_dir}/{ledger}/index/{session}. Those directories are removed on every
exit — success, error, or a panic in the build task — so nothing outlives the
build. Only a process killed mid-build leaves its session directories behind;
nothing removes those automatically, so after a crash clear both
{data_dir}/{ledger}/tmp_import/ and {data_dir}/{ledger}/index/.
data_dir defaults to $TMPDIR/fluree-index. Embedders set it with
IndexerConfig::data_dir through FlureeBuilder::with_indexer_config. The
server has no setting for it, so its rebuilds always stage under
$TMPDIR/fluree-index: for an encrypted server, set TMPDIR in the server’s
environment to a directory on an encrypted volume
(#1955). The rebuild logs a warning
when encryption is on and data_dir is unset.
A bulk import stages the same kind of plaintext, but not under data_dir: it
uses $TMPDIR/fluree-import/{ledger}/tmp_import/{session}, or the directory
named by FLUREE_IMPORT_DIR. For an encrypted deployment, point
FLUREE_IMPORT_DIR at an encrypted volume too. Import removes its session
directory when it finishes, on success or error, unless cleanup_local_files
is turned off. Unlike a rebuild, an import that panics or is cancelled leaves
its session directory behind
(#1950).
Performance Considerations
- CPU overhead: ~5-15% for encryption/decryption (depends on hardware AES support)
- Storage overhead: 22 bytes header + 16 bytes tag per object
- Memory: Keys are kept in memory while the connection is open
- No disk cache: because the read-through disk cache is bypassed (see above),
a remote backend such as S3 re-fetches an index artifact whenever it falls out
of the in-memory leaflet cache. Concurrent readers of the same artifact still
share one fetch. Size that cache (
cacheMaxMb) for the working set. File storage has no network round trip, but each cache miss re-reads and decrypts the whole file rather than mapping it.
Modern CPUs with AES-NI instructions provide hardware acceleration, minimizing the performance impact.
Troubleshooting
Common Errors
“Invalid encryption format”
- The data doesn’t have the expected magic bytes
- Possible causes: trying to read unencrypted data with encryption enabled, or corrupted data
“Unknown encryption key ID: N”
- The blob was encrypted with key id N, which this node does not hold
- A key was removed from
AES256Keysbeforeverifyreported zero remaining, or a node was not given the new key before another node made it current - Moving from a single
AES256KeytoAES256Keys: the old key must keep id0
“Decryption failed”
- The encryption key doesn’t match
- The data may be corrupted
- The authentication tag verification failed (data was tampered with)
“Invalid encryption key: Invalid key: key must be exactly 32 bytes when decoded”
(or “…: invalid base64 encoding”; “Invalid encryption key N: …” for an
AES256Keys entry)
- The key is not valid base64, or does not decode to exactly 32 bytes
“AES256Key is configured but did not resolve to a key”
- The
envVarit names is unset or empty, and there is nodefaultVal
“commitStorage carries an encryption key that indexStorage does not”
- Move the key to the
indexStoragenode; it encrypts commit storage too
Key set errors at startup
- “AES256Key and AES256Keys are mutually exclusive”: use one form
- “AES256Keys entry requires AES256Key” / “AES256Keys entry requires keyId”: an
entry is missing its key (or its
envVardid not resolve) or its id - “AES256Keys requires AES256CurrentKey”: name the key that encrypts new writes
- “AES256CurrentKey N is not among the AES256Keys ids”: list that key
Rotation refused
- “key N is the current key” / “key N is not held”:
--retiremust name a held key other than the current one - 409 “a key rotation is running on …”: another holder checkpointed within ten
minutes; wait for
stalled, orresumeareleasedrecord - 409 “no key rotation is running in this process”:
pauseandcancelreach only a sweep running in the process you ask; use--remote
Verifying Encryption
Check if a file is encrypted by looking for the magic bytes:
# Check the first 4 bytes of a blob, for example a commit
xxd -l 4 /var/lib/fluree/data/<ledger>/<branch>/commit/<file>
# Encrypted: 00000000: 464c 5500 FLU.
# Unencrypted: another format's magic, for example FCV2, FLI3 or FIR6, or JSON
Compare all four bytes: FLI3 also starts with 46 4c. Files under ns@v2/
and lock files are plaintext by design. After every start, confirm with
fluree encryption status, which lists the key ids a node holds, or
GET /v1/fluree/encryption, which returns encrypted: true.
Changing Encryption Settings
Enabling Encryption on Existing Data
Key rotation cannot do this: it re-encrypts enveloped blobs and leaves
plaintext ones alone. Move each ledger through a .flpack archive instead,
which keeps its commits and history:
- Export each ledger from the unencrypted server as a ledger archive
- Stop the server and move the unencrypted data aside
- Configure encryption and start the server on a fresh storage path
- Restore each archive into the encrypted server
# 1. Export (while running without encryption); one archive per branch
fluree export mydb:main --remote old --format ledger -o mydb.flpack
# 2. Stop the server and move the old data aside
mv /var/lib/fluree/data /var/lib/fluree/data-unencrypted-backup
# 3. Configure an encryption key and start the server with it
export FLUREE_ENCRYPTION_KEY=$(fluree encryption generate-key)
echo "Save this key securely: $FLUREE_ENCRYPTION_KEY"
fluree server run --connection-config encrypted-config.jsonld
# 4. From another shell, restore into the encrypted server
fluree create mydb --remote new --from mydb.flpack
Then delete what is still plaintext: the .flpack files, the moved-aside data
directory once you no longer need it, and the old read-through disk cache
($TMPDIR/fluree_binary_cache by default). fluree export --format json-ld
also works but carries only the current state of the default graph, with no
history and no named graphs.
Disabling Encryption
Warning: This exposes your data. Only do this if absolutely necessary.
Follow the same export/import process, but configure without an encryption key.
Related Documentation
fluree encryption- Key rotation commands- Storage Modes - Storage backend configuration
- Configuration - General configuration reference
- Policy Model - Access control and authorization