GCP Lesson 39 of 98

Cloud KMS in Depth: CMEK, Envelope Encryption, Cloud HSM, and External Key Manager

Every byte at rest in GCP is already encrypted with Google-managed keys you never see. So why does anyone bother with Cloud KMS? Because “encrypted by default” answers the wrong question. The question auditors, regulators, and your own incident-response team actually ask is: who can revoke access to the plaintext, and how fast? With default encryption the answer is “Google, and you have no lever.” Customer-Managed Encryption Keys (CMEK) put that lever in your hands — disable one key version and a petabyte of BigQuery becomes ciphertext nobody can read until you re-enable it. This guide builds the full picture: the key hierarchy, how CMEK actually wires into services, the envelope-encryption mechanics underneath, rotation, the Cloud HSM and EKM boundaries, and the separation-of-duties controls that stop a single admin from destroying it all.

In a nutshell

Every safe-deposit box in a bank vault is already locked, and the vault is fireproof. That is roughly what “encrypted at rest by default” means on GCP: Google locks every byte with keys it manages, and you never see them. So the honest question is not “is my data encrypted?” — it always is. The question an auditor or your incident-response lead actually asks is “who can open the box, and can I stop them on a moment’s notice?” With default encryption the bank holds the only master key. Customer-Managed Encryption Keys (CMEK) change that: you bring your own master key, the vault is required to use it, and the day you take that key home — disable it — every box welds shut until you bring it back. That revocation lever, not the presence of a lock, is the entire reason CMEK exists.

Now the clever part, because a vault does not want to lug your heavy master key to every single box. Instead it uses a cheap, disposable little key (a Data Encryption Key, or DEK) to actually lock your documents — fast, and a fresh one per box — and then locks that little key inside your master key (the Key Encryption Key, or KEK). Only the small, wrapped key rides along next to the documents; your master key never leaves the locked key room (Cloud KMS, or a hardware safe inside it). That two-layer trick is envelope encryption, and it is why encryption can be fast and unlimited in size while your one precious key stays put.

The rest of the lesson is about where that key room is (a software cabinet, a tamper-proof Cloud HSM, or a safe in a bank you own entirely — External Key Manager), how you hand the vault permission to use your key (a per-service robot called a service agent gets exactly one role on the key), and how you make sure no single employee can both weld the boxes shut and read what is inside. Get those three right and CMEK becomes a precise, auditable kill-switch over petabytes of data. Get them wrong and you have either a false sense of control or a self-inflicted outage.

Level: Expert, with a beginner on-ramp · Time: ~30 min

How CMEK protects a resource with envelope encryption

Read the diagram left to right: a service agent (1) is granted the encrypt/decrypt role on your key; the key lives in a permanent regional key ring and rotates versions (2); its material sits behind a software, HSM, or external boundary (3); at write time your KEK wraps a fast local DEK (4) which encrypts the bytes; the resource stores ciphertext plus the wrapped DEK (5); and disabling the key version makes all of it unreadable in one reversible command (6).

Prerequisites & what you’ll be able to do

Before this lesson you should be comfortable with IAM members, roles, and bindings and with the idea of a service account — grants are the load-bearing part of CMEK, so a solid grip on IAM roles, policies, and conditions pays off immediately. It helps to have met Cloud KMS at an introductory level in Cloud KMS & Secret Manager fundamentals, and to know that services like GCS and BigQuery already encrypt at rest by default. You do not need a GCP account open to follow along — every gcloud command below is real and current, project IDs and key paths are placeholders, and any representative output is labelled as such.

After this lesson you can:

Encryption models at a glance: default vs CMEK vs CSEK vs EKM

Before the deep dives, here is the whole decision on one page. Everything at rest in GCP is encrypted; these rows differ only in who holds the key and what lever that gives you. Read it as “requirement → model,” because that is how it comes up in a design review or an audit.

Model Who holds the key material Your revocation / rotation lever Uses Cloud KMS? Reach for it when…
Google-default Google None No There is no compliance requirement to control keys — the sane default
CMEK You, in Cloud KMS You disable / rotate / destroy the key Yes You must be able to prove you can revoke access to the plaintext
CSEK (customer-supplied) You, off-platform; supplied on every request You, by withholding the key No (KMS stores nothing) You refuse to let Google store the key at all (niche; GCE/GCS only)
CMEK + Cloud HSM You, inside a FIPS 140-2 L3 HSM You (same as CMEK) Yes (HSM) A regulator requires hardware-backed keys and attestation
CMEK + EKM You / a third party, outside Google You, by pulling the external key Yes (KMS proxies) Hold-your-own-key: material must never reside in Google

