Customer-Managed Encryption Keys: Envelope Encryption and Real Revocation Time

How envelope encryption with a customer's KMS key works, how long revocation really takes, and a lab measuring AES-GCM cost and what associated data prevents.

Some enterprise customers ask to control the key that protects their data in your service. The reasons are usually concrete: they want to be able to cut off access at the end of a contract, they want key use to appear in their own audit logs, or their policy requires it. Cloud providers call variations of this customer-managed keys (CMEK) or bring your own key (BYOK).

The standard design is envelope encryption: your service encrypts data locally with a data key, and the customer's key in a key management service (KMS) only wraps and unwraps that data key. This guide explains the design, the decisions that determine how quickly a customer's revocation actually takes effect, and two limits that are easy to miss. A lab measures local AES-256-GCM cost on real hardware and shows what binding ciphertext to its tenant and row prevents.

The two keys

  • Key-encryption key (KEK). Lives in the customer's KMS account and never leaves it. Your service is granted permission to use it for specific operations, such as generating and decrypting data keys.
  • Data-encryption key (DEK). A random symmetric key that encrypts the actual data. You store it only in wrapped (encrypted) form, next to the data it protects.

To write, you ask KMS for a new data key, receive it in plaintext and wrapped form, encrypt the data locally, store the ciphertext with the wrapped key, and discard the plaintext key. To read, you send the wrapped key to KMS, get the plaintext key back, and decrypt locally. AWS documents this flow in Generate data keys; Google Cloud describes its equivalent in Customer-managed encryption keys.

Why not call KMS for every record?

You can, and for small volumes it is the simplest design. Three things push larger systems to envelope encryption:

  • Request quotas. AWS KMS limits cryptographic requests per second per account and Region, for example a shared 10,000 per second for symmetric keys in some Regions and higher in others; see Request quotas. Every tenant's traffic counts against your account's quota.
  • Cost. AWS's pricing page lists symmetric requests at $0.03 per 10,000 in its examples. At one request per record read, a busy service pays for every read.
  • Latency. Each call is a network round trip to the KMS endpoint. We did not measure KMS latency for this guide; measure it from your own Region and network path before deciding.

Local encryption, by contrast, is cheap. The lab measures how cheap.

Lab: local encryption cost and binding ciphertext to its row

The program measures AES-256-GCM seal and open with Go's standard library for 256 bytes to 1 MiB on a single goroutine. It then encrypts a value with additional authenticated data (AAD) that names the tenant and row, copies the ciphertext to another tenant's row, and tries to open it there. Part 2 of the program measures an in-process cache lookup, which the entitlements guide discusses.

Run it with go run .. Numbers are from one run on one machine; expect different absolute values on your hardware.

// Lab: measure local costs that guides often quote without measurement.
//
// Part 1: AES-256-GCM seal/open on this machine for several payload sizes,
// and what happens when a ciphertext is moved to another tenant's record.
// Part 2: an in-process entitlement cache hit (sync.Map vs RWMutex map).
// Numbers are from one machine and one run; re-run on your own hardware.
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"fmt"
	"runtime"
	"sync"
	"testing"
)

