Encryption and Key Management for Spatial Data

Geospatial estates accumulate encryption boundaries faster than almost any other workload, because the same dataset is repeatedly copied into different shapes: authoritative geometry in PostGIS, derived Cloud Optimized GeoTIFFs in object storage, generated tile pyramids in a cache, snapshots in a backup vault, and extracts in a data lake. Each copy is a separate place where a key decision is made, and a platform that encrypts the database while leaving the derived tiles on a default-managed key has not protected the data — it has protected one of six copies of it. This topic sits within Network Security and Access Control and completes the picture that IAM Role Mapping for GIS starts: identity governs who may call the service, and key policy governs whether the ciphertext they retrieve is legible.

There is also a specifically spatial reason to take key management seriously rather than accepting defaults. Location data is frequently regulated on its own terms — cadastral records, utility networks, protected-species observations, and any dataset that resolves to an individual dwelling — and the regulatory question is rarely “was it encrypted” but “who held the key, and can you prove when they used it”. A customer-managed key with an auditable grant list answers that question. A provider-default key does not, because it has no policy of your own to show and no key-usage trail attributable to your principals.

Environment parity and configuration drift mitigation

The parity failure that matters here is asymmetric and quiet: it is normal for production to be encrypted with a customer-managed key while development runs on the default key, and normal for nobody to notice, because encryption is invisible when it works. The consequence surfaces later, during a restore drill or a region migration, when a snapshot encrypted under a key that does not exist in the target account simply cannot be read. Parity in encryption configuration is not aesthetic tidiness; it is the difference between a recoverable estate and an unrecoverable one.

Express the key as a module input in every environment, never as a default that falls back to the provider-managed key when unset. A variable with no default forces each environment to name its key explicitly, which converts a silent omission into a plan-time error. The keys themselves differ per environment — that is correct, since a development key should not decrypt production ciphertext — but the presence of an explicit key must be uniform.

Rotation schedules are the second drift vector. Automatic annual rotation on a symmetric key is transparent to readers, because the provider retains previous key material and selects the right version by the ciphertext’s key ID. Manual rotation, where a new key is created and the old one retired, is not transparent at all: every object encrypted under the old key remains readable only while that key is enabled, and a cleanup that disables it makes historical raster archives unreadable. Encode which model a key uses as a tag on the key itself, so an operator deciding whether a key is safe to disable is reading a fact rather than reconstructing a history.

Encryption boundaries across a spatial platform A customer-managed key sits at the centre. Four separate encryption boundaries reference it: the PostGIS cluster together with its automated snapshots, the object store holding Cloud Optimized GeoTIFFs, the generated tile cache, and the long-term backup vault. Each boundary is drawn as a distinct box because each makes its own key decision, and a platform that encrypts only the database has protected one copy of the data out of four. Every use of the key from any boundary is written to a single audit trail. Customer-managed key policy · grants · rotation PostGIS cluster storage + snapshots Raster object store COG archives Tile cache generated pyramids Backup vault long-term retention Single key-usage audit trail who decrypted what, and when Every copy of the data is its own boundary — encrypting the database alone protects one copy out of four.

Cross-account and cross-region access is where drift becomes visible fastest. A raster archive shared with a partner account is unreadable unless the key policy grants that account decrypt rights, and a bucket replicated to a second region is unreadable there unless a key exists in the destination region and the replication configuration names it. Both are configuration that lives beside the resource, not inside it, and both are routinely forgotten in the module that creates the replica. Assert them the same way you assert the key itself: as required inputs.

CI/CD validation and operational guardrails

Encryption is exceptionally well suited to policy-as-code because the properties worth enforcing are structural and appear directly in the plan. Four rules cover most of the risk. Every storage resource that can carry a key must name a customer-managed one. No key policy may grant kms:* to a wildcard principal. Every bucket holding spatial data must deny unencrypted transport. And no key scheduled for deletion may still be referenced by a live resource in the same plan — the check that turns an unrecoverable archive into a failed build.

These belong in the same pull-request gate that runs the rest of the estate’s rules, described in Policy as Code for Spatial Resources. The reason to run them on the plan rather than on the deployed account is timing: a key policy that grants too much is a finding when discovered by a scanner an hour after apply, and a non-event when it fails the plan before merge.

The guardrail that teams most often skip is the restore drill, and it is the only one that proves the whole chain works. Encryption failures are silent by construction until you try to read the ciphertext, which means an untested backup encrypted with a key whose grants were revoked is indistinguishable from a working one right up to the moment you need it. Schedule an automated restore of an encrypted snapshot into a scratch environment, run a spatial query against the restored cluster, and fail the job if the restore or the query fails. That single job validates key existence, key policy, grant propagation and backup integrity at once.

