S3 Encryption Gateway
The Problem
Countless applications write data to S3-compatible storage — database backups, log archives, ML training data, CI/CD artifacts — but most of them don't encrypt that data client-side.
The real threat isn't a rogue storage provider. Most people reasonably trust their cloud provider and their server-side encryption (SSE). The much more common and practical risk is a misconfigured IAM policy, overly broad bucket policy, accidentally public ACL, or compromised access key. Any mistake at the IAM or policy layer directly exposes your plaintext data — because without client-side encryption, whoever can reach the bucket can read everything in it.
By adding a cryptographic layer at the gateway, a configuration mistake in your cloud account no longer immediately translates into a data breach. An attacker who gains unauthorized S3 access — through a policy misconfiguration, a leaked key, or any other account-level compromise — only retrieves ciphertext. They would also need to compromise the gateway — which in a typical deployment never leaves your private network.
This is defense-in-depth for object storage: your cloud account's access controls remain your first line of defense; client-side encryption is the second — and it holds even when the first fails.
Beyond misconfiguration risk, there are valid reasons to want an independent crypto layer: regulated environments that require customer-managed keys, multi-tenant shared infrastructure, or simply a preference for not relying solely on provider controls.
Modifying every application to implement client-side encryption isn't realistic. Different tools use different S3 SDKs, different languages, and different upload strategies. Some are closed-source. Some are operators you don't control.
The result: sensitive data sits on object storage, protected only by IAM policies and SSE keys the provider controls — one misconfiguration away from full exposure.
The Solution
The S3 Encryption Gateway is a transparent HTTP proxy that sits between your applications and any S3-compatible storage backend. It encrypts data on the way in and decrypts it on the way out — without changing a single line of application code.
┌─────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ S3 Client │──S3 API──▶ Encryption Gateway │──S3 API──▶ S3 Backend │
│ (any app) ◀──────────│ encrypt/decrypt ◀──────────│ (AWS, MinIO, │
└─────────────┘ plain └──────────────────────┘ cipher │ Hetzner, ...) │
text text └─────────────────┘
Transparent: Point your S3 endpoint URL at the gateway — that's it. No application changes required.
Who Needs This?
| Category | Examples | What they store | Encrypts itself? |
|---|---|---|---|
| Databases | CNPG, Zalando Postgres, MySQL Operator | Backups, WAL archives | ❌ |
| Backup tools | Velero, Restic, Longhorn, Kasten | Cluster/app backups, snapshots | ⚠️ Varies |
| Log & metrics | Loki, Thanos, Mimir, Tempo, Cortex | Logs, metrics, traces | ❌ |
| File sharing | Nextcloud, Seafile, ownCloud | User files, documents, photos | ⚠️ Partial/complex |
| Data platforms | Spark, Trino, Iceberg, Delta Lake | Analytics data, query results | ❌ |
| ML platforms | MLflow, Kubeflow, DVC, JupyterHub | Models, training data, experiments | ❌ |
| CI/CD & Git | GitLab, Gitea, Forgejo, Jenkins | Artifacts, LFS, packages | ⚠️ Varies |
| Chat & social | Mattermost, Mastodon | Uploads, media, attachments | ❌ |
| IaC state | Terraform, OpenTofu, Pulumi | State files (often containing secrets!) | ⚠️ Often forgotten |
| Container registries | Harbor, GitLab Registry | Image layers, blobs | ❌ |
| Custom apps | Any S3 client | Whatever you store | ⚠️ Your responsibility |
If your compliance team, CISO, or data protection officer asks "Are our S3 objects encrypted client-side?" — and the honest answer is "not all of them" — this gateway fixes that in one place, for all applications at once.
Born from Production
We built this gateway because we needed it ourselves. We run cloud platforms for customers and use CloudNativePG as our PostgreSQL operator on Kubernetes. CNPG handles automated backups, WAL archiving, and point-in-time recovery — but it writes those database dumps to S3 unencrypted.
Full database backups in plaintext on object storage wasn't acceptable. But modifying every application that writes to S3 wasn't practical either — the problem wasn't limited to CNPG.
So we built a transparent proxy that solves the problem once, for every application, without touching a single line of application code. The S3 Encryption Gateway is running in production, protecting data across multiple environments and storage backends.
Features
Object Encryption
All objects are encrypted before being sent to the backend and decrypted on retrieval. Encryption is transparent — any S3 client works without modification.
Recommended: Envelope encryption with a locally-held AES-256 or RSA key, or an external KMS (Cosmian KMIP). A random per-object Data Encryption Key (DEK) is wrapped with the Key Encryption Key (KEK) at encrypt time and unwrapped at decrypt time — no key derivation on the hot path. Envelope encryption is 50–76× faster than PBKDF2 600k for single-object uploads and over 70× faster for range reads. See the Encryption Modes Guide for full benchmark tables and a mode comparison.
Password-derived (legacy, simpler deployment): Derives per-object keys from a gateway password via PBKDF2 or argon2id. Requires no key infrastructure — just a single ENCRYPTION_PASSWORD environment variable — but runs key derivation on every request. See the Encryption Modes Guide for throughput numbers and a migration guide if you are switching from password-derived to envelope encryption.
- AES-256-GCM (default) or ChaCha20-Poly1305: Authenticated encryption with per-object keys
- Chunked streaming: Large files are encrypted in chunks with per-chunk IVs, enabling efficient range requests
- Range requests: Fetches only the encrypted chunks covering the requested plaintext byte range
- FIPS-compliant profile: Build with
-tags=fipsto restrict to AES-256-GCM + HKDF-SHA256 (FIPS-140 approved). Under envelope encryption, DEK wrapping uses AES-256-GCM (AES KEK) or RSA-OAEP/SHA-256 (RSA KEK) — both FIPS-approved. Theargon2idKDF is rejected at startup under-tags=fips.
Encrypted Multipart Uploads
Large objects uploaded via the S3 multipart API are encrypted end-to-end. Each upload gets its own key; each chunk gets a deterministic, collision-free IV derived via HKDF-SHA256.
- Per-upload DEK: Fresh 32-byte AES-256-GCM key generated at
CreateMultipartUpload - DEK wrapping: Via the configured
KeyManager(Cosmian KMIP, HSM, or built-in password-basedPasswordKeyManager) - Per-chunk IV:
HKDF-Expand(SHA-256, dek, salt=sha256(uploadId), info=ivPrefix‖BE32(part)‖BE32(chunk))— deterministic and collision-free - AEAD manifest: Encrypted companion object at
<key>.mpu-manifest; main object metadata carries a pointer - Ranged GET across part boundaries: Precise byte-range fetch via
EncRangeForPlaintextRange; only the chunks covering the requested plaintext range are fetched and decrypted - Tamper detection: AES-GCM tag failure on any chunk returns 500 and emits an
mpu.tamper_detectedaudit event; first-chunk tamper returns 500 before any body is written - State store: Valkey (or any Redis-protocol-compatible store); 7-day TTL; one hash per active upload
- FIPS: AES-256-GCM + HKDF-SHA256 — both FIPS-140 approved (works under
-tags=fips) - Opt-in per bucket via policy; requires Valkey for in-flight state
See ADR 0009 for the full design rationale.
Enabling encrypted multipart uploads
Encrypted multipart uploads require a Valkey (or Redis-protocol-compatible) instance for in-flight state storage. Enable per bucket via policy and configure the state store in the gateway config:
# config.yaml
multipart_state:
valkey:
addr: "valkey.internal:6379"
password_env: "VALKEY_PASSWORD" # env var name (not the literal password)
tls:
enabled: true
ca_file: "/etc/gateway/valkey-ca.pem"
cert_file: "/etc/gateway/valkey-client.pem"
key_file: "/etc/gateway/valkey-client-key.pem"
ttl_seconds: 604800 # 7 days — refreshed on every UploadPart
pool_size: 16
# policy/my-bucket.yaml
id: my-encrypted-bucket
buckets:
- "my-important-bucket"
encrypt_multipart_uploads: true
All Valkey settings are also available as environment variables (VALKEY_ADDR, VALKEY_TLS_ENABLED, VALKEY_TLS_CA_FILE, VALKEY_TTL_SECONDS, etc.).
encrypt_multipart_uploads defaults to true as of v0.8. Buckets without a matching policy, or policies that omit the field, will use the encrypted multipart upload path automatically. Set encrypt_multipart_uploads: false explicitly in the policy to opt a specific bucket out.
Fail-closed guarantees
The gateway refuses to silently degrade security under any of these conditions:
| Situation | Behaviour |
|---|---|
encrypt_multipart_uploads: true on any policy + Valkey address not configured at startup |
Process exits with a Fatal log — no silent fallback to plaintext |
| Valkey reachable at startup but transient failure mid-upload | 503 ServiceUnavailable on the affected request; client retries are safe because the IV schedule is deterministic |
UploadPart succeeds on backend but AppendPart to Valkey fails |
503 ServiceUnavailable; client retries overwrite the same part idempotently |
| Policy is flipped mid-upload | In-flight uploads use the PolicySnapshot captured at CreateMultipartUpload; the flip only affects new uploads |
No KeyManager and an encrypted-MPU request arrives |
503 ServiceUnavailable with reason "KeyManager not configured" |
Plaintext Valkey + production config (insecure_allow_plaintext: false) |
Startup refuses; emits a gateway_mpu_valkey_insecure=1 gauge if overridden |
The dedicated escape hatch for deployments that cannot run Valkey at all:
server:
disable_multipart_uploads: true # env: SERVER_DISABLE_MULTIPART_UPLOADS
This enforces a 5 GiB single-PUT ceiling but guarantees all data is encrypted and requires no state infrastructure.
UploadPart memory cap (server.max_part_buffer)
Each UploadPart request is buffered into a seekable in-memory buffer so the AWS SDK V2 SigV4 signer can re-read the body for payload hashing and retries. The default cap is 64 MiB — parts larger than this are refused with HTTP 413 before any backend write occurs:
server:
max_part_buffer: 67108864 # 64 MiB (default); env: SERVER_MAX_PART_BUFFER
Raise this value if your workload uploads parts larger than 64 MiB. The cap applies to both encrypted and plaintext multipart upload paths. Server.MaxLegacyCopySourceBytes (default 256 MiB, set via server.max_legacy_copy_source_bytes / SERVER_MAX_LEGACY_COPY_SOURCE_BYTES) separately bounds the allocation incurred when copying legacy (non-chunked) encrypted objects with CopyObject or UploadPartCopy.
Deploying Valkey with the Helm chart
The Helm chart bundles an optional Valkey subchart which is off by default:
# values.yaml
valkey:
enabled: true
architecture: standalone # or "cluster" for HA
auth:
enabled: false # enable + mount a secret for production
When valkey.enabled=true, the deployment template auto-wires VALKEY_ADDR to the subchart's <release>-valkey:6379 service. You can also point at an external Valkey cluster via the config.multipartState.valkey values stanza — the two paths are mutually exclusive.
Cost note for Wasabi / Glacier / S3 IA users: The Valkey state store exists precisely to avoid writing state objects to S3 — which on backends with minimum-storage-duration policies (Wasabi: 90 days on Pay-Go; Glacier / Standard-IA / One Zone-IA: 30–180 days) would incur significant phantom-storage charges. Your data objects still land on whichever backend you choose; only the ephemeral per-upload state lives in Valkey.
Envelope Encryption (Recommended)
Envelope encryption removes key derivation from the per-request hot path: a random per-object Data Encryption Key (DEK) is wrapped with a Key Encryption Key (KEK). The KEK is loaded once at startup. This is 50–76× faster than PBKDF2 600k and is the recommended path for all production deployments. See docs/ENCRYPTION_MODES.md for performance benchmarks.
Migrating from password-only? Set
encryption.passwordto your existing password and enablekey_manager. The gateway reads the password for objects encrypted before the switch and uses the KEK for all new objects — no data migration required. To re-encrypt existing objects, use the GET-through-gateway → PUT-through-gateway pattern with any standard S3 client. Seedocs/MIGRATION.mdfor details.
Three variants are supported:
Self-contained AES KEK (simplest, no external dependencies)
Generate a 32-byte base64 KEK:
openssl rand -base64 32
# example output: pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=
Option A — YAML config (uses key_source to read the key from an environment variable):
encryption:
password: "fallback-password-123456" # for legacy objects encrypted with password
key_manager:
enabled: true
provider: self_contained
self_contained:
type: aes
aes:
keys:
- version: 1
key_source: "env:S3EG_AES_KEK" # name of the env var holding the base64 key
export S3EG_AES_KEK="pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o="
Option B — environment variables only (no config file; the SELF_CONTAINED_AES_KEYS format is "version=base64:value"):
export KEY_MANAGER_ENABLED=true
export KEY_MANAGER_PROVIDER=self_contained
export SELF_CONTAINED_TYPE=aes
export SELF_CONTAINED_AES_KEYS="1=base64:pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o="
Self-contained RSA KEK (uses existing PKI)
encryption:
key_manager:
enabled: true
provider: self_contained
self_contained:
type: rsa
rsa:
private_key_source: "file:/run/secrets/kek_rsa.pem"
key_version: 1
Cosmian KMIP KMS (external KMS with key rotation)
The gateway supports envelope encryption via Cosmian KMIP with key rotation and dual-read windows.
- Envelope encryption: A unique DEK is generated per object, then wrapped with the KMS master key
- Key rotation: The
dual_read_windowsetting allows reading objects encrypted with previous key versions during rotation - Fallback support: Objects encrypted with the password (before KMS was enabled) can still be decrypted
- Health checks: KMS health is automatically checked via the
/readyendpoint
Quick start
- Start Cosmian KMS:
docker run -d --rm --name cosmian-kms \
-p 5696:5696 -p 9998:9998 --entrypoint cosmian_kms \
ghcr.io/cosmian/kms:5.22.0
-
Create a wrapping key via the Cosmian KMS UI (http://localhost:9998/ui) and note the key ID.
-
Configure the gateway:
encryption:
password: "fallback-password-123456"
key_manager:
enabled: true
provider: "cosmian"
dual_read_window: 1
cosmian:
endpoint: "http://localhost:9998/kmip/2_1"
timeout: "10s"
keys:
- id: "your-key-id-from-cosmian"
version: 1
Or via environment variables:
export KEY_MANAGER_ENABLED=true
export KEY_MANAGER_PROVIDER=cosmian
export KEY_MANAGER_DUAL_READ_WINDOW=1
export COSMIAN_KMS_ENDPOINT=http://localhost:9998/kmip/2_1
export COSMIAN_KMS_TIMEOUT=10s
export COSMIAN_KMS_KEYS="your-key-id:1"
Protocol options
JSON/HTTP (recommended, tested in CI):
- Full URL:
http://localhost:9998/kmip/2_1orhttps://kms.example.com/kmip/2_1 - Base URL also works:
http://localhost:9998(path/kmip/2_1is auto-appended) - No client certificates required for HTTP;
ca_certrecommended for HTTPS
Binary KMIP (advanced, requires TLS):
- Endpoint format:
localhost:5696orkms.example.com:5696 - Requires
ca_cert,client_cert, andclient_key - Not fully tested in CI — use with caution
See docs/KMS_COMPATIBILITY.md for detailed documentation. AWS KMS and Vault Transit adapters are on the roadmap.
Compression
Built-in pre-encryption compression was removed in v1.0. For users who need compression, we recommend external composition:
client → s4 (https://github.com/abyo-software/s4) → s3-encryption-gateway → storage
Rate Limiting
Token-bucket rate limiting protects against abuse.
rate_limit:
enabled: true
limit: 100 # requests per window
window: "60s"
Caching
Optional in-memory response cache reduces backend traffic for frequently read objects.
cache:
enabled: false
max_size: 104857600 # 100 MB
max_items: 1000
default_ttl: "5m"
Audit Logging
Structured audit events for every S3 operation, with configurable sinks.
audit:
enabled: false
max_events: 10000
sink:
type: "stdout" # stdout, file, or http
file_path: ""
endpoint: ""
batch_size: 100
flush_interval: "5s"
Multipart-specific audit events: mpu.create, mpu.part, mpu.complete, mpu.abort, mpu.tamper_detected, mpu.valkey_unavailable.
Monitoring & Observability
Health endpoints
GET /health— general health statusGET /ready(alias/readyz) — readiness probe with per-dependency status:
{
"status": "ready",
"checks": {
"kms": "ok",
"valkey": "ok"
}
}
Returns HTTP 503 with status: "not_ready" if any configured dependency fails its health check.
GET /live— liveness probeGET /metrics— Prometheus metrics
Metrics endpoint routing (in priority order):
- Dedicated metrics port (
metrics.addr: ":9090"/METRICS_ADDR) — recommended for Kubernetes; unauthenticated plain HTTP, restrict viaNetworkPolicy - Admin port fallback — when
metrics.addris empty and admin is enabled,/metricsis served on the admin port (requires bearer auth) - S3 port fallback — when both
metrics.addris empty and admin is disabled,/metricsis served on the S3 data-plane port (legacy, no auth)
Prometheus metrics
- HTTP: request counts, durations, bytes transferred
- S3 operations: operation counts, durations, error rates
- Encryption: encryption/decryption counts, durations, throughput
- System: active connections, goroutines, memory usage
Key metric names: http_requests_total, encryption_operations_total, active_connections, goroutines_total, memory_alloc_bytes.
Seven metrics track the multipart-encryption hot path:
| Metric | Type | Labels | Emitted on |
|---|---|---|---|
gateway_mpu_encrypted_total |
counter | result |
Every CompleteMultipartUpload on encrypted buckets |
gateway_mpu_parts_total |
counter | result |
Every UploadPart on encrypted buckets |
gateway_mpu_state_store_ops_total |
counter | op, result |
Every Valkey op |
gateway_mpu_state_store_latency_seconds |
histogram | op |
Every Valkey op |
gateway_mpu_valkey_up |
gauge | — | Ready-check HealthCheck |
gateway_mpu_valkey_insecure |
gauge | — | Startup, if TLS is disabled |
gateway_mpu_manifest_bytes |
histogram | — | Every CompleteMultipartUpload |
TLS
The gateway can terminate TLS directly.
tls:
enabled: true
cert_file: /path/to/cert.pem
key_file: /path/to/key.pem
All responses also include security headers: X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy, and others.
Admin API
Bearer-authenticated admin endpoints on a separate port:
| Endpoint | Purpose |
|---|---|
POST /admin/mpu/abort/{uploadId} |
Force-abort an in-flight upload and delete its Valkey state |
GET /admin/mpu/list |
List active uploads (bucket, key, creation time) |
Quick Start
Prerequisites
- Docker (recommended), or
- Go 1.25+ for local builds
Docker — Envelope encryption (Recommended)
Generate an AES-256 KEK once and keep it secret — this is the key that protects all your encrypted objects:
openssl rand -base64 32
# example output: pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=
docker run -p 8080:8080 \
-e BACKEND_ENDPOINT="https://s3.amazonaws.com" \
-e BACKEND_REGION="us-east-1" \
-e BACKEND_ACCESS_KEY="your-key" \
-e BACKEND_SECRET_KEY="your-secret" \
-e ENCRYPTION_PASSWORD="your-existing-password" \
-e KEY_MANAGER_ENABLED=true \
-e KEY_MANAGER_PROVIDER=self_contained \
-e SELF_CONTAINED_TYPE=aes \
-e SELF_CONTAINED_AES_KEYS="1=base64:pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=" \
-e GW_ACCESS_KEY_1="gateway-access-key" \
-e GW_SECRET_KEY_1="gateway-secret-key" \
cloud37io/s3-encryption-gateway:0.10.0-rc3
Replace the example KEK. The value shown in
SELF_CONTAINED_AES_KEYSis a documentation placeholder — generate your own withopenssl rand -base64 32and keep it in a secrets manager. Anyone with this key can decrypt your objects.
ENCRYPTION_PASSWORDis the fallback for objects encrypted before you enabledKEY_MANAGER. If you have no existing objects, set it to any strong random value. If you are migrating from password-only mode, set it to your existing encryption password — existing objects will continue to decrypt transparently.
This runs envelope encryption — per-object DEKs wrapped with a local AES-256 KEK. No key derivation on the hot path. See Envelope Encryption above and benchmark results.
Docker — Password-only (simpler deployment, slower)
docker run -p 8080:8080 \
-e BACKEND_ENDPOINT="https://s3.amazonaws.com" \
-e BACKEND_REGION="us-east-1" \
-e BACKEND_ACCESS_KEY="your-key" \
-e BACKEND_SECRET_KEY="your-secret" \
-e ENCRYPTION_PASSWORD="your-password" \
-e GW_ACCESS_KEY_1="gateway-access-key" \
-e GW_SECRET_KEY_1="gateway-secret-key" \
cloud37io/s3-encryption-gateway:0.10.0-rc3
Runs PBKDF2-SHA256 600k on every request. No key infrastructure needed — just a single password — but throughput is 50× lower than envelope encryption.
Authentication is required. As of v0.8, every request must include valid AWS Signature V4 or V2 credentials matching an entry in
auth.credentials. Unauthenticated requests will receiveAccessDenied.
Point any S3 client at the gateway instead of directly at S3:
# Before: direct to S3 (unencrypted)
aws s3 cp backup.sql s3://my-bucket/ --endpoint-url https://s3.amazonaws.com
# After: through the gateway (encrypted)
aws s3 cp backup.sql s3://my-bucket/ --endpoint-url http://localhost:8080
Kubernetes (Helm)
Envelope encryption (recommended):
# Generate a KEK — replace the placeholder below with this output
openssl rand -base64 32
# example output: pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=
kubectl create secret generic s3-encryption-gateway-secrets \
--from-literal=backend-access-key=YOUR_KEY \
--from-literal=backend-secret-key=YOUR_SECRET \
--from-literal=encryption-password=YOUR_EXISTING_PASSWORD \
--from-literal=gateway-access-key=YOUR_GATEWAY_ACCESS_KEY \
--from-literal=gateway-secret-key=YOUR_GATEWAY_SECRET_KEY \
--from-literal=master-key=pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=
# values.yaml
config:
encryption:
keyManager:
enabled:
value: "true"
provider:
value: self_contained
selfContained:
type:
value: aes
aes:
activeVersion:
value: "1"
keys:
- version: 1
secretKeyRef:
name: s3-encryption-gateway-secrets
key: master-key
Password-only (simpler, slower):
kubectl create secret generic s3-encryption-gateway-secrets \
--from-literal=backend-access-key=YOUR_KEY \
--from-literal=backend-secret-key=YOUR_SECRET \
--from-literal=encryption-password=YOUR_PASSWORD \
--from-literal=gateway-access-key=YOUR_GATEWAY_ACCESS_KEY \
--from-literal=gateway-secret-key=YOUR_GATEWAY_SECRET_KEY
helm install s3-encryption-gateway ./helm/s3-encryption-gateway
See the Helm chart documentation for detailed deployment options.
Docker — Local testing with RustFS
RustFS is a lightweight, S3-compatible object store ideal for local development. It is actively maintained (unlike MinIO, which is archived for self-hosted use):
docker network create s3gw-net
# Start RustFS
docker run -d --name rustfs --network s3gw-net \
-p 9000:9000 -p 9001:9001 \
-e RUSTFS_ACCESS_KEY=minioadmin \
-e RUSTFS_SECRET_KEY=minioadmin \
rustfs/rustfs:latest
# Start the gateway pointing at RustFS
docker run -p 8080:8080 --network s3gw-net \
-e BACKEND_ENDPOINT="http://rustfs:9000" \
-e BACKEND_REGION="us-east-1" \
-e BACKEND_ACCESS_KEY="minioadmin" \
-e BACKEND_SECRET_KEY="minioadmin" \
-e BACKEND_USE_PATH_STYLE="true" \
-e ENCRYPTION_PASSWORD="dev-password" \
-e KEY_MANAGER_ENABLED=true \
-e KEY_MANAGER_PROVIDER=self_contained \
-e SELF_CONTAINED_TYPE=aes \
-e SELF_CONTAINED_AES_KEYS="1=base64:pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o=" \
-e GW_ACCESS_KEY_1="gw-access-key" \
-e GW_SECRET_KEY_1="gw-secret-key" \
cloud37io/s3-encryption-gateway:0.10.0-rc3
Docker Compose
For local development and testing with a bundled MinIO backend:
cp docker/docker-compose.example.yml docker-compose.yml
cp docker/docker-compose.env.example .env
nano .env # Edit with your configuration
docker-compose up -d
Access MinIO Console at http://localhost:9001. Gateway API at http://localhost:8080.
Building from Source
make build
# or
go build -o bin/s3-encryption-gateway ./cmd/server
./bin/s3-encryption-gateway
Configuration
Create a config.yaml file (see config.yaml.example for reference):
listen_addr: ":8080"
log_level: "info"
auth:
credentials:
- access_key: "YOUR_GATEWAY_ACCESS_KEY"
secret_key: "YOUR_GATEWAY_SECRET_KEY"
# proxied_bucket: "optional-bucket-filter"
backend:
endpoint: "https://s3.amazonaws.com"
region: "us-east-1"
access_key: "YOUR_ACCESS_KEY"
secret_key: "YOUR_SECRET_KEY"
provider: "aws"
use_path_style: false # set true for some S3-compatible providers
encryption:
password: "fallback-for-legacy-objects" # only needed when migrating from password-only
preferred_algorithm: "AES256-GCM" # or "ChaCha20-Poly1305"
supported_algorithms:
- "AES256-GCM"
- "ChaCha20-Poly1305"
# --- Envelope encryption (Recommended) ---
# Remove key_manager entirely to use password-only PBKDF2 derivation instead.
key_manager:
enabled: true
provider: self_contained # or "cosmian"
self_contained:
type: aes
aes:
keys:
- version: 1
key_source: "env:S3EG_AES_KEK" # name of the env var holding the base64 key
# --- Password-only KDF (legacy, slower) ---
# Only active when key_manager.enabled is false or key_manager is absent.
# kdf:
# algorithm: "pbkdf2-sha256" # or "argon2id"
# argon2id:
# time: 2
# memory: 19456
# threads: 1
# multipart_state:
# valkey:
# addr: "valkey.internal:6379"
# tls:
# enabled: true
# # encryption_password_env: "VALKEY_ENCRYPTION_PASSWORD"
# # encrypt_state: true # default: true
# NOTE: built-in compression was removed in v1.0 (V1.0-MAINT-2).
# Use external composition: client → s4 → s3-encryption-gateway → storage
rate_limit:
enabled: false
limit: 100
window: "60s"
cache:
enabled: false
max_size: 104857600 # 100MB
max_items: 1000
default_ttl: "5m"
audit:
enabled: false
max_events: 10000
sink:
type: "stdout" # stdout, file, or http
file_path: ""
endpoint: ""
batch_size: 100
flush_interval: "5s"
Environment variables are also supported for every setting:
export LISTEN_ADDR=":8080"
export BACKEND_ENDPOINT="https://s3.amazonaws.com"
export BACKEND_REGION="us-east-1"
export BACKEND_ACCESS_KEY="your-access-key"
export BACKEND_SECRET_KEY="your-secret-key"
export BACKEND_USE_PATH_STYLE=false
export ENCRYPTION_PASSWORD="fallback-for-legacy-objects"
export ENCRYPTION_PREFERRED_ALGORITHM="AES256-GCM"
export ENCRYPTION_SUPPORTED_ALGORITHMS="AES256-GCM,ChaCha20-Poly1305"
# --- Envelope encryption (Recommended) ---
export KEY_MANAGER_ENABLED=true
export KEY_MANAGER_PROVIDER=self_contained # or "cosmian"
export SELF_CONTAINED_TYPE=aes
export SELF_CONTAINED_AES_KEYS="1=base64:pmW3QqWUWCvjYpcsW1ypkUMPuzdF2w5LfR3ligYtK/o="
# --- Password-only KDF (legacy, omit if using envelope encryption) ---
# export ENCRYPTION_KDF_ALGORITHM="pbkdf2-sha256" # or "argon2id"
# export ENCRYPTION_KDF_ARGON2ID_TIME="2"
# export ENCRYPTION_KDF_ARGON2ID_MEMORY="19456"
# export ENCRYPTION_KDF_ARGON2ID_THREADS="1"
# export VALKEY_ENCRYPTION_PASSWORD_ENV="VALKEY_ENCRYPTION_PASSWORD"
# export VALKEY_ENCRYPT_STATE="true"
export COMPRESSION_ENABLED=false
export RATE_LIMIT_ENABLED=false
export CACHE_ENABLED=false
export AUDIT_ENABLED=false
export TLS_ENABLED=false
Use Cases
Database Backup Encryption
CloudNativePG, Zalando Postgres Operator, and similar database operators write backups directly to S3. Point the backup endpoint at the gateway:
# CloudNativePG Cluster example
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
spec:
backup:
barmanObjectStore:
endpointURL: "http://s3-encryption-gateway:8080"
destinationPath: "s3://database-backups/"
s3Credentials:
accessKeyId:
name: s3-creds
key: ACCESS_KEY
secretAccessKey:
name: s3-creds
key: SECRET_KEY
Kubernetes Backup Encryption
Velero and similar backup tools can route through the gateway by configuring the S3 endpoint:
# Velero BackupStorageLocation example
apiVersion: velero.io/v1
kind: BackupStorageLocation
spec:
provider: aws
objectStorage:
bucket: velero-backups
config:
s3Url: "http://s3-encryption-gateway:8080"
region: us-east-1
Log Data Protection
Log aggregators like Loki store potentially PII-containing log data on S3. Route through the gateway to encrypt at rest:
# Loki S3 storage config example
storage_config:
aws:
s3: s3://access-key:secret-key@s3-encryption-gateway:8080/loki-logs
s3forcepathstyle: true
Compliance & Data Sovereignty
The gateway helps satisfy encryption requirements across multiple compliance frameworks:
- ISO 27001 (A.10.1.1) — Cryptographic controls for data protection
- BSI C5 / IT-Grundschutz — Client-side encryption with customer-managed keys
- GDPR Art. 32 — Technical measures for data protection (encryption at rest)
- PCI DSS Req. 3 — Protect stored cardholder data
Architecture
flowchart LR
C["S3 Client<br/>(awscli, SDKs)"] --> |S3 API| G[Encryption Gateway]
subgraph G["Encryption Gateway"]
D["Middleware<br/>(logging, recovery, security, rate limit)"]
E["Encryption Engine<br/>AES-256-GCM default<br/>ChaCha20-Poly1305"]
K["Key Manager<br/>(AES KEK / RSA KEK / Cosmian KMIP)"]
CMP["Compression<br/>(optional)"]
D --> E
K --> |wrap / unwrap DEK| E
CMP -.-> |pre/post| E
end
G --> |S3 API| B[("S3 Backend<br/>AWS, MinIO, Wasabi, Hetzner")]
Data Flow (PUT/GET)
sequenceDiagram
participant Client
participant Gateway
participant S3
Client->>Gateway: PUT /bucket/key plaintext
Gateway->>Gateway: generate per-object DEK, wrap with KEK
Gateway->>Gateway: encrypt AES-GCM or ChaCha20-Poly1305
Gateway->>S3: PUT /bucket/key ciphertext + metadata
Note over Gateway,S3: metadata stores wrapped DEK, iv, alg, original size
Client->>Gateway: GET /bucket/key
alt Range request
Gateway->>S3: GET optimized encrypted byte range chunked
else Full object
Gateway->>S3: GET object
end
Gateway->>Gateway: decrypt stream
Gateway-->>Client: plaintext
Compatible Backends
The gateway works with any S3-compatible storage service. Tested and compatible backends:
| Backend | Status | Notes |
|---|---|---|
| AWS S3 | Tested | Full compatibility |
| MinIO | Tested | Primary development backend |
| Hetzner Object Storage | Tested | Production use |
| Wasabi | Tested | Full compatibility |
| Ceph RGW | Compatible | S3-compatible mode |
| Cloudflare R2 | Compatible | S3-compatible API |
| DigitalOcean Spaces | Compatible | S3-compatible API |
| Backblaze B2 | Compatible | S3-compatible API |
Using a backend not listed here? Open an issue to let us know about your experience.
Security Considerations
- Key Encryption Key (KEK): Store securely using a secrets manager (Kubernetes Secrets, HashiCorp Vault, etc.). For self-contained AES KEK, generate with
openssl rand -base64 32and inject via environment variable or mounted secret file. Never commit KEKs to version control. - Encryption password (fallback / legacy only): Used only for pre-existing objects when migrating to envelope encryption. If running password-only mode, treat as the primary secret.
- Backend credentials: Use IAM roles, service accounts, or secure credential storage
- Network security: Deploy behind TLS termination or enable built-in TLS
- Access control: Restrict gateway access using network policies, firewalls, or API gateways
- Rate limiting: Enable in production to prevent abuse
Roadmap
v1.0
- AWS KMS adapter — native envelope encryption with AWS-managed keys
- HashiCorp Vault Transit — key management via Vault's Transit secrets engine
Shipped in v0.8 (current release)
- Gateway-managed credential store (V1.0-AUTH-1) — every request validated via SigV4/V2 against a gateway-local credential store;
use_client_credentialspassthrough removed; credentials configured viaauth.credentialsin config or env vars - Full security audit hardening — 23 defence-in-depth findings resolved:
context.Contextpropagation through the encryption engine, length-prefixed AAD canonicalization,keyResolveroracle removal, PBKDF2 default raised to 600 000 iterations, decompression bomb protection, presigned URL expiry cap (7 days), rate limiter map cap, admin token zeroization on shutdown, TLS config for audit HTTP sink, policy reload wired into config watcher, and more - Dedicated metrics port —
/metricsis no longer served on the public S3 port. A dedicated unauthenticated listener can be configured viametrics.addr/METRICS_ADDR; when not set, metrics fall back to the admin port (if enabled) or the S3 port (legacy). The Helm chart wires this end-to-end viametrics.portincluding Service, ServiceMonitor, PodMonitor, and NetworkPolicy (V1.0-SEC-L01) - XML injection fix —
generateListObjectsXMLnow uses properencoding/xmlmarshaling - Docker healthcheck — replaced
wgethealthcheck with a native Go binary; FIPS variant included
Shipped in v0.7
- Audit tool (
s3eg-cli) — read-only backend-envelope inspection: inspect, verify-key, list-algorithm (V1.0-CLI-2). Replaces the removeds3eg-migrate(now deprecated). For re-encryption use GET-through-gateway → PUT-through-gateway; seedocs/MIGRATION.md. - Configurable PBKDF2 iterations + per-object KDF metadata (V1.0-SEC-H03) — iteration count recorded in object metadata; mixed-iteration deployments decrypt correctly
- Large MPU streaming fixes —
ReadTimeoutset to 0 (same asWriteTimeout) to prevent timeout kills on multi-hundred-MiB downloads; active write-deadline refresh during long streams; network errors distinguished from tamper on streaming (#135) - Constant-time credential comparison — timing-safe comparison for all credential checks
- V1.0-SEC-2 / V1.0-SEC-4 / V1.0-SEC-27 / V1.0-SEC-29 — additional hardening fixes
Shipped in v0.6
- Encrypted multipart uploads — per-upload DEK, HKDF-derived per-chunk IVs, AEAD manifest, ranged GET across part boundaries, end-to-end tamper detection, Valkey state store, Helm subchart wiring (ADR 0009)
- Pluggable KeyManager interface (ADR 0004)
- FIPS-compliant crypto profile (ADR 0005,
-tags=fips) - Multipart copy (ADR 0006)
- Admin API with key-rotation state machine (ADR 0007)
- Object Lock / Retention / Legal Hold pass-through (ADR 0008)
Future
- Azure Key Vault and GCP Cloud KMS adapters
- Per-bucket encryption policies
- S3 Encryption Gateway Kubernetes Operator
- Multi-arch images with SBOM and SLSA provenance
See docs/ROADMAP.md for the complete roadmap.
Performance
Per-provider performance baselines and regression gates live in
docs/PERFORMANCE.md. The nightly
performance-baseline workflow re-runs 19 micro-benchmarks plus per-provider
soak tests (MinIO, Garage, RustFS, SeaweedFS) and fails the job on
> 15 % throughput regressions or p99 latency growth.
Test Coverage
The project enforces a ≥ 75% statement coverage gate on every PR and push to
make coverage-gate. Nightly mutation testing (Gremlins) runs on the
critical non-crypto packages. See docs/COVERAGE.md
for the exclusion policy, regeneration guide, and mutation testing scope.
Contributing
We welcome contributions! Please see docs/DEVELOPMENT_GUIDE.md for development setup and guidelines.
Areas Where We'd Love Help
- Additional KMS adapters — AWS KMS, Vault Transit, Azure Key Vault, GCP Cloud KMS
- Backend testing — testing with more S3-compatible storage providers
- Interop matrix for encrypted multipart uploads — verify AWS CLI, boto3,
aws-sdk-go-v2,minio-goall round-trip correctly at 1 MiB / 8 MiB / 100 MiB / 500 MiB payload sizes against real backends - Zero-copy streaming encrypt/decrypt — currently
UploadPartbuffers one part at a time viaio.ReadAll(V0.6-PERF-1 follow-up) - Documentation — guides, tutorials, and integration examples
- Performance benchmarks — throughput and latency measurements across providers
Getting Started
- Fork the repository
- Create a feature branch
- Make your changes
- Run tests and linter (
make test && make lint) - Submit a pull request
License
MIT License — see LICENSE file for details.
Support
- Issues: GitHub Issues
- Documentation:
docs/directory