func main() {
	fmt.Printf("%s %s/%s, GOMAXPROCS=%d\n\n", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))

	key := make([]byte, 32)
	rand.Read(key)
	block, _ := aes.NewCipher(key)
	gcm, _ := cipher.NewGCM(block)
	nonce := make([]byte, gcm.NonceSize())
	rand.Read(nonce)

	fmt.Println("Part 1: AES-256-GCM (Go standard library), single goroutine")
	fmt.Printf("%-10s %14s %14s\n", "payload", "seal ns/op", "open ns/op")
	for _, size := range []int{256, 4 << 10, 64 << 10, 1 << 20} {
		pt := make([]byte, size)
		aad := []byte("tenant:tnt_42|table:documents|column:body|row:9f1c")
		ct := gcm.Seal(nil, nonce, pt, aad)
		buf := make([]byte, 0, size+gcm.Overhead())
		seal := testing.Benchmark(func(b *testing.B) {
			b.SetBytes(int64(size))
			for i := 0; i < b.N; i++ {
				buf = gcm.Seal(buf[:0], nonce, pt, aad)
			}
		})
		open := testing.Benchmark(func(b *testing.B) {
			b.SetBytes(int64(size))
			for i := 0; i < b.N; i++ {
				if _, err := gcm.Open(buf[:0], nonce, ct, aad); err != nil {
					b.Fatal(err)
				}
			}
		})
		fmt.Printf("%-10s %14d %14d\n", label(size), seal.NsPerOp(), open.NsPerOp())
	}
	ct := gcm.Seal(nil, nonce, []byte("quarterly board deck"), []byte("tenant:tnt_42|row:9f1c"))
	_, err := gcm.Open(nil, nonce, ct, []byte("tenant:tnt_77|row:9f1c"))
	fmt.Printf("\nciphertext copied to another tenant's row, opened with that row's AAD: err = %v\n", err)
	_, err = gcm.Open(nil, nonce, ct, []byte("tenant:tnt_42|row:9f1c"))
	fmt.Printf("same ciphertext opened with its original AAD:                       err = %v\n", err)

	fmt.Println("\nPart 2: entitlement lookup that hits an in-process cache (10,000 tenants cached)")
	var sm sync.Map
	var mu sync.RWMutex
	m := map[string]map[string]string{}
	keys := make([]string, 10_000)
	for i := range keys {
		keys[i] = fmt.Sprintf("tnt_%05d", i)
		v := map[string]string{"saml_sso": "1", "max_seats": "50", "version": "17"}
		sm.Store(keys[i], v)
		m[keys[i]] = v
	}
	for _, par := range []bool{false, true} {
		name := "1 goroutine"
		if par {
			name = fmt.Sprintf("%d goroutines", runtime.GOMAXPROCS(0))
		}
		smr := testing.Benchmark(func(b *testing.B) {
			if !par {
				for i := 0; i < b.N; i++ {
					v, _ := sm.Load(keys[i%len(keys)])
					_ = v.(map[string]string)["saml_sso"]
				}
				return
			}
			b.RunParallel(func(pb *testing.PB) {
				i := 0
				for pb.Next() {
					v, _ := sm.Load(keys[i%len(keys)])
					_ = v.(map[string]string)["saml_sso"]
					i++
				}
			})
		})
		rw := testing.Benchmark(func(b *testing.B) {
			if !par {
				for i := 0; i < b.N; i++ {
					mu.RLock()
					_ = m[keys[i%len(keys)]]["saml_sso"]
					mu.RUnlock()
				}
				return
			}
			b.RunParallel(func(pb *testing.PB) {
				i := 0
				for pb.Next() {
					mu.RLock()
					_ = m[keys[i%len(keys)]]["saml_sso"]
					mu.RUnlock()
					i++
				}
			})
		})
		fmt.Printf("%-14s sync.Map: %4d ns/op   RWMutex map: %4d ns/op\n", name, smr.NsPerOp(), rw.NsPerOp())
	}
}

func label(n int) string {
	switch {
	case n >= 1<<20:
		return fmt.Sprintf("%d MiB", n>>20)
	case n >= 1<<10:
		return fmt.Sprintf("%d KiB", n>>10)
	}
	return fmt.Sprintf("%d B", n)
}

Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64, AMD Ryzen 7 4800H):

go1.26.5 windows/amd64, GOMAXPROCS=16

Part 1: AES-256-GCM (Go standard library), single goroutine
payload        seal ns/op     open ns/op
256 B                 137            121
4 KiB                 960            927
64 KiB              14199          14071
1 MiB              227978         226029

ciphertext copied to another tenant's row, opened with that row's AAD: err = cipher: message authentication failed
same ciphertext opened with its original AAD:                       err = <nil>

Part 2: entitlement lookup that hits an in-process cache (10,000 tenants cached)
1 goroutine    sync.Map:   58 ns/op   RWMutex map:   37 ns/op
16 goroutines  sync.Map:    5 ns/op   RWMutex map:   19 ns/op

What it shows

Local encryption is not the bottleneck. Sealing 4 KiB took under a microsecond; 1 MiB took about 0.23 ms. Even large documents cost far less to encrypt than a network round trip.

Associated data stops ciphertext from being moved. GCM authenticates the AAD along with the ciphertext. Opening the value with another tenant's AAD failed with message authentication failed; opening it with its own succeeded. Without AAD, someone who can write to the database could copy an encrypted value, together with its wrapped data key, from one tenant's row into another's. Your service would unwrap the key (the wrapped blob names the original tenant's KMS key, which your service is allowed to use) and show the first tenant's data to the second. Include at least the tenant ID and the record ID in the AAD, and the column name if one row has several encrypted fields. RFC 5116 defines the authenticated encryption interface this relies on.