Scheduled restore drill for encrypted spatial backups A scheduled job selects the most recent encrypted snapshot and restores it into a scratch environment using the customer-managed key. If the restore fails, the cause is key access — the key is missing, disabled, or the grant was revoked. If the restore succeeds, a spatial query runs against the restored cluster; if that query fails the cause is backup integrity rather than encryption. A successful query records the drill result and the scratch environment is destroyed. Both failure paths raise an alert, and they are distinguished because they point at different remedies. Scheduled job weekly Restore snapshot using the CMK Spatial query proves legibility Record and destroy scratch environment Restore failed key missing, disabled or ungranted Query failed backup integrity, not encryption Encryption failures are silent until you read the ciphertext — the drill is what makes them loud on your schedule. Separating the two failure paths matters: they have different owners and different remedies.

Resource architecture and service integration

The practical architecture is a small key hierarchy rather than one key or one key per resource. One key per data domain — operational geometry, raster archive, backup — gives a blast radius small enough to be meaningful and a grant list short enough to be reviewable. One key for everything makes revocation impossible without an outage. One key per bucket produces a key estate nobody can audit, and eventually a key that nobody can prove is unused but nobody dares disable.

Integration with the database follows the storage-encryption model: the cluster is created with the key, snapshots inherit it, and read replicas in another region need a key in that region. This is the coupling that most often surprises teams building on PostGIS Cluster Provisioning — a cross-region replica is not merely a networking and replication problem, it is a key-material problem, and the replica cannot be created until the destination key exists and the source key policy permits it.

Integration with object storage adds the bucket-key optimisation, which matters more for spatial workloads than for most. Cloud Optimized GeoTIFF access patterns issue many small range reads against large objects, and a naive server-side encryption configuration charges a key-service request per operation. Enabling bucket-level keys collapses those into far fewer key calls, which is both a cost and a latency improvement on exactly the read pattern raster serving depends on. It is a one-line configuration that is easy to omit and expensive to omit at scale, which is why it belongs in the shared module rather than in each bucket definition.

Integration with the tile cache is the boundary teams argue about, usually on the grounds that generated tiles are derived data and therefore not sensitive. That argument is weaker than it sounds. A tile pyramid rendered from restricted parcel geometry contains that geometry, quantised but entirely legible to anyone who can decode a vector tile — and unlike the database, the cache is by design close to the public edge and readable by a fleet of stateless renderers. Encrypting the cache with the same domain key as its source data keeps the derived copy inside the same audit and revocation boundary as the original, which is the property that matters when a regulator asks who could read the data rather than which service stored it.

The backup vault is the boundary with the longest memory and therefore the strictest constraint. Retention periods measured in years outlive key rotations, account restructurings and often the team that set them up, so a vault whose key policy is not maintained will eventually hold ciphertext nobody can open. Two habits prevent it: grant the vault’s service principal decrypt rights through the key policy rather than through a role that may be deleted, and include the vault in the scheduled restore drill so an unreadable archive is discovered on a Tuesday rather than during a recovery.

Integration with the compute tier is the part that fails at runtime rather than at apply. A renderer, a batch job or a Lambda that reads encrypted objects needs kms:Decrypt on the key in addition to s3:GetObject on the bucket, and the two are granted in different places. An access-denied error on a raster read is therefore ambiguous by default, and the diagnostic habit worth building is to check the key policy second, immediately after the bucket policy, before looking anywhere else.

Encryption in transit across the tile path

At-rest encryption gets the attention because it is the one with keys to manage, but a spatial platform’s in-transit surface is longer than most and it is where the practical gaps appear. A single tile request may traverse four hops: browser to CDN, CDN to load balancer, load balancer to renderer, renderer to PostGIS. Each hop terminates and re-originates TLS independently, and a platform that has proven the first hop with a certificate check has proven one hop out of four.

The two hops that are routinely left unencrypted are the internal ones, on the reasoning that they run inside a private network. That reasoning was defensible when the private network was a rack; it is weak in a shared-tenancy cloud and it is untenable when the traffic crosses an availability zone or a peering connection, which is exactly what happens in the multi-zone topologies described in VPC Routing for Tile Servers. The database hop specifically deserves sslmode=verify-full rather than require, because require encrypts without authenticating the server and therefore accepts any endpoint that answers — including one substituted by a DNS or routing fault.

The certificate lifecycle for the public hop is handled separately in TLS Certificate Automation; what belongs here is the enforcement side. A bucket policy that denies requests where aws:SecureTransport is false, a load balancer listener that redirects rather than serves on port 80, and a database parameter group that sets rds.force_ssl to 1 are three declarations that make the unencrypted path structurally unavailable rather than merely discouraged. Each is one line, and each closes a hop that a code review would otherwise have to notice.

Encrypted hops on a tile request path A tile request passes through four hops, each terminating and re-originating TLS on its own. Browser to content delivery network is the hop teams normally verify. Content delivery network to load balancer and load balancer to renderer are internal hops that are frequently left in plaintext on the assumption that a private network is safe. Renderer to PostGIS should use full certificate verification rather than encryption alone, because encryption without verification accepts any endpoint that answers. Browser TLS CDN TLS Load balancer TLS Renderer PostGIS Four hops, four independent terminations — proving the first proves one out of four. The middle two hops are the ones usually left in plaintext; the last needs verify-full, not merely require. Encryption without server verification accepts any endpoint that answers, including one reached by a routing fault.