Two lines to internalise, because interviews and auditors probe exactly these:

Where the key material physically sits — and the FIPS boundary around it — is the protection level, set per key at creation:

Protection level --protection-level Material lives FIPS Locations Note
Software software Google KMS, in software 140-2 L1 regional · multi-region · global Default, cheapest, highest quotas
Cloud HSM hsm Google-operated HSM hardware 140-2 L3 regional only Never leaves hardware; attestation available
External external Your external KMS, reached over HTTPS per external manager regional KMS holds only a key URI reference
External VPC external via ekmConnection Your external KMS, reached over your VPC per external manager regional No public egress from the data plane

1. KMS concepts: key rings, keys, versions, protection levels

Cloud KMS has a four-level hierarchy, and getting the vocabulary exact saves you from IAM and rotation mistakes later:

Create a ring and a software key:

PROJECT=sec-kms-prod
LOCATION=us-central1

gcloud kms keyrings create app-keyring \
  --project="$PROJECT" --location="$LOCATION"

gcloud kms keys create gcs-cmek \
  --project="$PROJECT" --location="$LOCATION" \
  --keyring=app-keyring \
  --purpose=encryption \
  --protection-level=software \
  --rotation-period=90d \
  --next-rotation-time="$(date -u -v+90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)"

The resource name you will paste everywhere is the fully-qualified key path: projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY. CMEK bindings reference the key, not a version — KMS always encrypts with the current primary and can decrypt with any enabled version.

2. Wiring CMEK into GCS, BigQuery, Cloud SQL, and Persistent Disk

The mechanism is consistent across services: each service runs a service agent (a Google-managed service account in your project), and you grant that agent the roles/cloudkms.cryptoKeyEncrypterDecrypter role on the key. The service agent — not your user identity — calls KMS at write and read time. Get the IAM grant wrong and resource creation fails with a permission error on the agent, which trips people up because the error is about an identity they did not create.

Cloud Storage. The Storage service agent is service-PROJECTNUMBER@gs-project-accounts.iam.gserviceaccount.com:

PROJECT_NUMBER=$(gcloud projects describe "$PROJECT" --format='value(projectNumber)')
KEY=projects/$PROJECT/locations/$LOCATION/keyRings/app-keyring/cryptoKeys/gcs-cmek

# Force the agent to exist, then grant it
gcloud storage service-agent --project="$PROJECT"

gcloud kms keys add-iam-policy-binding gcs-cmek \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --member="serviceAccount:service-${PROJECT_NUMBER}@gs-project-accounts.iam.gserviceaccount.com" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"

# Set a default CMEK on the bucket: every new object is wrapped with it
gcloud storage buckets update gs://my-cmek-bucket --default-encryption-key="$KEY"

BigQuery. Grant the BigQuery service agent, then set a default key on the dataset (and/or per-table). The agent is bq-PROJECTNUMBER@bigquery-encryption.iam.gserviceaccount.com:

gcloud kms keys add-iam-policy-binding bq-cmek \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --member="serviceAccount:bq-${PROJECT_NUMBER}@bigquery-encryption.iam.gserviceaccount.com" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"

bq update --default_kms_key="$KEY" "$PROJECT:analytics_ds"

Cloud SQL. The Cloud SQL service agent gets the grant, and the key is set at instance creation — you cannot retrofit CMEK onto an existing instance, you must create a new one (typically restore from backup into a CMEK instance):

SQL_SA="service-${PROJECT_NUMBER}@gcp-sa-cloud-sql.iam.gserviceaccount.com"
gcloud kms keys add-iam-policy-binding sql-cmek \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --member="serviceAccount:${SQL_SA}" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"

gcloud sql instances create pg-cmek \
  --project="$PROJECT" --region="$LOCATION" \
  --database-version=POSTGRES_16 --edition=ENTERPRISE \
  --tier=db-custom-2-8192 \
  --disk-encryption-key="projects/$PROJECT/locations/$LOCATION/keyRings/app-keyring/cryptoKeys/sql-cmek"

Persistent Disk / Compute. The Compute Engine service agent is service-PROJECTNUMBER@compute-system.iam.gserviceaccount.com; the disk takes the key at create time:

gcloud compute disks create data-disk \
  --project="$PROJECT" --zone="${LOCATION}-a" --size=200 \
  --kms-key="projects/$PROJECT/locations/$LOCATION/keyRings/app-keyring/cryptoKeys/disk-cmek"

The Terraform shape for the binding is identical regardless of service — grant the agent, then reference the key:

resource "google_kms_crypto_key_iam_member" "gcs_agent" {
  crypto_key_id = google_kms_crypto_key.gcs_cmek.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:service-${data.google_project.p.number}@gs-project-accounts.iam.gserviceaccount.com"
}

resource "google_storage_bucket" "data" {
  name                        = "my-cmek-bucket"
  location                    = "US"
  uniform_bucket_level_access = true
  encryption {
    default_kms_key_name = google_kms_crypto_key.gcs_cmek.id
  }
  depends_on = [google_kms_crypto_key_iam_member.gcs_agent]
}

That depends_on matters: without the IAM binding in place first, bucket creation with CMEK races and fails.

3. Envelope encryption: DEKs, KEKs, and the encrypt/decrypt flow

CMEK at the service layer hides a pattern you should implement yourself whenever you encrypt application payloads, because calling KMS to encrypt every record directly is slow, rate-limited, and size-capped (the encrypt API tops out at 64 KiB of plaintext). The pattern is envelope encryption:

  1. Generate a random Data Encryption Key (DEK) locally — a 256-bit AES key.
  2. Encrypt your data with the DEK locally (fast, unlimited size, your own AES-GCM).
  3. Call KMS to encrypt (wrap) the DEK with a Key Encryption Key (KEK) that never leaves KMS.
  4. Store the wrapped DEK next to the ciphertext. Discard the plaintext DEK from memory.

To decrypt: read the wrapped DEK, call KMS decrypt to unwrap it, decrypt the data locally, drop the DEK again. KMS only ever sees the tiny DEK, never your data.

import os
from google.cloud import kms
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

client = kms.KeyManagementServiceClient()
KEK = "projects/sec-kms-prod/locations/us-central1/keyRings/app-keyring/cryptoKeys/app-kek"

def encrypt(plaintext: bytes, aad: bytes = b"") -> dict:
    dek = AESGCM.generate_key(bit_length=256)          # 1. local DEK
    nonce = os.urandom(12)
    ciphertext = AESGCM(dek).encrypt(nonce, plaintext, aad)  # 2. local encrypt
    wrapped = client.encrypt(                            # 3. wrap DEK in KMS
        request={"name": KEK, "plaintext": dek,
                 "additional_authenticated_data": aad}
    ).ciphertext
    return {"wrapped_dek": wrapped, "nonce": nonce, "ciphertext": ciphertext}

def decrypt(blob: dict, aad: bytes = b"") -> bytes:
    dek = client.decrypt(                                # unwrap DEK in KMS
        request={"name": KEK, "ciphertext": blob["wrapped_dek"],
                 "additional_authenticated_data": aad}
    ).plaintext
    return AESGCM(dek).decrypt(blob["nonce"], blob["ciphertext"], aad)

Two production notes. First, pass Additional Authenticated Data (AAD) — the same value must be supplied on encrypt and decrypt, binding the wrapped DEK to a context (e.g. a tenant ID), so a stolen ciphertext cannot be unwrapped against the wrong record. Second, for hot paths, don’t reach for raw KMS — use Tink, Google’s crypto library, with a KMS-backed KEK. Tink does envelope encryption correctly, caches nothing dangerous, and removes the foot-guns of hand-rolling nonces.

4. Rotation: automatic, manual, and re-encryption reality

Set --rotation-period and KMS automatically generates a new primary version on schedule. This is cheap because it does not re-encrypt anything. New writes use the new primary; existing ciphertext stays wrapped under whichever version created it, and old versions remain enabled for decrypt. Rotation limits the blast radius of a single version’s compromise and satisfies “keys must rotate every N days” controls — it does not, by itself, re-protect old data.

# Inspect and force a manual rotation
gcloud kms keys versions list --location="$LOCATION" \
  --keyring=app-keyring --key=gcs-cmek

gcloud kms keys versions create --location="$LOCATION" \
  --keyring=app-keyring --key=gcs-cmek --primary   # new primary now

# Adjust the schedule
gcloud kms keys update gcs-cmek --location="$LOCATION" \
  --keyring=app-keyring --rotation-period=30d \
  --next-rotation-time="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"

If a control genuinely requires that old data be re-wrapped under the new version (true key compromise, or a hard “no data older than the current key” mandate), you must actively re-encrypt:

Do not destroy old versions just because you rotated. Any object still wrapped under version 3 becomes permanently unreadable the moment version 3 is destroyed. Disable, observe crypto_key_version usage in logs for your full retention window, then schedule destruction.

5. Cloud HSM and the FIPS 140-2 Level 3 boundary

Software-protected keys are FIPS 140-2 Level 1. Many regulated workloads require Level 3 — tamper-evident, tamper-responsive hardware with identity-based authentication. Cloud HSM gives you exactly that: keys with --protection-level=hsm are generated and used inside Google-operated, FIPS 140-2 Level 3 validated HSMs, and the private material provably never leaves the hardware in plaintext. The API surface is identical to software keys — same encrypt/decrypt, same CMEK wiring — only the protection level and (modestly higher) price change.

gcloud kms keys create payments-hsm \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --purpose=encryption --protection-level=hsm --rotation-period=90d \
  --next-rotation-time="$(date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)"

Cloud HSM also supports attestation: each version can return a signed statement from the HSM proving the key was created in genuine Google HSM hardware, which auditors increasingly ask for. Two constraints to plan around: HSM keys are regional only (no global/multi-region HSM key rings — pick the region deliberately), and HSM has its own cryptographic-operation quotas, so a high-QPS envelope workload should cache unwrapped DEKs rather than calling the HSM per request.

# Retrieve the signed attestation for an HSM key version
gcloud kms keys versions describe 1 \
  --location="$LOCATION" --keyring=app-keyring --key=payments-hsm \
  --attestation-file=attestation.dat

6. External Key Manager (EKM) and EKM via VPC for hold-your-own-key

For “hold-your-own-key” / key-externalization mandates — where the organization (or its regulator) insists the key material live outside Google entirely, in a third-party manager like Fortanix, Thales, or Equinix SmartKey — use External Key Manager. With --protection-level=external, the key material stays in your external HSM/KMS; Cloud KMS holds only a reference (a key URI) and proxies crypto operations out to it. Pull your key from the external manager and Google instantly loses the ability to decrypt: that is the entire value proposition, and the entire risk (your external manager is now a hard availability dependency for your data plane).

gcloud kms keys create ekm-key \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --purpose=encryption --protection-level=external \
  --skip-initial-version-creation

gcloud kms keys versions create \
  --location="$LOCATION" --keyring=app-keyring --key=ekm-key \
  --external-key-uri="https://my-ekm.example.com/v0/keys/abc-123" \
  --primary

The original EKM reached the external manager over the public internet via HTTPS, which many security teams will not accept. EKM via VPC removes that: Cloud KMS connects to your external manager over a private path through your VPC (no public exposure of the external endpoint). You first create an ekmConnection pointing at a service-attachment or hostname reachable in your VPC, then bind key versions to it:

gcloud kms ekm-connections create ekm-vpc-conn \
  --project="$PROJECT" --location="$LOCATION" \
  --service-resolvers-from-file=resolvers.yaml

gcloud kms keys versions create \
  --location="$LOCATION" --keyring=app-keyring --key=ekm-key \
  --ekm-connection-key-path="/keys/abc-123" --primary

Latency and availability are real here. Every CMEK read on an EKM-backed resource is a network round-trip to your external manager. Size its HA accordingly, and prefer EKM for the keys that must be externalized (the crown-jewels dataset), not blanket across every bucket.

7. IAM separation of duties and key destruction safeguards

The whole point of CMEK collapses if one person can both destroy the key and read the data. Enforce three distinct roles, granted at the key ring level, never bundling them on one identity:

Role Predefined role Can do Must NOT also have
Key admin roles/cloudkms.admin create keys, set rotation, schedule destruction data-reader access to protected resources
Crypto operator (services) roles/cloudkms.cryptoKeyEncrypterDecrypter encrypt/decrypt (the service agents) admin / destroy
Auditor roles/cloudkms.viewer read key metadata, no crypto, no admin any write
# Key admins (a small group) — admin only, no decrypt
gcloud kms keyrings add-iam-policy-binding app-keyring \
  --project="$PROJECT" --location="$LOCATION" \
  --member="group:kms-admins@example.com" \
  --role="roles/cloudkms.admin"

Two safeguards stop accidental or malicious destruction:

Destruction is a scheduled, reversible delay. Destroying a version moves it to DESTROY_SCHEDULED for a configurable period (default 24 hours, settable up to 120 days at key-ring creation) before the material is actually gone. During that window you can restore it. Set this window deliberately — 24 hours is too short to catch a bad change over a long weekend:

gcloud kms keyrings create app-keyring \
  --project="$PROJECT" --location="$LOCATION"
# destroyScheduledDuration is set per-key at creation, e.g. 30 days:
gcloud kms keys create critical-cmek \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --purpose=encryption --destroy-scheduled-duration=2592000s

# If someone schedules a destroy in error, restore within the window:
gcloud kms keys versions restore 5 \
  --location="$LOCATION" --keyring=app-keyring --key=critical-cmek

An Org Policy can forbid destruction tooling-wide as a backstop, and Cloud KMS Autokey (where available) can centralize key creation so app teams never hold cloudkms.admin at all.

8. Auditing key usage and handling disabled-key incidents

Cloud KMS data-access audit logs are not on by default — and without them you are blind to who decrypted what. Turn them on, then alert on the events that matter.

# In the project/org IAM policy auditConfigs, enable KMS data-access logs
auditConfigs:
- service: cloudkms.googleapis.com
  auditLogConfigs:
  - logType: DATA_READ
  - logType: DATA_WRITE
  - logType: ADMIN_READ

Every crypto op then lands in Cloud Logging, tagged with the exact key version used:

resource.type="cloudkms_crypto_key"
protoPayload.serviceName="cloudkms.googleapis.com"
protoPayload.methodName="Decrypt"
protoPayload.resourceName=~"cryptoKeys/payments-hsm"

The single highest-value alert is on administrative state changes — disable and destroy:

resource.type="cloudkms_crypto_key_version"
protoPayload.methodName=("DestroyCryptoKeyVersion" OR
  "UpdateCryptoKeyVersion")

Handling a disabled-key incident. When a key version flips to DISABLED, every dependent resource starts failing reads — GCS returns 403s on objects wrapped by that version, BigQuery queries error, a Cloud SQL or Compute instance whose disk key is disabled will eventually fail to start. The recovery is fast precisely because disable is reversible:

# Triage: which version, what state, who touched it
gcloud kms keys versions list --location="$LOCATION" \
  --keyring=app-keyring --key=payments-hsm \
  --format='table(name.scope(cryptoKeyVersions), state)'

# Re-enable to restore access immediately
gcloud kms keys versions enable 4 \
  --location="$LOCATION" --keyring=app-keyring --key=payments-hsm

Disabling a key is the fastest “logical shred” you have — flip it and the data is unreadable everywhere instantly, without touching the data. That makes it a deliberate incident-response tool (kill access to a breached dataset in one command) and a self-inflicted outage waiting to happen. Alert on it, document who is allowed to do it, and never wire it into automation that can fire by accident.

Enterprise scenario

A payments platform team running a regulated tokenization service had a contractual hold-your-own-key requirement: their bank partner mandated that the bank, not Google, control the key protecting the cardholder dataset, and that key material never reside in Google’s infrastructure. The naive read was “use EKM” — but their first EKM design reached the external Fortanix cluster over the public internet, and their own VPC Service Controls perimeter plus the partner’s security review both rejected any public egress from the data plane.

The fix was EKM via VPC combined with strict separation of duties. They stood up an ekmConnection so Cloud KMS reached the external manager privately through their Shared VPC host project — no public endpoint, all traffic inside the perimeter. The cardholder BigQuery dataset and the GCS bucket holding raw card files were bound to the EKM-backed key; the bank held the actual material in Fortanix and could revoke it unilaterally. Crucially, no human at the platform team held both cloudkms.admin and BigQuery data-reader on that dataset, and a destroy-scheduled duration of 30 days plus an alert on every Disable/Destroy event meant an accidental or malicious key kill could be caught and restored long before material was lost.

# The load-bearing binding: dataset CMEK pinned to the EKM-via-VPC key,
# whose material the bank controls externally.
bq update --default_kms_key=\
"projects/pay-prod/locations/us-central1/keyRings/pci-ring/cryptoKeys/ekm-card-key" \
  pay-prod:cardholder_ds

The result satisfied the audit: the bank could prove sole control of the key, GCP never saw the material, the path was private, and a single disabled key version became the documented, alarmed kill-switch for the entire cardholder dataset rather than a silent outage.

Verify

Going deeper

Everything above builds a correct, defensible CMEK setup. This section is for the person who has to defend it in an audit, size it under load, or keep it alive at 2 a.m. — the internals and edge cases where the tidy mental model quietly breaks.