Do the same on the KMS side

KMS has its own version of AAD, the encryption context: key-value pairs you pass when generating or decrypting a data key. Decryption fails unless the same context is supplied, and the context appears in the customer's audit logs; see Encryption context. Put the tenant ID in it. The customer can then write a key policy that allows your service to use their key only with their own tenant ID, and can see in their logs exactly which tenant's data keys were used.

How fast does revocation take effect?

A customer who disables or deletes their key expects your service to stop being able to read their data. What actually happens:

  • New KMS calls fail once the key is disabled; AWS describes the behavior of each state in Key states of AWS KMS keys.
  • Plaintext data keys already in memory keep working until your service discards them. If you cache unwrapped keys for five minutes, revocation takes up to five minutes plus the time for every process to notice.
  • Plaintext already decrypted into caches, search indexes, analytics stores, exports, or logs is not affected at all.

So the effective revocation time is the longest of: your data-key cache lifetime, the lifetime of any derived plaintext copies, and how long a running job holds keys. Write that number down, tell the customer, and test it: disable a test tenant's key and measure how long reads keep succeeding. If the answer needs to be "immediately", do not cache plaintext keys, and accept that every read depends on KMS being available.

Caching data keys

Most services cache unwrapped data keys briefly to avoid a KMS call on every read. The AWS Encryption SDK's data key caching documents the usual limits: maximum age, maximum messages, and maximum bytes per cached key. Key the cache by the wrapped key and the tenant together, never by the wrapped key alone, and clear a tenant's entries when you learn its key changed state.

A limit on reusing one data key

If a data key is reused for many records with random 96-bit GCM nonces, there is a limit. Go's documentation for cipher.NewGCM warns never to use more than 2^32 random nonces with a given key because of the risk of a repeat, and a repeated nonce under GCM is a serious break. A per-tenant data key used for every row a large tenant ever writes can approach that over years. Generate a new data key per record, per object, or per bounded batch, and record which key encrypted what.

What "the customer controls the key" does not cover

Be precise in your security documentation. Customer-managed keys protect stored data against someone who obtains the storage without the key. They do not protect against your own running service, which by design can ask KMS to unwrap keys, or against plaintext held in other systems. Some customers will ask for a design in which your service never sees plaintext; that is a different architecture (for example client-side encryption) with real product limits, such as no server-side search.

Where to start

List every place your service stores or caches plaintext for a tenant: primary database, caches, search index, analytics, backups, logs, exports. That list is the honest answer to "what happens when we revoke the key?" Then add tenant and record IDs to your AAD and your KMS encryption context, set a data-key cache lifetime you can state to customers, and run a revocation test on a staging tenant. The audit log guide covers keeping signing keys outside the application's reach, and NIST SP 800-57 covers key lifetimes in depth.

Frequently Asked Questions

Is BYOK the same as customer-managed keys?

Providers use the terms differently. Usually customer-managed means the customer controls a key inside the provider's KMS, while bring-your-own means the customer imports key material they generated. Both use the same envelope design in your service.

Should every tenant get its own KMS key?

For customers who manage their own key, yes, it lives in their account. For other tenants, one service-managed key per tenant or per group is a cost and quota trade-off; per-tenant keys make revocation and audit cleaner.

What happens to backups when a customer revokes their key?

Encrypted backups become unreadable as long as they contain only ciphertext and wrapped keys. Backups that contain plaintext, such as database dumps taken after decryption, are not affected, so check what each backup actually contains.

Does encrypting fields break search and sorting?

Yes, for the encrypted fields. Keep searchable metadata unencrypted if it is not sensitive, use a separately protected search index, or accept that those fields can only be filtered after decryption.

How do I rotate the customer's key?

KMS key rotation keeps old key versions available for decryption, so existing wrapped data keys still work. To move data onto a new key entirely, re-wrap each stored data key, which only needs KMS calls, not re-encryption of the data.

Sources

Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.

Revision history

  • — Added a reproducible benchmark and AAD lab. Replaced unmeasured latency, quota, and microsecond figures with measured or cited values, removed the description of NIST SP 800-57 as an envelope encryption standard, and added the GCM random-nonce limit and KMS encryption context.
  • — Corrected instant-revocation and crypto-shredding claims.