Runnable configuration

The following module creates a domain key with a scoped policy, applies it to a raster bucket with bucket-level keys enabled, and grants a named workload role decrypt rights without granting it any administrative capability over the key.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

variable "raster_reader_role_arn" { type = string }
variable "key_admin_role_arn" { type = string }

resource "aws_kms_key" "raster" {
  description = "Domain key: raster archive (COG) for the spatial platform"
  # Automatic rotation keeps prior key material, so historical objects stay
  # readable. Manual rotation does not — record which model applies.
  enable_key_rotation     = true
  deletion_window_in_days = 30

  tags = {
    Domain         = "raster-archive"
    RotationModel  = "automatic-annual"
    DataClass      = "geospatial-restricted"
  }
}

data "aws_iam_policy_document" "raster_key" {
  # Administration and use are separated: the admin statement cannot decrypt,
  # and the workload statement cannot change the policy.
  statement {
    sid       = "KeyAdministration"
    effect    = "Allow"
    actions   = ["kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*",
                 "kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*",
                 "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = [var.key_admin_role_arn]
    }
  }

  statement {
    sid       = "WorkloadDecryptOnly"
    effect    = "Allow"
    actions   = ["kms:Decrypt", "kms:DescribeKey", "kms:GenerateDataKey"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = [var.raster_reader_role_arn]
    }
    # Bind the grant to this service so a compromised role cannot reuse the key
    # against an unrelated resource type.
    condition {
      test     = "StringEquals"
      variable = "kms:ViaService"
      values   = ["s3.${data.aws_region.current.name}.amazonaws.com"]
    }
  }
}

resource "aws_kms_key_policy" "raster" {
  key_id = aws_kms_key.raster.id
  policy = data.aws_iam_policy_document.raster_key.json
}

resource "aws_s3_bucket_server_side_encryption_configuration" "raster" {
  bucket = aws_s3_bucket.raster.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.raster.arn
    }
    # COG range reads issue many small requests against large objects. Bucket
    # keys collapse those into far fewer key-service calls.
    bucket_key_enabled = true
  }
}

data "aws_region" "current" {}

Guardrails embedded in the configuration

  • Administration and use are separate statements. The admin role can disable and schedule deletion but cannot decrypt; the workload role can decrypt but cannot alter the policy. Collapsing these into one statement is the most common key-policy mistake and it removes the whole point of having a policy.
  • kms:ViaService binds the grant to a service. A decrypt grant without this condition lets a compromised workload role use the key against any resource type in the account, not just the bucket it was provisioned for.
  • A deletion window long enough to notice. Thirty days is not bureaucracy; it is the interval in which a quarterly job that still reads the archive will fail loudly while the key is recoverable rather than after it is gone.
  • The rotation model is recorded as a tag. An operator deciding whether an old key is safe to disable needs to know whether prior key material is retained, and reconstructing that from history is exactly the research nobody does under time pressure.
  • Bucket keys are on by default in the shared module. Left to per-bucket definitions, this is omitted often enough that the key-request bill becomes a line item in the raster workload’s cost model.

Troubleshooting and failure modes

1. Access denied on a raster read despite a correct bucket policy. The role can list the object and cannot get it. The missing permission is kms:Decrypt on the key, not s3:GetObject on the bucket, and the two live in different policies. Check the key policy immediately after the bucket policy — the full triage sequence is in Debugging Access Denied on Cross-Account Raster Buckets.

2. A cross-region replica that cannot be created. Replication configuration applies cleanly and objects never arrive. Server-side encryption with a key that exists only in the source region cannot be replicated; the destination needs its own key and the replication role needs decrypt on the source key and encrypt on the destination key.

3. A snapshot that will not restore into another account. The snapshot is shared, the restore fails on permissions. Sharing an encrypted snapshot requires sharing the key as well, and a snapshot encrypted with the provider-default key cannot be shared at all — which is the concrete reason the default key is unacceptable for anything you may ever need to move.

4. A key-request bill larger than the storage bill. Characteristic of raster serving without bucket keys: many small range reads, one key call each. The fix is configuration, not architecture.

5. Silent unreadability after a manual key retirement. Objects written under a retired key remain unreadable, and nothing reports it until something reads a historical archive — often a quarterly reprocessing job. This is the failure the scheduled restore drill exists to catch, and it is the reason automatic rotation is preferred wherever the provider supports it.

6. Key policy drift from console edits during an incident. A grant added by hand to unblock a job persists indefinitely and never appears in code. The nightly drift detection covered in Drift Detection and Remediation should include key policies, which are routinely excluded from drift scopes because they are not thought of as infrastructure.