The version state machine, and why “destroy” has a delay

A CryptoKeyVersion is a small state machine, and every operational decision hangs off which state a version is in. The steady-state path is ENABLED → DISABLED → DESTROY_SCHEDULED → DESTROYED, plus a few creation-time states you meet with HSM/external/import keys: PENDING_GENERATION (async key generation for HSM and external), PENDING_IMPORT / IMPORT_FAILED (bring-your-own-key), and PENDING_EXTERNAL_DESTRUCTION for EKM.

gcloud kms keys versions describe 4 \
  --location="$LOCATION" --keyring=app-keyring --key=payments-hsm \
  --format='yaml(state, generateTime, destroyTime, protectionLevel)'

Two properties follow from the machine and matter enormously in production. ENABLED ⇄ DISABLED is instant and reversible — that is why disable is your kill-switch and your recovery is a single enable. DESTROY_SCHEDULED is the one and only place you can still restore from. Once a version reaches DESTROYED, anything wrapped under it is unrecoverable, full stop. The scheduled delay is not bureaucracy: it is the platform giving you a window to notice a mistake before an irreversible, blast-radius-of-a-petabyte operation completes. Set --destroy-scheduled-duration to survive a long weekend (30–90 days on anything that matters), never the 24-hour default.

KMS quotas, rate limits, and why hot paths cache DEKs

Cloud KMS enforces per-project, per-location quotas on cryptographic operations (separate read and write QPS ceilings), and those ceilings step down as the boundary hardens: software is highest, HSM lower, EXTERNAL/EKM lower still and additionally gated by your external manager’s own throughput. Every raw client.encrypt/decrypt is a network round-trip that counts against the quota and adds latency; an EKM operation round-trips all the way to your external HSM. A service that naively calls KMS per record will hit the quota wall and the latency wall together, under exactly the load you least want it to.

This is the deeper reason envelope encryption exists: you call KMS once to wrap/unwrap the DEK, then do thousands of local AES-GCM operations with that DEK without touching KMS again. On a hot path, cache the unwrapped DEK in memory with a bounded lifetime (and re-wrap periodically) so you amortise one KMS call across many records. Do not hand-roll that cache — Tink’s KMS-envelope AEAD does exactly this, safely. If you must raise a genuine per-op ceiling, request a quota increase, but treat that as the last lever, not the first.

Bring-your-own-key without EKM: key import

EKM keeps material outside Google. Its sibling — key import — does the opposite: it puts your own material into Cloud KMS so KMS manages it thereafter. You create an import job (which mints an RSA wrapping key), wrap your raw key to the import job’s public key client-side, and import it as a new version. Purpose and protection level must match the target key, and the target key is created with --skip-initial-version-creation so the first version is the imported one.

# 1. An import job establishes the RSA-OAEP wrapping key
gcloud kms import-jobs create byok-import \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --import-method=rsa-oaep-3072-sha256-aes-256 \
  --protection-level=hsm

# 2. Wrap your raw key to that job's public key (offline), then import it
gcloud kms keys versions import \
  --project="$PROJECT" --location="$LOCATION" --keyring=app-keyring \
  --key=byok-key --import-job=byok-import \
  --algorithm=google-symmetric-encryption \
  --target-key-file=wrapped-key.bin

Use import when a compliance regime says “we generate the key material” but is content for Google to hold it afterward; use EKM when the regime says the material may never reside in Google at all. They are different answers to different sentences in the same audit.

Enforcing CMEK org-wide, and Autokey

CMEK you set by hand is CMEK someone forgets. Two org-policy list constraints make it mandatory: constraints/gcp.restrictNonCmekServices lists the services that must refuse to create resources without CMEK, and constraints/gcp.restrictCmekCryptoKeyProjects restricts which projects’ keys are acceptable (so a team cannot satisfy the first constraint with a key from an unmanaged project).

# policy.yaml — listed services refuse resource creation without CMEK
name: organizations/ORG_ID/policies/gcp.restrictNonCmekServices
spec:
  rules:
  - values:
      allowedValues:
      - bigquery.googleapis.com
      - storage.googleapis.com
      - sqladmin.googleapis.com
gcloud org-policies set-policy policy.yaml

The remaining friction is that CMEK-everywhere means app teams need keys, and you do not want them all holding cloudkms.admin. Cloud KMS Autokey (where available) resolves that: a team requests a resource with a lightweight key handle, and Autokey provisions a compliant, correctly-located CMEK key on their behalf from a central key project — so the guardrail is satisfied without spreading key-admin power.

EKM connection state and the data-plane availability math

An EKM-backed key turns your external manager into a synchronous dependency of every read of the protected data. That is a different risk shape from software CMEK, and the math is unforgiving: your effective data availability is min(GCP, your external manager, the network path between them). A three-nines external HSM silently caps a four-nines dataset at three nines.

# Confirm the connection before you trust the boundary
gcloud kms ekm-connections describe ekm-vpc-conn \
  --project="$PROJECT" --location="$LOCATION" \
  --format='yaml(serviceResolvers, cryptoSpacePath, keyManagementMode)'

Design consequences experienced teams bake in: run the external manager multi-node and, ideally, multi-region; keep the EKM-via-VPC path (the ekmConnection and its service resolvers) as carefully monitored as any production dependency; and — the judgement call — reserve EKM for the crown-jewels keys whose externalisation is a hard mandate, leaving everything else on software or HSM CMEK. Blanket EKM buys you a fragile data plane for buckets nobody asked to externalise.

Key location compatibility, and the multi-region trap

A CMEK key must live in a location compatible with the resource it protects. A regional resource wants a key in the same region. A multi-region resource — a US or EU multi-region GCS bucket, a multi-region BigQuery dataset — wants a key in that same multi-region. Here is the trap: HSM and EKM keys are regional-only. There is no US-multi-region HSM key, so you cannot protect a US multi-region bucket or dataset with an HSM key — you must either pin the resource to a single region (and use a regional HSM key) or accept software protection for the multi-region case. Because the key ring’s location is permanent, this is a create-time decision you cannot walk back; sketch the resource-to-key location map before you create the first ring.

Cost model and the crypto-shred reality

Cloud KMS bills on two axes: active key versions per month and cryptographic operations (per block of operations). HSM versions and operations cost more than software; EXTERNAL/EKM more again. Two implications: first, rotation multiplies active versions — a 30-day rotation left running for three years accumulates ~36 billable versions per key, so disable (and eventually, after the observation window, destroy) versions that no longer wrap any live data. Second, and more strategic, destroying the last version that wraps a dataset is a legitimate deletion mechanism: “crypto-shredding” satisfies a GDPR/CCPA erasure obligation for data you cannot otherwise reach (immutable backups, WORM archives) by making it mathematically unrecoverable. It is a powerful tool and a loaded gun pointed at your own data — which is exactly why the destroy delay, separation of duties, and disable/destroy alerting in sections 7 and 8 are not optional.

Practice challenges

Work these in order, on a throwaway project — several touch irreversible operations (version destruction, locked settings) you never want to rehearse on real keys. Each expands to a solution with exact commands and a one-line “why.”

Challenge 1 — Build the hierarchy (beginner). Create a key ring and a software ENCRYPT_DECRYPT key with a 90-day rotation period, then print the fully-qualified key path and confirm a primary version exists.

<details> <summary>Solution</summary>

gcloud kms keyrings create lab-ring --location=us-central1
gcloud kms keys create lab-key --location=us-central1 --keyring=lab-ring \
  --purpose=encryption --protection-level=software --rotation-period=90d \
  --next-rotation-time="$(date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)"
gcloud kms keys describe lab-key --location=us-central1 --keyring=lab-ring \
  --format='value(name)'
gcloud kms keys versions list --location=us-central1 --keyring=lab-ring --key=lab-key

Why: it fixes the vocabulary — ring → key → version — and shows that a --purpose=encryption key auto-creates version 1 as the primary, the thing CMEK binds to. </details>

Challenge 2 — Wire CMEK into a bucket (beginner → intermediate). Grant the GCS service agent cryptoKeyEncrypterDecrypter on your lab key, set it as a bucket’s default key, and prove it is applied with describe.

<details> <summary>Solution</summary>

PN=$(gcloud projects describe "$(gcloud config get-value project)" --format='value(projectNumber)')
gcloud storage service-agent   # force the agent to exist
gcloud kms keys add-iam-policy-binding lab-key --location=us-central1 --keyring=lab-ring \
  --member="serviceAccount:service-${PN}@gs-project-accounts.iam.gserviceaccount.com" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"
gcloud storage buckets update gs://my-lab-bucket \
  --default-encryption-key="projects/$(gcloud config get-value project)/locations/us-central1/keyRings/lab-ring/cryptoKeys/lab-key"
gcloud storage buckets describe gs://my-lab-bucket \
  --format='value(encryption.defaultKmsKeyName)'   # must echo the key path

Why: the grant is on the service agent, not you — and a config that is not confirmed by describe is a config you are only assuming works. </details>

Challenge 3 — Envelope encryption with AAD (intermediate). Using the lesson’s encrypt/decrypt functions, encrypt a payload with aad=b"tenant-42", decrypt it correctly, then prove that decrypting with aad=b"tenant-99" fails.

<details> <summary>Solution</summary>

blob = encrypt(b"card:4111-1111-1111-1111", aad=b"tenant-42")
assert decrypt(blob, aad=b"tenant-42") == b"card:4111-1111-1111-1111"
try:
    decrypt(blob, aad=b"tenant-99")            # wrong context
    print("BUG: should not reach here")
except Exception as e:
    print("correctly rejected:", type(e).__name__)   # InvalidTag / KMS error

Why: AAD binds the wrapped DEK to a context, so a stolen ciphertext cannot be replayed against a different record — a mismatch fails the authentication tag rather than returning wrong plaintext. </details>

Challenge 4 — Exercise the kill-switch (intermediate → advanced). On a test object encrypted with your lab key, disable the primary version, confirm the read fails, then re-enable and confirm recovery.

<details> <summary>Solution</summary>

gcloud kms keys versions disable 1 --location=us-central1 --keyring=lab-ring --key=lab-key
gcloud storage cp gs://my-lab-bucket/secret.txt .    # expect a 403 / key-disabled error
gcloud kms keys versions enable 1  --location=us-central1 --keyring=lab-ring --key=lab-key
gcloud storage cp gs://my-lab-bucket/secret.txt .    # now succeeds

Why: if disabling the key does not break the read, the resource is not actually using that key — this is the one test that proves CMEK is real, and it demonstrates disable is an instant, reversible logical shred. </details>

Challenge 5 — Hardware boundary and its constraint (advanced). Create an HSM key, pull its attestation to a file, and explain in one line why you could not have made it a global key.

<details> <summary>Solution</summary>

gcloud kms keys create hsm-key --location=us-central1 --keyring=lab-ring \
  --purpose=encryption --protection-level=hsm
gcloud kms keys versions describe 1 --location=us-central1 --keyring=lab-ring \
  --key=hsm-key --attestation-file=att.dat
# global fails: HSM keys are regional-only.
gcloud kms keyrings create hsm-global --location=global   # a ring is fine...
gcloud kms keys create x --location=global --keyring=hsm-global \
  --purpose=encryption --protection-level=hsm            # ...but this is rejected

Why: Cloud HSM material is bound to regional hardware, so there is no global/multi-region HSM key — attestation is the signed proof the key was born in genuine Google HSM hardware, which auditors ask for. </details>

Challenge 6 — Hold-your-own-key with separation of duties (senior stretch). Design (commands + reasoning) an EKM-via-VPC key for a cardholder dataset: private connection, a 30-day destroy window, admin and data-reader on different identities, and an alert on disable/destroy. State the availability trade-off you accepted.

<details> <summary>Solution</summary>

# Private path to the external manager (no public egress)
gcloud kms ekm-connections create card-ekm --location=us-central1 \
  --service-resolvers-from-file=resolvers.yaml
gcloud kms keys create card-key --location=us-central1 --keyring=lab-ring \
  --purpose=encryption --protection-level=external \
  --destroy-scheduled-duration=2592000s --skip-initial-version-creation
gcloud kms keys versions create --location=us-central1 --keyring=lab-ring \
  --key=card-key --ekm-connection-key-path="/keys/card-123" --primary

# SoD: admins get admin only; the BigQuery service agent gets crypto only; no overlap.
gcloud kms keyrings add-iam-policy-binding lab-ring --location=us-central1 \
  --member="group:kms-admins@example.com" --role="roles/cloudkms.admin"
# Alert (log-based metric) on the two events that lose data:
#   methodName = DestroyCryptoKeyVersion OR UpdateCryptoKeyVersion (disable)

Why: the bank holds the material and can revoke unilaterally (the mandate), the private path keeps the data plane off the public internet, and the 30-day window plus alerting make an accidental kill recoverable — the trade-off accepted is that the external manager is now a synchronous availability dependency for every read, so it must be run HA. </details>

Common beginner mistakes

Glossary

Checklist

gcpcloud-kmscmekencryptionsecurity
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments