<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-GB"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://cloud.doublewords.net/feed.xml" rel="self" type="application/atom+xml" /><link href="https://cloud.doublewords.net/" rel="alternate" type="text/html" hreflang="en-GB" /><updated>2026-07-31T19:37:05+01:00</updated><id>https://cloud.doublewords.net/feed.xml</id><title type="html">Cloud Sandbox</title><subtitle>Production-shaped notes on platform engineering, cloud-native systems, DevSecOps, and the strange joy of making software less fragile.</subtitle><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><entry><title type="html">Centralised AWS networking: what belongs in the network account?</title><link href="https://cloud.doublewords.net/writing/2026/07/27/centralised-aws-networking-what-belongs-in-the-network-account/" rel="alternate" type="text/html" title="Centralised AWS networking: what belongs in the network account?" /><published>2026-07-27T00:00:00+01:00</published><updated>2026-07-27T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/27/centralised-aws-networking-what-belongs-in-the-network-account</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/27/centralised-aws-networking-what-belongs-in-the-network-account/"><![CDATA[<p>bfstore has a dedicated AWS account called:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-network
</code></pre></div></div>

<p>The name appears straightforward.</p>

<p>Then almost every resource involved in communication begins making a case for residence there: load balancers, VPC endpoints, Route 53 zones, security groups and even EKS clusters.</p>

<p>By that reasoning, the network account becomes the reluctant landlord of half the AWS estate.</p>

<p>That is centralisation by vocabulary rather than responsibility.</p>

<p>The network account should not own every resource involved in communication.</p>

<p>It should own the <strong>shared connectivity capabilities</strong> through which independently operated accounts obtain addresses, exchange routes, reach common destinations and cross external network boundaries.</p>

<p>AWS guidance recommends a dedicated networking account for shared services such as Transit Gateway and Amazon VPC IP Address Manager. It also presents centralised egress and decentralised ingress as a useful multi-account pattern.</p>

<p>For bfstore, the opening boundary is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT

Owns network capabilities
shared by several accounts.


WORKLOAD ACCOUNT

Owns network resources
whose lifecycle follows one workload.
</code></pre></div></div>

<blockquote>
  <p><strong>The network account should own the roads shared by several properties. It should not own every front door, driveway and room merely because people travel through them.</strong></p>
</blockquote>

<h2 id="the-central-ownership-boundary">The central ownership boundary</h2>

<p>The network account owns the shared connectivity control plane: address allocation, transit relationships, common egress, inspection, DNS distribution, external connectivity and resource sharing.</p>

<p>It may also operate shared data-plane services such as Transit Gateway, NAT gateways, Network Firewall, Resolver endpoints and VPN attachments.</p>

<p>Workload-specific data planes remain with the workloads:</p>

<ul>
  <li>workload VPCs;</li>
  <li>application subnets;</li>
  <li>local route tables;</li>
  <li>internet-facing and internal load balancers;</li>
  <li>application security groups;</li>
  <li>Kubernetes network interfaces;</li>
  <li>database network interfaces;</li>
  <li>workload-specific VPC endpoints.</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         bfstore-network
                                │
        ┌───────────────────────┼───────────────────────┐
        │                       │                       │
        ▼                       ▼                       ▼
 ADDRESS AUTHORITY        TRANSIT AUTHORITY       SHARED DNS
        │                       │                       │
        ▼                       ▼                       ▼
       IPAM              TRANSIT GATEWAY        PROFILES / RULES
        │                       │                       │
        └───────────────────────┼───────────────────────┘
                                ▼
                    SHARED EGRESS AND INSPECTION
                                │
               ┌────────────────┼────────────────┐
               │                │                │
               ▼                ▼                ▼
          bfstore-dev     bfstore-staging    bfstore-prod
               │                │                │
               ▼                ▼                ▼
        WORKLOAD VPC       WORKLOAD VPC       WORKLOAD VPC
</code></pre></div></div>

<p>The network account sets the shared rules of movement.</p>

<p>The workload accounts retain ownership of the workloads being moved.</p>

<h3 id="address-authority-belongs-centrally">Address authority belongs centrally</h3>

<p>The first capability I would place in <code class="language-plaintext highlighter-rouge">bfstore-network</code> is Amazon VPC IP Address Manager.</p>

<p>bfstore’s address plan must cover every environment, shared platform, future AWS Region and external cloud or network.</p>

<p>If each account chooses its own CIDRs independently, overlapping address space eventually becomes a connectivity constraint.</p>

<p>Transit Gateway does not make overlapping networks conveniently transitive.</p>

<p>Direct private connectivity between overlapping ranges requires redesign, translation or deliberate isolation.</p>

<p>AWS supports delegating IPAM administration to an AWS Organizations member account, allowing the management account to avoid everyday IPAM administration.</p>

<p>For bfstore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS ORGANIZATIONS MANAGEMENT ACCOUNT
              │
              │ delegates IPAM administration
              ▼
       bfstore-network
              │
              ▼
             IPAM
</code></pre></div></div>

<p>IPAM can be bfstore’s central AWS allocation authority.</p>

<p>It can allocate and monitor AWS CIDRs, while externally owned ranges are recorded as reservations in the broader address plan.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE ADDRESS PLAN
        │
        ▼
AMAZON VPC IPAM
│
├── allocates and monitors AWS CIDRs
├── reserves planned Azure ranges
├── reserves planned GCP ranges
└── records external network space


AZURE AND GCP

enforce their allocations
through their own provider controls
</code></pre></div></div>

<p>Amazon VPC IPAM does not automatically discover or enforce Azure and GCP allocations in the same way it integrates with AWS Organizations resources.</p>

<p>The AWS pools might begin with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore
│
├── eu-west-2
│   ├── infrastructure
│   │   ├── network
│   │   ├── shared-services
│   │   ├── observability
│   │   └── backup
│   │
│   ├── workloads
│   │   ├── development
│   │   ├── staging
│   │   └── production
│   │
│   ├── sandbox
│   └── policy-test
│
└── future-regions
</code></pre></div></div>

<p>The network account owns IPAM, pools, allocation rules, sharing, compliance, overlap detection and external reservations.</p>

<p>The workload account owns the VPC created from the allocation, its subnet design and the resources consuming those addresses.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT

You may allocate a /20
from the production pool.


WORKLOAD ACCOUNT

This /20 becomes the production VPC.

Its subnets follow
the workload's availability needs.
</code></pre></div></div>

<p>Address governance belongs centrally.</p>

<p>Every subnet decision does not.</p>

<h3 id="transit-authority-belongs-centrally">Transit authority belongs centrally</h3>

<p>AWS Transit Gateway is a Regional routing hub for VPC, VPN, Direct Connect and other supported attachments.</p>

<p>bfstore should create it in <code class="language-plaintext highlighter-rouge">bfstore-network</code> and share it with participating accounts through AWS RAM.</p>

<p>The network account owns the Transit Gateway, its route tables, attachment acceptance, associations, propagation, static and blackhole routes, peering, VPN attachments and network-function integration.</p>

<p>When <code class="language-plaintext highlighter-rouge">bfstore-network</code> shares the Transit Gateway, a workload account creates the VPC attachment for the VPC it owns and selects one attachment subnet in each required Availability Zone.</p>

<p>The network account accepts the attachment where required and controls its Transit Gateway route-table association, propagation and transit routes.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-network

Transit Gateway
Transit Gateway route tables
route segmentation
attachment governance


bfstore-prod

production VPC
VPC attachment request
attachment subnets
local route tables
security groups
</code></pre></div></div>

<p>The Transit Gateway route tables express communication policy.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRODUCTION TRANSIT ROUTE TABLE

may reach:
    shared services
    observability
    backup
    central egress

must not reach:
    development
    sandbox
    policy-test


NON-PRODUCTION TRANSIT ROUTE TABLE

may reach:
    shared services
    non-production observability
    central egress

must not reach:
    production
</code></pre></div></div>

<p>I would disable automatic default route-table association and propagation, then associate and propagate attachments explicitly.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ATTACHMENT ASSOCIATION

Which Transit Gateway route table
governs traffic arriving here?


ROUTE PROPAGATION

Which attachment destinations
may appear in this route table?
</code></pre></div></div>

<p>Those decisions affect relationships between accounts, so they belong centrally.</p>

<p>The workload team still owns the local VPC routes directing selected destinations towards Transit Gateway.</p>

<h3 id="workload-local-networking-stays-with-the-workload">Workload-local networking stays with the workload</h3>

<p>Production, development and observability VPCs should live in their respective workload or platform accounts.</p>

<p>Those accounts own VPC lifecycle, subnets, local routes, internet gateways, security groups, network ACLs, endpoint associations, Flow Logs, local DNS associations and connectivity tests.</p>

<p>The network account supplies address allocation, shared transit, common routing policy, egress, DNS configuration and external connectivity.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKLOAD VPC

belongs with the workload
because its topology is part
of the workload architecture.


TRANSIT NETWORK

belongs centrally
because it determines how
several VPCs interact.
</code></pre></div></div>

<p>AWS also supports VPC sharing, which can suit organisations requiring tight central control over topology.</p>

<p>It is not my preferred starting point for bfstore because separate VPC ownership makes teardown, infrastructure state, responsibility and isolation clearer.</p>

<p>The network account should establish the network contract.</p>

<p>It should not deploy every application subnet for every team.</p>

<h2 id="shared-egress-should-be-deliberate">Shared egress should be deliberate</h2>

<p>Private workloads still need controlled outbound access for activities such as:</p>

<ul>
  <li>operating-system updates;</li>
  <li>package repositories;</li>
  <li>payment-provider APIs;</li>
  <li>shipping-provider APIs;</li>
  <li>public identity endpoints;</li>
  <li>selected SaaS integrations;</li>
  <li>public AWS service endpoints where no private endpoint is used.</li>
</ul>

<p>A central egress design sends internet-bound traffic from spoke VPCs through Transit Gateway towards a network-account egress path.</p>

<p>That path may contain:</p>

<ul>
  <li>Transit Gateway attachment subnets;</li>
  <li>inspection;</li>
  <li>NAT gateways;</li>
  <li>an internet gateway;</li>
  <li>egress route tables;</li>
  <li>network telemetry;</li>
  <li>zonal failure-domain routing.</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE WORKLOAD
       │
       ▼
WORKLOAD VPC ROUTE TABLE
       │
       ▼
TRANSIT GATEWAY
       │
       ▼
INSPECTION
       │
       ▼
EGRESS VPC
       │
       ▼
NAT GATEWAY
       │
       ▼
INTERNET GATEWAY
</code></pre></div></div>

<p>The network account should own the shared route, NAT, internet gateway, inspection integration, availability design and network-level telemetry.</p>

<p>The workload account still owns:</p>

<ul>
  <li>whether a subnet uses that route;</li>
  <li>application security groups;</li>
  <li>application-level destination controls;</li>
  <li>credentials;</li>
  <li>timeout and retry behaviour.</li>
</ul>

<p>Central egress answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which shared path carries
internet-bound traffic?
</code></pre></div></div>

<p>It does not answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is the order service authorised
to call this payment operation?
</code></pre></div></div>

<p>Central egress can provide:</p>

<ul>
  <li>shared inspection;</li>
  <li>fewer NAT gateways;</li>
  <li>stable public source addresses;</li>
  <li>common network policy;</li>
  <li>central flow visibility.</li>
</ul>

<p>It also introduces:</p>

<ul>
  <li>Transit Gateway processing charges;</li>
  <li>concentrated traffic;</li>
  <li>central failure modes;</li>
  <li>route complexity;</li>
  <li>possible cross-Availability-Zone cost;</li>
  <li>dependence on network-account changes.</li>
</ul>

<p>The correct decision tree is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OUTBOUND REQUIREMENT
        │
        ▼
CAN IT USE A PRIVATE SERVICE ENDPOINT?
        │
     ┌──┴──┐
     │     │
    YES    NO
     │     │
     ▼     ▼
 ENDPOINT  DOES IT NEED CONTROLLED
           INTERNET EGRESS?
                │
             ┌──┴──┐
             │     │
            YES    NO
             │     │
             ▼     ▼
       CENTRAL OR LOCAL   NO ROUTE
         EGRESS PATH
</code></pre></div></div>

<p>Gateway endpoints for Amazon S3 and DynamoDB are VPC-local.</p>

<p>They are associated with route tables in the consuming VPC and cannot be used from another VPC through Transit Gateway, VPC peering, VPN or Direct Connect.</p>

<p>They therefore belong in each consuming workload VPC.</p>

<p>Interface endpoints can be local or shared, depending on cost, DNS, availability and failure-boundary requirements.</p>

<p>A local NAT gateway may still be appropriate where workload independence, latency or cost analysis favours it.</p>

<p>The network account should provide a shared egress capability.</p>

<p>It should not force every byte through it when a simpler private or local path is better.</p>

<h2 id="inspection-belongs-in-the-shared-transit-architecture">Inspection belongs in the shared transit architecture</h2>

<p>Central inspection is part of the shared routing architecture.</p>

<p>Its account ownership should follow the organisation’s operational and security-policy model.</p>

<p>bfstore has a separate account called:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-security-tooling
</code></pre></div></div>

<p>That creates two reasonable ownership arrangements.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT

owns routing integration
and shared transit behaviour


SECURITY TOOLING

owns policy objectives,
standards and exceptions
</code></pre></div></div>

<p>The firewall resource itself may live in either account, provided the Transit Gateway and firewall attachment workflow supports the chosen boundary and operational responsibilities are explicit.</p>

<p>AWS now provides two principal Network Firewall integration models.</p>

<h3 id="inspection-vpc">Inspection VPC</h3>

<p>The traditional design deploys Network Firewall endpoints inside a dedicated VPC.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SPOKE VPC
    │
    ▼
TRANSIT GATEWAY
    │
    ▼
INSPECTION VPC
    │
    ▼
NETWORK FIREWALL ENDPOINTS
    │
    ▼
TRANSIT GATEWAY
    │
    ▼
EGRESS OR DESTINATION
</code></pre></div></div>

<p>This is useful when bfstore needs:</p>

<ul>
  <li>detailed control over VPC subnet routing;</li>
  <li>additional network appliances;</li>
  <li>Gateway Load Balancer integration;</li>
  <li>an established inspection-VPC model.</li>
</ul>

<p>The Transit Gateway VPC attachment must use appliance mode so forward and return flows remain on a consistent Availability Zone path.</p>

<h3 id="transit-gateway-network-function-attachment">Transit Gateway network-function attachment</h3>

<p>Transit Gateway can connect directly to AWS Network Firewall through a network-function attachment.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SPOKE VPC
    │
    ▼
TRANSIT GATEWAY
    │
    ▼
NETWORK FUNCTION ATTACHMENT
    │
    ▼
AWS NETWORK FIREWALL
    │
    ▼
TRANSIT GATEWAY DESTINATION
</code></pre></div></div>

<p>This removes the need to create and operate an inspection VPC.</p>

<p>It reduces subnet and VPC route-table components and keeps traffic steering in Transit Gateway route tables.</p>

<p>Appliance mode is handled by the attachment model.</p>

<p>A firewall attachment created from another account can require acceptance by the Transit Gateway owner, allowing the security account to operate the firewall while the network account retains transit authority.</p>

<p>The bfstore decision should compare:</p>

<ul>
  <li>account ownership;</li>
  <li>route complexity;</li>
  <li>appliance requirements;</li>
  <li>change workflow;</li>
  <li>failure-domain design;</li>
  <li>operational skills;</li>
  <li>support for future third-party appliances.</li>
</ul>

<p>The inventory should therefore describe an <strong>inspection capability</strong>, not assume that an inspection VPC must exist.</p>

<p>For policy rollout:</p>

<ul>
  <li>begin Route 53 DNS Firewall rules with the <code class="language-plaintext highlighter-rouge">ALERT</code> action;</li>
  <li>for Network Firewall, begin with non-blocking or alert-producing rules appropriate to the selected rule type and policy;</li>
  <li>enable logging;</li>
  <li>characterise legitimate traffic;</li>
  <li>introduce drop or reject actions deliberately.</li>
</ul>

<p>Network Firewall does not have one universal firewall-wide alert mode.</p>

<h2 id="shared-name-resolution-and-private-service-access">Shared name resolution and private service access</h2>

<p>DNS is partly central and partly distributed.</p>

<p>The network account should own common resolution behaviour.</p>

<p>Workload and platform accounts should own names whose lifecycle follows their services.</p>

<h3 id="common-dns-configuration">Common DNS configuration</h3>

<p>When required, the network account should own Resolver endpoints, shared rules, DNS Firewall groups, Route 53 Profiles, common query logging, hybrid integration and genuinely shared zones.</p>

<p>A Route 53 Profile can distribute:</p>

<ul>
  <li>private hosted zones;</li>
  <li>Resolver forwarding and system rules;</li>
  <li>DNS Firewall rule groups;</li>
  <li>Resolver query logging;</li>
  <li>selected VPC DNS settings;</li>
  <li>interface VPC endpoint DNS associations.</li>
</ul>

<p>Profiles can be shared through AWS RAM and associated with VPCs in other accounts.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-network

Route 53 Profile
│
├── common Resolver rules
├── DNS Firewall rule groups
├── query logging
├── shared private zones
└── selected endpoint DNS
        │
        ▼
shared with workload accounts
</code></pre></div></div>

<p>Only one Route 53 Profile can be associated with a VPC.</p>

<p>That makes Profile composition and ownership important.</p>

<p>Locally configured DNS resources can also take precedence when they conflict with Profile-provided configuration.</p>

<p>The network team therefore cannot treat Profiles as several independent policy layers that every central team attaches separately.</p>

<p>bfstore needs one coherent Profile design per VPC population.</p>

<h3 id="namespace-ownership-remains-distributed">Namespace ownership remains distributed</h3>

<p>Workload and platform accounts may own service-specific zones, environment aliases, platform discovery, application records and local associations.</p>

<p>For bfstore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT

network.aws.internal.bfstore.example
shared forwarding rules
future hybrid DNS


SHARED-SERVICES ACCOUNT

platform.aws.internal.bfstore.example
Backstage
TerraKube
module registry


OBSERVABILITY ACCOUNT

observability.aws.internal.bfstore.example
telemetry ingestion
query endpoints


WORKLOAD ACCOUNTS

service and environment names
owned entirely by those workloads
</code></pre></div></div>

<p>Central DNS should provide a coherent namespace.</p>

<p>It should not turn a record for <code class="language-plaintext highlighter-rouge">orders</code> into a cross-team planning summit.</p>

<h3 id="resolver-endpoints-exist-only-for-resolver-boundaries">Resolver endpoints exist only for resolver boundaries</h3>

<p>Resolver endpoints belong centrally when another DNS environment exists.</p>

<p>An inbound endpoint lets another resolver ask Route 53 VPC Resolver for AWS-private answers.</p>

<p>An outbound endpoint lets Route 53 Resolver forward selected namespaces to another authority.</p>

<p>They are useful for:</p>

<ul>
  <li>on-premises DNS;</li>
  <li>future Azure DNS;</li>
  <li>future GCP DNS;</li>
  <li>private partner or identity services;</li>
  <li>bidirectional hybrid resolution.</li>
</ul>

<p>Today, bfstore has no on-premises network and no active private connection to Azure or GCP.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">network_account_dns</span><span class="pi">:</span>
  <span class="na">inbound_resolver_endpoints</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">outbound_resolver_endpoints</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">hybrid_forwarding_rules</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<p>The network account owns the future capability.</p>

<p>That does not mean it should deploy idle endpoints before another resolver boundary exists.</p>

<p>A central account should contain shared infrastructure with a job.</p>

<p>Not a museum of things bfstore may one day need.</p>

<h3 id="interface-endpoints-require-an-ownership-test">Interface endpoints require an ownership test</h3>

<p>Interface VPC endpoints can be deployed in each consuming VPC or centralised in a shared VPC reachable through Transit Gateway.</p>

<p>A consumer-owned endpoint is usually better for one workload, natural private DNS, low latency and independent failure behaviour.</p>

<p>A shared endpoint may be worthwhile when many VPCs consume the service, endpoint-hour cost is material, Transit Gateway already exists, and the DNS, policy and shared failure boundary are acceptable.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WHO CONSUMES THE ENDPOINT?
        │
   ┌────┴────┐
   │         │
ONE VPC    MANY VPCs
   │         │
   ▼         ▼
LOCAL      EVALUATE CENTRAL
ENDPOINT   SHARED ENDPOINT
</code></pre></div></div>

<p>Historically, central interface-endpoint designs often required custom private hosted zones and Resolver configuration because the endpoint’s managed private DNS applied within the endpoint VPC.</p>

<p>Route 53 Profiles can now distribute selected interface endpoint DNS associations to Profile-associated VPCs.</p>

<p>That can reduce custom private-zone work, but it does not remove routing, security-group, endpoint-policy, zonal availability, failure-boundary, capacity or cost requirements.</p>

<p>A PrivateLink endpoint service normally belongs with the provider service, not the network account.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-observability

owns:
    observability endpoint service
    provider load balancer
    telemetry application


bfstore-prod

owns:
    consumer interface endpoint
    endpoint security group


bfstore-network

owns:
    shared routing and naming standards
</code></pre></div></div>

<p>The network account enables connectivity.</p>

<p>It does not become the owner of every privately consumed service.</p>

<h2 id="public-ingress-stays-with-the-workload">Public ingress stays with the workload</h2>

<p>Customer traffic enters through workload-specific application infrastructure.</p>

<p>For production, that may include:</p>

<ul>
  <li>Amazon CloudFront;</li>
  <li>AWS WAF;</li>
  <li>an internet-facing ALB;</li>
  <li>Envoy Gateway;</li>
  <li>EKS workloads;</li>
  <li>application certificates;</li>
  <li>host and path routing.</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INTERNET
    │
    ▼
PRODUCTION INGRESS
inside bfstore-prod
    │
    ▼
PRODUCTION APPLICATION
</code></pre></div></div>

<p>The production account owns its load balancers, target groups, certificates, ingress routes, WAF association, DNS intent and customer-facing availability.</p>

<p>Security tooling may own organisation-wide WAF and Shield policy. The network account retains shared address planning, private external connectivity and any common inspection architecture.</p>

<p>It should not own the production ALB merely because an ALB is a network service.</p>

<p>That resource’s lifecycle is coupled to the production application.</p>

<h2 id="sharing-and-external-boundaries">Sharing and external boundaries</h2>

<p>AWS RAM is one of the network account’s quiet workhorses.</p>

<p>bfstore may use it to share:</p>

<ul>
  <li>Transit Gateway;</li>
  <li>IPAM pools;</li>
  <li>Route 53 Profiles;</li>
  <li>Resolver rules;</li>
  <li>DNS Firewall rule groups;</li>
  <li>selected prefix lists;</li>
  <li>other supported shared network resources.</li>
</ul>

<p>Sharing and unsharing semantics are resource-specific. A Transit Gateway attachment, for example, can remain functional after the gateway is unshared even though the participant loses visibility of the gateway.</p>

<p>Every share therefore needs an owner, approved consumers, infrastructure-code evidence, a removal runbook, a tested impact model and rollback.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resource_share</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">bfstore-transit-gateway</span>

  <span class="na">resource</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">ec2:TransitGateway</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">bfstore-network</span>

  <span class="na">principals</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">bfstore-dev</span>
    <span class="pi">-</span> <span class="s">bfstore-staging</span>
    <span class="pi">-</span> <span class="s">bfstore-prod</span>
    <span class="pi">-</span> <span class="s">bfstore-shared-services</span>
    <span class="pi">-</span> <span class="s">bfstore-observability</span>

  <span class="na">change_repository</span><span class="pi">:</span> <span class="s">bfstore-network-infrastructure</span>
  <span class="na">removal_runbook</span><span class="pi">:</span> <span class="s">transit-gateway-share-removal</span>
</code></pre></div></div>

<p>Hybrid connectivity also belongs in the shared architecture when a real external network exists.</p>

<p>The network account is the natural owner of:</p>

<ul>
  <li>Site-to-Site VPN;</li>
  <li>customer gateways;</li>
  <li>Transit Gateway VPN attachments;</li>
  <li>hybrid routing policy;</li>
  <li>advertised and accepted prefixes;</li>
  <li>connection monitoring;</li>
  <li>redundant paths.</li>
</ul>

<p>A Site-to-Site VPN attachment must be created in the account that owns the Transit Gateway.</p>

<p>Direct Connect can involve a more distributed ownership model.</p>

<p>A Direct Connect gateway can be associated with a Transit Gateway in another account through an association proposal.</p>

<p>The Transit Gateway owner proposes the association, while the Direct Connect gateway owner accepts it and can control or override the allowed prefixes.</p>

<p>That is a useful reminder:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CENTRAL NETWORK AUTHORITY

does not always mean

ONE ACCOUNT OWNS
EVERY PROVIDER COMPONENT
</code></pre></div></div>

<p>bfstore currently has no on-premises data centre and no active Azure or GCP private network.</p>

<p>The circuit should not be created before the relationship exists.</p>

<h2 id="telemetry-crosses-the-account-boundary">Telemetry crosses the account boundary</h2>

<p>The network account creates VPC and Transit Gateway Flow Logs, firewall logs, NAT and transit metrics, Resolver query logs, hybrid-connectivity metrics and CloudTrail change evidence.</p>

<p>The network account should own the configuration that emits these signals.</p>

<p>It should not necessarily store every signal locally.</p>

<h3 id="bfstore-log-archive"><code class="language-plaintext highlighter-rouge">bfstore-log-archive</code></h3>

<p>Owns:</p>

<ul>
  <li>durable security evidence;</li>
  <li>long-term network-flow archives;</li>
  <li>organisation CloudTrail;</li>
  <li>recovery copies;</li>
  <li>tightly controlled retention.</li>
</ul>

<p>Where bfstore requires immutability, the archive design must provide it explicitly through controls such as:</p>

<ul>
  <li>S3 versioning;</li>
  <li>S3 Object Lock;</li>
  <li>a defined governance or compliance retention mode;</li>
  <li>restricted retention administration;</li>
  <li>KMS key controls;</li>
  <li>CloudTrail log-file integrity validation;</li>
  <li>replication where required.</li>
</ul>

<p>CloudTrail integrity validation can detect modification or deletion.</p>

<p>It does not prevent either action.</p>

<p>S3 Object Lock compliance mode prevents protected object versions from being overwritten or deleted during their retention period, including by the account root user.</p>

<h3 id="bfstore-observability"><code class="language-plaintext highlighter-rouge">bfstore-observability</code></h3>

<p>Owns:</p>

<ul>
  <li>searchable network telemetry;</li>
  <li>dashboards;</li>
  <li>route and firewall alerts;</li>
  <li>flow analysis;</li>
  <li>DNS investigations;</li>
  <li>operational correlations.</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT
      │
      ├── emits network telemetry
      │
      ├────────────► LOG ARCHIVE
      │                durable evidence
      │
      └────────────► OBSERVABILITY
                       operational analysis
</code></pre></div></div>

<p>A routing failure may need correlation with:</p>

<ul>
  <li>a workload deployment;</li>
  <li>a DNS change;</li>
  <li>a firewall-rule change;</li>
  <li>an IAM session;</li>
  <li>an application timeout;</li>
  <li>an external-provider incident.</li>
</ul>

<p>The network account supplies the source.</p>

<p>The observability and archive accounts provide the destinations appropriate to their responsibilities.</p>

<h2 id="what-does-not-belong-in-the-network-account">What does not belong in the network account</h2>

<p>The following would not normally belong in <code class="language-plaintext highlighter-rouge">bfstore-network</code>.</p>

<table>
  <thead>
    <tr>
      <th>Resource or capability</th>
      <th>Normal owner</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Application services</td>
      <td>Workload or platform account</td>
    </tr>
    <tr>
      <td>Workload databases</td>
      <td>Account owning the service state</td>
    </tr>
    <tr>
      <td>Public and internal application load balancers</td>
      <td>Workload account</td>
    </tr>
    <tr>
      <td>Application security groups</td>
      <td>Workload team, within central policy</td>
    </tr>
    <tr>
      <td>Kubernetes network policy</td>
      <td>Cluster or workload platform</td>
    </tr>
    <tr>
      <td>Service-specific DNS records</td>
      <td>Service or platform owner</td>
    </tr>
    <tr>
      <td>Application secrets and certificates</td>
      <td>Application or security boundary</td>
    </tr>
    <tr>
      <td>Customer-facing ingress</td>
      <td>Workload account</td>
    </tr>
    <tr>
      <td>Backstage, TerraKube, Kafka or observability backends</td>
      <td>Their platform accounts</td>
    </tr>
    <tr>
      <td>Organisation-wide security intent</td>
      <td>Security tooling or governance</td>
    </tr>
  </tbody>
</table>

<p>The network account may implement shared enforcement resources.</p>

<p>It should not become the vault for application credentials, the home of workload databases or a general shared-services cluster.</p>

<p>A network account full of platform applications becomes another shared-services account with unusually dangerous routing permissions.</p>

<h2 id="the-proposed-bfstore-inventory">The proposed bfstore inventory</h2>

<table>
  <thead>
    <tr>
      <th>Capability</th>
      <th>Initial state</th>
      <th>Network-account responsibility</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Amazon VPC IPAM</td>
      <td><strong>Deploy</strong></td>
      <td>Delegated administration, pools, allocation policy and sharing</td>
    </tr>
    <tr>
      <td>Transit Gateway</td>
      <td><strong>Deploy</strong></td>
      <td>Gateway, route tables, association, propagation and sharing</td>
    </tr>
    <tr>
      <td>Network-services VPC</td>
      <td><strong>Deploy thinly</strong></td>
      <td>Host central DNS or selected endpoint capabilities</td>
    </tr>
    <tr>
      <td>Central egress</td>
      <td><strong>Deploy when required</strong></td>
      <td>Shared routing, NAT, internet gateway and availability</td>
    </tr>
    <tr>
      <td>Inspection capability</td>
      <td><strong>Phase later</strong></td>
      <td>Choose inspection VPC or network-function attachment</td>
    </tr>
    <tr>
      <td>Shared private zones</td>
      <td><strong>Selective</strong></td>
      <td>Shared network namespaces only</td>
    </tr>
    <tr>
      <td>Route 53 Profiles</td>
      <td><strong>Adopt as DNS estate grows</strong></td>
      <td>One coherent common DNS configuration per VPC population</td>
    </tr>
    <tr>
      <td>Resolver endpoints</td>
      <td><strong>Do not deploy yet</strong></td>
      <td>Add for genuine hybrid or multi-cloud DNS</td>
    </tr>
    <tr>
      <td>DNS Firewall</td>
      <td><strong>Begin with <code class="language-plaintext highlighter-rouge">ALERT</code> later</strong></td>
      <td>Central rule implementation, coordinated with security</td>
    </tr>
    <tr>
      <td>Shared interface endpoints</td>
      <td><strong>Evaluate individually</strong></td>
      <td>Centralise only when economics and failure model justify it</td>
    </tr>
    <tr>
      <td>Gateway endpoints</td>
      <td><strong>Deploy in consuming VPCs</strong></td>
      <td>Not centralisable through Transit Gateway</td>
    </tr>
    <tr>
      <td>Site-to-Site VPN</td>
      <td><strong>None currently</strong></td>
      <td>Add when a real external network exists</td>
    </tr>
    <tr>
      <td>Direct Connect</td>
      <td><strong>None currently</strong></td>
      <td>Add with explicit gateway and prefix ownership</td>
    </tr>
    <tr>
      <td>Transit Gateway peering</td>
      <td><strong>None initially</strong></td>
      <td>Add for another AWS Region when required</td>
    </tr>
    <tr>
      <td>Network telemetry</td>
      <td><strong>Deploy with each capability</strong></td>
      <td>Emit to archive and observability accounts</td>
    </tr>
    <tr>
      <td>AWS RAM shares</td>
      <td><strong>Deploy</strong></td>
      <td>Share IPAM, Transit Gateway and later DNS resources</td>
    </tr>
    <tr>
      <td>Network applications</td>
      <td><strong>Never</strong></td>
      <td>Keep ordinary applications out of this account</td>
    </tr>
  </tbody>
</table>

<p>The physical VPC layout may evolve.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-network
│
├── IPAM
├── Transit Gateway
├── AWS RAM shares
│
├── network-services VPC
│   ├── future Resolver endpoints
│   └── selected shared endpoints
│
├── inspection capability
│   ├── inspection VPC
│   └── or TGW network-function attachment
│
└── egress VPC
    ├── NAT gateways
    ├── internet gateway
    └── shared egress routes
</code></pre></div></div>

<p>Combining functions initially may reduce cost.</p>

<p>Separating them later may improve clarity and blast-radius control.</p>

<p>Infrastructure code should preserve the roles even when the first implementation uses fewer physical VPCs.</p>

<h2 id="ownership-follows-the-decision">Ownership follows the decision</h2>

<p>One resource can involve several owners.</p>

<table>
  <thead>
    <tr>
      <th>Decision</th>
      <th>Network team</th>
      <th>Workload team</th>
      <th>Security team</th>
      <th>Observability team</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CIDR allocation</td>
      <td>Owns pool and approval</td>
      <td>Requests and consumes</td>
      <td>Reviews policy exceptions</td>
      <td>Monitors utilisation</td>
    </tr>
    <tr>
      <td>VPC attachment</td>
      <td>Accepts and routes</td>
      <td>Creates attachment and subnets</td>
      <td>Approves communication intent</td>
      <td>Tests and alerts</td>
    </tr>
    <tr>
      <td>Egress</td>
      <td>Operates shared path</td>
      <td>Chooses local routes and application controls</td>
      <td>Defines policy</td>
      <td>Monitors health and flow</td>
    </tr>
    <tr>
      <td>Inspection</td>
      <td>Integrates transit</td>
      <td>Validates application impact</td>
      <td>Owns standards and exceptions</td>
      <td>Monitors enforcement</td>
    </tr>
    <tr>
      <td>DNS</td>
      <td>Distributes common configuration</td>
      <td>Owns service records</td>
      <td>Defines filtering policy</td>
      <td>Monitors resolution</td>
    </tr>
    <tr>
      <td>Recovery</td>
      <td>Reconstructs shared network</td>
      <td>Revalidates workloads</td>
      <td>Reviews emergency access</td>
      <td>Proves restored visibility</td>
    </tr>
  </tbody>
</table>

<p>Central networking should centralise shared authority.</p>

<p>It should not centralise every task.</p>

<p>Otherwise the network team becomes the queue through which all application delivery must crawl.</p>

<h2 id="the-network-account-is-a-service">The network account is a service</h2>

<p>The network account operates production capabilities.</p>

<p>Its service indicators should cover:</p>

<ul>
  <li><strong>Transit:</strong> attachment availability, route correctness, reachability, loss, latency and unintended blackholes.</li>
  <li><strong>Egress and inspection:</strong> connection success, NAT errors, zonal availability, firewall health and rejected flows.</li>
  <li><strong>DNS:</strong> endpoint health, latency, <code class="language-plaintext highlighter-rouge">SERVFAIL</code>, target timeouts, query-log freshness and unusual <code class="language-plaintext highlighter-rouge">NXDOMAIN</code> growth.</li>
  <li><strong>IPAM:</strong> pool utilisation, non-compliant allocations, overlaps, allocation failures and available capacity.</li>
  <li><strong>Recovery:</strong> time to reconstruct transit, firewall and DNS configuration from infrastructure code, including emergency-access validation.</li>
</ul>

<p>A central network is shared infrastructure.</p>

<p>Shared infrastructure eventually becomes somebody’s outage unless it is operated as a service.</p>

<h2 id="a-staged-implementation">A staged implementation</h2>

<h3 id="phase-1-address-governance">Phase 1: Address governance</h3>

<ul>
  <li>delegate IPAM administration to <code class="language-plaintext highlighter-rouge">bfstore-network</code>;</li>
  <li>define AWS pools and external range reservations;</li>
  <li>share approved pools;</li>
  <li>allocate workload VPC ranges.</li>
</ul>

<h3 id="phase-2-thin-network-baseline">Phase 2: Thin network baseline</h3>

<ul>
  <li>establish network-account roles;</li>
  <li>enable CloudTrail and Config integration;</li>
  <li>create telemetry destinations;</li>
  <li>create the infrastructure repository;</li>
  <li>define emergency access.</li>
</ul>

<h3 id="phase-3-transit-routing">Phase 3: Transit routing</h3>

<ul>
  <li>create Transit Gateway;</li>
  <li>disable automatic default association and propagation;</li>
  <li>create segmented route tables;</li>
  <li>share the gateway through AWS RAM;</li>
  <li>attach development and shared services first;</li>
  <li>test allowed and forbidden reachability;</li>
  <li>add staging and production deliberately.</li>
</ul>

<h3 id="phase-4-shared-dns-governance">Phase 4: Shared DNS governance</h3>

<ul>
  <li>define internal namespace conventions;</li>
  <li>determine central and distributed zone ownership;</li>
  <li>enable Resolver query logging;</li>
  <li>design one Route 53 Profile per VPC population where common configuration justifies it.</li>
</ul>

<h3 id="phase-5-central-egress">Phase 5: Central egress</h3>

<ul>
  <li>create the egress path;</li>
  <li>deploy zonal NAT;</li>
  <li>route selected non-production traffic first;</li>
  <li>measure cost and failure behaviour;</li>
  <li>add production after testing.</li>
</ul>

<h3 id="phase-6-inspection">Phase 6: Inspection</h3>

<ul>
  <li>compare an inspection VPC with a Transit Gateway network-function attachment;</li>
  <li>establish policy ownership;</li>
  <li>begin DNS Firewall with <code class="language-plaintext highlighter-rouge">ALERT</code>;</li>
  <li>begin Network Firewall with suitable non-blocking or alert-producing rules;</li>
  <li>enable logging;</li>
  <li>move to blocking only after legitimate traffic is understood.</li>
</ul>

<h3 id="phase-7-external-connectivity">Phase 7: External connectivity</h3>

<p>Add Resolver endpoints, VPN, Direct Connect or multi-cloud transit only when an actual external environment needs the path.</p>

<p>The network account should grow in response to connectivity relationships.</p>

<p>Not in anticipation of every icon in the AWS console.</p>

<h2 id="the-mental-model-i-am-keeping">The mental model I am keeping</h2>

<p>My earlier model was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK ACCOUNT

contains AWS networking resources
</code></pre></div></div>

<p>The stronger model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                       AWS ORGANISATION
                              │
                              ▼
                        bfstore-network
                              │
       ┌──────────────────────┼──────────────────────┐
       │                      │                      │
       ▼                      ▼                      ▼
  ADDRESS AUTHORITY      ROUTING AUTHORITY       COMMON DNS
       │                      │                      │
       ▼                      ▼                      ▼
      IPAM              TRANSIT GATEWAY       PROFILES / RULES
       │                      │                      │
       └──────────────────────┼──────────────────────┘
                              ▼
                  SHARED EGRESS AND INSPECTION
                              │
       ┌──────────────────────┼──────────────────────┐
       │                      │                      │
       ▼                      ▼                      ▼
      DEV                  STAGING                 PROD
       │                      │                      │
       ▼                      ▼                      ▼
  WORKLOAD VPC           WORKLOAD VPC          WORKLOAD VPC
       │                      │                      │
       ├── subnets            ├── subnets            ├── subnets
       ├── load balancers     ├── load balancers     ├── load balancers
       ├── security groups    ├── security groups    ├── security groups
       └── applications       └── applications       └── applications
</code></pre></div></div>

<p>IPAM answers which address space an account may consume.</p>

<p>Transit Gateway answers which attached networks may exchange routes.</p>

<p>Central egress and inspection answer which shared path traffic uses and which flows cross enforcement.</p>

<p>Route 53 Profiles answer which common DNS configuration a VPC population inherits.</p>

<p>Resolver endpoints answer how DNS crosses into another resolver environment.</p>

<p>AWS RAM answers how other accounts consume centrally owned capabilities.</p>

<p>The workload VPC answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How is this application environment
segmented and operated locally?
</code></pre></div></div>

<p>The workload ingress answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How does traffic reach
this particular application?
</code></pre></div></div>

<p>The security account answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which organisation-wide network
behaviour should be prohibited?
</code></pre></div></div>

<p>The observability account answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How do we know the shared
network is still behaving?
</code></pre></div></div>

<p>And the network account answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which shared connectivity decisions
must remain consistent across bfstore?
</code></pre></div></div>

<p>That is the account’s real job.</p>

<blockquote>
  <p><strong>The network account should own the map, the shared junctions and the bridges between territories. The workload accounts should still own the buildings, their entrances and the locks on their doors.</strong></p>
</blockquote>

<p>For bfstore, <code class="language-plaintext highlighter-rouge">bfstore-network</code> will own IPAM, Transit Gateway, shared route policy, AWS RAM network shares and the eventual shared egress, inspection, hybrid connectivity and Resolver infrastructure.</p>

<p>It will not own the production EKS cluster.</p>

<p>It will not own the order database.</p>

<p>It will not own the customer-facing ALB.</p>

<p>It will not become a general shared-services cluster.</p>

<p>It will not host Backstage merely because Backstage has a hostname.</p>

<p>The account should remain narrow, resilient and slightly dull.</p>

<p>When every other account depends on the roads, the road authority should not also run a furniture shop from the central reservation.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li>
    <p><a href="https://docs.aws.amazon.com/whitepapers/latest/organizing-your-aws-environment/foundational-ous.html">Foundational infrastructure and the network account</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/transitioning-to-multiple-aws-accounts/centralized-egress.html">Centralised egress</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/transitioning-to-multiple-aws-accounts/decentralized-ingress.html">Decentralised ingress</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/tgw/working-with-transit-gateways.html">Work with shared Transit Gateways</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/tgw/tgw-vpc-attachments.html">Amazon VPC attachments in Transit Gateway</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/ipam/enable-integ-ipam.html">Amazon VPC IPAM and AWS Organizations</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/ipam/manually-allocate-ipam.html">Manual IPAM allocations for external reservations</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/tgw/tgw-nf-fw.html">AWS Transit Gateway network-function attachments</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/network-firewall/latest/developerguide/vpc-config-tgw-multi-az.html">Transit Gateway attachment configuration for AWS Network Firewall</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/tgw/accept-reject-firewall-attachment.html">Accepting cross-account firewall attachments</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/profiles.html">Route 53 Profiles</a></p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/profile-associate-vpc-endpoints.html">Associating interface VPC endpoints with Route 53 Profiles</a>
Covers distributing selected interface endpoint DNS associations.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/rehost-multi-account-architecture-interface-endpoints/introduction.html">Centralised interface VPC endpoints</a>
Describes central endpoint patterns and their DNS implications.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html">Gateway endpoints for Amazon S3</a>
Documents that gateway endpoints are VPC-local and cannot be consumed through Transit Gateway.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-dns-firewall-rule-actions.html">DNS Firewall rule actions</a>
Documents the <code class="language-plaintext highlighter-rouge">ALERT</code>, <code class="language-plaintext highlighter-rouge">ALLOW</code> and <code class="language-plaintext highlighter-rouge">BLOCK</code> actions.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/network-firewall/latest/developerguide/rule-action.html">Network Firewall rule actions</a>
Covers alerting and enforcement behaviour for different rule types.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/directconnect/latest/UserGuide/direct-connect-transit-gateways.html">Direct Connect gateways and Transit Gateway associations</a>
Covers cross-account association proposals and allowed prefixes.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">S3 Object Lock</a>
Covers governance and compliance retention modes for WORM protection.</p>
  </li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[A central AWS network account should own shared address planning, transit routing, central egress, inspection, hybrid connectivity and selected DNS infrastructure. Workload VPCs, application ingress and service-level network controls should normally remain with the accounts that operate those workloads.]]></summary></entry><entry><title type="html">AWS Control Tower: what it solves and why I chose not to use it</title><link href="https://cloud.doublewords.net/writing/2026/07/23/aws-control-tower-what-it-solves-and-why-i-chose-not-to-use-it/" rel="alternate" type="text/html" title="AWS Control Tower: what it solves and why I chose not to use it" /><published>2026-07-23T00:00:00+01:00</published><updated>2026-07-23T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/23/aws-control-tower-what-it-solves-and-why-i-chose-not-to-use-it</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/23/aws-control-tower-what-it-solves-and-why-i-chose-not-to-use-it/"><![CDATA[<p>AWS Control Tower appears to offer a wonderfully compact answer to a large architectural problem.</p>

<p>I need:</p>

<ul>
  <li>an AWS organisation;</li>
  <li>multiple accounts;</li>
  <li>central logging;</li>
  <li>security oversight;</li>
  <li>workforce access;</li>
  <li>account provisioning;</li>
  <li>organisation-wide controls;</li>
  <li>compliance visibility;</li>
  <li>protection against configuration drift.</li>
</ul>

<p>AWS Control Tower says, in effect:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Give me the organisation.

I will assemble a governed
multi-account landing zone.
</code></pre></div></div>

<p>That is not an empty promise.</p>

<p>AWS Control Tower orchestrates services including AWS Organizations, AWS Service Catalog and AWS IAM Identity Center to establish and govern a multi-account environment. It creates and manages landing-zone resources, applies controls and provides a consolidated view of governance state.</p>

<p>For many organisations, this is an excellent starting point.</p>

<p>It converts a long list of foundational tasks into a supported AWS service.</p>

<p>It can reduce the amount of bespoke landing-zone engineering required before application teams receive usable accounts.</p>

<p>It can also make good multi-account patterns more accessible to a company that needs governance quickly.</p>

<p>bfstore still chose not to use it.</p>

<p>That choice was not based on the belief that Control Tower is bad, inflexible or only suitable for beginners.</p>

<p>The current service is much more flexible than its earlier versions. Landing Zone 4.0 allows organisations to define their own structure, makes several integrations optional and even supports a controls-focused environment without deploying a complete traditional landing zone.</p>

<p>My decision was narrower:</p>

<blockquote>
  <p><strong>Control Tower was capable of producing much of the environment I needed. It would also have taken ownership of several mechanisms I specifically wanted bfstore to expose, test and explain.</strong></p>
</blockquote>

<p>The decision was about project purpose, operating model and learning value.</p>

<p>Not product quality.</p>

<h2 id="a-landing-zone-is-more-than-an-organisation">A landing zone is more than an organisation</h2>

<p>AWS Organizations provides the hierarchy.</p>

<p>It gives me:</p>

<ul>
  <li>a management account;</li>
  <li>member accounts;</li>
  <li>organisational units;</li>
  <li>consolidated billing;</li>
  <li>organisation policies;</li>
  <li>delegated administration;</li>
  <li>trusted service integrations.</li>
</ul>

<p>That is not yet a complete landing zone.</p>

<p>A production-ready multi-account environment also needs decisions about:</p>

<ul>
  <li>account purposes;</li>
  <li>workforce access;</li>
  <li>central logging;</li>
  <li>security administration;</li>
  <li>approved Regions;</li>
  <li>account bootstrapping;</li>
  <li>policy attachment;</li>
  <li>detective controls;</li>
  <li>network ownership;</li>
  <li>backups;</li>
  <li>recovery;</li>
  <li>exception handling;</li>
  <li>account vending;</li>
  <li>evidence.</li>
</ul>

<p>A simplified landing zone looks more like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS ORGANISATION
       │
       ├── account structure
       ├── identity and access
       ├── preventive controls
       ├── detective controls
       ├── security visibility
       ├── central logging
       ├── account provisioning
       ├── baseline configuration
       └── governance operations
</code></pre></div></div>

<p>AWS Control Tower coordinates many of these concerns through AWS-native services.</p>

<p>It does not replace AWS Organizations.</p>

<p>It extends it with an opinionated governance and account-management layer.</p>

<p>That distinction matters.</p>

<p>Control Tower is not a separate cloud-governance universe.</p>

<p>Its controls and resources are implemented through familiar AWS mechanisms such as:</p>

<ul>
  <li>service control policies;</li>
  <li>resource control policies;</li>
  <li>AWS Config rules;</li>
  <li>CloudFormation hooks;</li>
  <li>CloudFormation StackSets;</li>
  <li>CloudTrail;</li>
  <li>IAM roles;</li>
  <li>IAM Identity Center;</li>
  <li>Service Catalog.</li>
</ul>

<p>The value is orchestration.</p>

<p>AWS owns more of the assembly, lifecycle and conformity checking.</p>

<h2 id="what-control-tower-sets-up">What Control Tower sets up</h2>

<p>A full Control Tower landing zone can establish shared governance capabilities and managed resources around an AWS organisation.</p>

<p>Depending on the selected landing-zone version and configuration, Control Tower can provide or integrate:</p>

<ul>
  <li>security and logging accounts;</li>
  <li>organisation-level CloudTrail;</li>
  <li>AWS Config recording and aggregation;</li>
  <li>IAM Identity Center access;</li>
  <li>baseline IAM roles;</li>
  <li>managed controls;</li>
  <li>account enrolment;</li>
  <li>Account Factory;</li>
  <li>dashboards for governance and drift.</li>
</ul>

<p>Landing Zone 4.0 makes integrations such as AWS Config, CloudTrail, IAM Identity Center, security roles and AWS Backup more selectively configurable than in earlier versions. It also removes the earlier requirement that every deployment adopt the same mandatory organisational structure.</p>

<p>This is important because an old description of Control Tower might say:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Control Tower forces every organisation
into one fixed OU structure.
</code></pre></div></div>

<p>That is no longer a fair description of the current service.</p>

<p>A more accurate model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTROL TOWER
      │
      ├── managed landing-zone baselines
      ├── optional service integrations
      ├── managed control catalogue
      ├── OU and account governance
      ├── account lifecycle workflows
      └── defined drift workflows
</code></pre></div></div>

<p>The platform remains prescriptive.</p>

<p>It has become more modular about which prescriptions must travel together.</p>

<p>Those choices are connected. In Landing Zone 4.0, disabling the AWS Config integration also removes detective controls, the full Account Factory and Service Catalog path, Account Factory for Terraform, Account Factory Customization and OU baselines. AWS Organizations account creation, account movement and auto-enrolment provide a lighter alternative.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/config-integration-feature-comparison.html">the Landing Zone 4.0 feature comparison</a>.</p>

<h2 id="controls-are-the-governance-vocabulary">Controls are the governance vocabulary</h2>

<p>Control Tower calls its governance rules <strong>controls</strong>.</p>

<p>Older documentation and discussions may call them <strong>guardrails</strong>. The terms refer to the same general Control Tower concept.</p>

<p>Controls are enabled against organisational units, but inheritance depends on their behaviour.</p>

<p>Preventive controls flow through nested OUs and affect their accounts, including accounts and OUs that are not registered with Control Tower. Detective and proactive controls do not automatically flow into nested OUs. They must be enabled for the relevant registered OUs and operate only in supported governed Regions.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/nested-ous.html">Nested OUs in AWS Control Tower</a>.</p>

<h3 id="preventive-controls">Preventive controls</h3>

<p>Preventive controls stop prohibited actions.</p>

<p>They are implemented through AWS Organizations service control policies, resource control policies or declarative policies.</p>

<p>A request that violates the applicable policy is denied before the action succeeds. Preventive controls work across AWS Regions because the underlying organisation policies govern the account rather than depending on a Regional resource assessment.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>REQUEST
   │
   ▼
ORGANISATION POLICY
   │
   ├── permitted → continue
   └── denied    → stop
</code></pre></div></div>

<h3 id="detective-controls">Detective controls</h3>

<p>Detective controls evaluate resources for non-compliance.</p>

<p>They are commonly implemented using AWS Config rules.</p>

<p>They do not necessarily prevent the resource from being created. They identify that the resulting state violates the control.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RESOURCE EXISTS
      │
      ▼
CONFIGURATION EVALUATED
      │
      ├── compliant
      └── violation reported
</code></pre></div></div>

<h3 id="proactive-controls">Proactive controls</h3>

<p>Proactive controls assess supported resources before CloudFormation deploys them.</p>

<p>They use CloudFormation hooks managed through Control Tower. A non-compliant resource can be rejected during provisioning rather than discovered afterwards.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLOUDFORMATION CHANGE
        │
        ▼
PROACTIVE CONTROL
        │
        ├── compliant     → provision
        └── non-compliant → reject
</code></pre></div></div>

<p>These behaviours answer different governance questions:</p>

<table>
  <thead>
    <tr>
      <th>Behaviour</th>
      <th>Question</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Preventive</td>
      <td>Must this action be impossible?</td>
    </tr>
    <tr>
      <td>Detective</td>
      <td>Has a prohibited or risky state appeared?</td>
    </tr>
    <tr>
      <td>Proactive</td>
      <td>Should this declared resource be admitted?</td>
    </tr>
  </tbody>
</table>

<p>That model is one of Control Tower’s strongest features.</p>

<p>It moves governance away from one giant policy document and towards controls with explicit enforcement behaviour.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/controlreference/control-behavior.html">Control behaviour and guidance</a>.</p>

<h2 id="managed-controls-reduce-repeated-policy-engineering">Managed controls reduce repeated policy engineering</h2>

<p>Without Control Tower, a platform team may need to:</p>

<ol>
  <li>identify the policy objective;</li>
  <li>select an enforcement mechanism;</li>
  <li>write the SCP, RCP, declarative policy, Config rule or hook;</li>
  <li>test and deploy it;</li>
  <li>map it to compliance requirements;</li>
  <li>monitor it;</li>
  <li>update it as AWS behaviour changes.</li>
</ol>

<p>The Control Catalog provides a consolidated catalogue of AWS-managed controls organised around objectives, services and compliance frameworks.</p>

<p>AWS currently describes the catalogue as containing more than 750 managed controls across areas such as security, operations, resilience, cost and compliance. The catalogue can be inspected through the console and APIs, and organisations can use the controls-focused experience without deploying a full traditional landing zone.</p>

<p>This can save substantial effort where an organisation needs:</p>

<ul>
  <li>consistent control definitions;</li>
  <li>documented policy intent;</li>
  <li>compliance-framework mapping;</li>
  <li>broad service coverage;</li>
  <li>supported control updates;</li>
  <li>governance reporting.</li>
</ul>

<p>A managed control does not remove the need to understand the policy.</p>

<p>It removes some of the repeated implementation work.</p>

<p>See <a href="https://aws.amazon.com/controltower/features/">AWS Control Tower features</a> and <a href="https://docs.aws.amazon.com/controltower/latest/controlreference/controls-reference.html">the Control Catalog reference</a>.</p>

<h2 id="mandatory-controls-protect-the-integrations-you-enable">Mandatory controls protect the integrations you enable</h2>

<p>Mandatory controls protect the governance resources on which Control Tower depends.</p>

<p>Landing Zone 4.0 changed how they are introduced. Mandatory controls are no longer enabled by default across every OU. AWS deploys the controls required by the integrations and managed resources selected for the environment.</p>

<p>Once an applicable mandatory control is deployed, it protects the associated Control Tower resources and generally cannot be disabled independently.</p>

<p>This is sensible.</p>

<p>A governance service cannot provide reliable oversight if ordinary account administrators can quietly dismantle its:</p>

<ul>
  <li>roles;</li>
  <li>logging resources;</li>
  <li>policies;</li>
  <li>configuration recorders;</li>
  <li>cross-account access.</li>
</ul>

<p>Control Tower therefore owns certain resources and expects them to be changed through supported Control Tower workflows. AWS warns that changing or deleting those resources outside supported methods can leave the environment drifted or in an unknown state.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CUSTOMER-MANAGED RESOURCES

change through ordinary
platform workflows


CONTROL-TOWER-MANAGED RESOURCES

change through supported
Control Tower workflows
</code></pre></div></div>

<p>For a company that wants AWS to own more of the landing-zone machinery, this is beneficial.</p>

<p>For bfstore, it would have obscured some of the machinery I wanted to operate directly.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/controlreference/control-behavior.html">Control behaviour and guidance</a> and <a href="https://docs.aws.amazon.com/controltower/latest/controlreference/control-considerations.html">considerations for controls and OUs</a>.</p>

<h2 id="baselines-make-governance-repeatable">Baselines make governance repeatable</h2>

<p>A Control Tower baseline is a group of resources and configurations applied to a target.</p>

<p>The <code class="language-plaintext highlighter-rouge">AWSControlTowerBaseline</code> is the fuller OU-level baseline. It establishes resources and controls needed for compliance monitoring, auditing, security monitoring and optional access management. Accounts in the target OU receive the baseline through enrolment.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>REGISTERED OU
      │
      ▼
CONTROL TOWER BASELINE
      │
      ▼
MEMBER ACCOUNTS RECEIVE
REQUIRED GOVERNANCE RESOURCES
</code></pre></div></div>

<p>That solves a real problem.</p>

<p>Without a baseline mechanism, every new account risks arriving without required roles, logging, Config recording, access configuration, governance policy or central evidence paths.</p>

<p>Landing Zone 4.0 makes the baseline model less monolithic.</p>

<p><code class="language-plaintext highlighter-rouge">ConfigBaseline</code> can establish the narrower Config resources needed for detective controls without the complete <code class="language-plaintext highlighter-rouge">AWSControlTowerBaseline</code>. Central functions are represented through separate baselines, including:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CentralConfigBaseline</code>;</li>
  <li><code class="language-plaintext highlighter-rouge">CentralSecurityRolesBaseline</code>;</li>
  <li><code class="language-plaintext highlighter-rouge">LogArchiveBaseline</code>;</li>
  <li><code class="language-plaintext highlighter-rouge">IdentityCenterBaseline</code>.</li>
</ul>

<p>The baseline resources are versioned. Landing-zone or OU baseline changes may require updates, re-registration or account-update workflows.</p>

<p>That creates work.</p>

<p>It also makes the work visible and gives it a supported lifecycle.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/types-of-baselines.html">Types of baselines</a> and <a href="https://docs.aws.amazon.com/controltower/latest/userguide/landing-zone-v4-migration-guide.html">the Landing Zone 4.0 migration guide</a>.</p>

<h2 id="specific-governance-drift-becomes-a-first-class-concern">Specific governance drift becomes a first-class concern</h2>

<p>A landing zone begins compliant.</p>

<p>Humans and automation then interact with it.</p>

<p>Someone may edit or detach a managed policy, move an account, disable trusted access, remove a required role or create inheritance differences between an OU and its accounts.</p>

<p>Control Tower detects defined classes of governance drift, including:</p>

<ul>
  <li>OU and account governance drift;</li>
  <li>control-policy drift;</li>
  <li>trusted-access and required-role drift;</li>
  <li>enabled-control inheritance drift;</li>
  <li>enabled-baseline inheritance drift.</li>
</ul>

<p>The resulting model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EXPECTED GOVERNANCE STATE
          │
          ▼
ACTUAL ORGANISATION STATE
          │
          ▼
SUPPORTED DRIFT DETECTION
          │
          ├── no drift
          └── remediation workflow
</code></pre></div></div>

<p>Drift detection does not mean every deviation heals itself automatically. Administrators may need to reset a control or baseline, re-register an OU, update an account or repair the landing zone.</p>

<p>It is also not a comprehensive state reconciler.</p>

<p>Control Tower does not generally inspect drift in associated services such as CloudTrail, CloudWatch, IAM Identity Center, CloudFormation or AWS Config. It does not detect arbitrary resource changes inside a baseline.</p>

<p>A complete operating model still needs:</p>

<ul>
  <li>CloudFormation or IaC drift detection;</li>
  <li>AWS Config resource assessment;</li>
  <li>periodic reconciliation;</li>
  <li>alerts for critical service changes.</li>
</ul>

<p>Control Tower makes important governance deviations visible. It does not replace every other form of configuration assurance.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/governance-drift.html">Types of governance drift</a>.</p>

<h2 id="account-factory-solves-repeated-account-vending">Account Factory solves repeated account vending</h2>

<p>A growing organisation should not provision accounts through improvised emails and hand-entered console steps.</p>

<p>A new account normally needs:</p>

<ul>
  <li>a unique email address;</li>
  <li>OU placement;</li>
  <li>workforce access;</li>
  <li>standard tags;</li>
  <li>security roles;</li>
  <li>logging;</li>
  <li>budgets;</li>
  <li>platform integration;</li>
  <li>ownership metadata.</li>
</ul>

<p>Control Tower Account Factory provides a supported account-provisioning workflow. Accounts are provisioned into governed OUs and receive the corresponding baselines and controls.</p>

<p>Account Factory Customization can apply a CloudFormation or Terraform-backed Service Catalog blueprint during provisioning or enrolment.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ACCOUNT REQUEST
      │
      ▼
VALIDATED PARAMETERS
      │
      ▼
ACCOUNT FACTORY
      │
      ▼
GOVERNED AWS ACCOUNT
      │
      ▼
CUSTOM BLUEPRINT
      │
      ▼
READY FOR TEAM USE
</code></pre></div></div>

<p>AWS Organizations can create an account.</p>

<p>Account Factory’s value is delivering one that already belongs to the organisation’s operating model.</p>

<p>The full Account Factory, Service Catalog, AFT and AFC paths depend on the Landing Zone 4.0 AWS Config integration. Without it, AWS points to a lighter workflow based on Organizations and auto-enrolment.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/account-factory.html">Account Factory</a> and <a href="https://docs.aws.amazon.com/controltower/latest/userguide/config-integration-feature-comparison.html">the Config integration comparison</a>.</p>

<h2 id="aft-adds-a-terraform-and-gitops-workflow">AFT adds a Terraform and GitOps workflow</h2>

<p>Account Factory for Terraform, usually shortened to AFT, adds a Terraform-oriented account-provisioning and customization workflow around Control Tower.</p>

<p>AFT supports account requests through Git, stores account metadata and audit history, and runs global or account-specific customizations through Terraform, Python, Bash and AWS CLI stages.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GIT ACCOUNT REQUEST
        │
        ▼
AFT PROVISIONING PIPELINE
        │
        ▼
CONTROL TOWER ACCOUNT FACTORY
        │
        ▼
NEW ACCOUNT
        │
        ▼
GLOBAL AND ACCOUNT
CUSTOMISATIONS
</code></pre></div></div>

<p>AFT requires an existing Control Tower landing zone and deploys into a separate AFT management account. It is intended for account provisioning and customization, not as the general deployment system for application infrastructure.</p>

<p>AFT itself has no separate service fee, but the infrastructure it deploys incurs AWS charges.</p>

<p>For a company vending many accounts, it may be a sensible platform component.</p>

<p>For bfstore’s current account count and learning objectives, it would have introduced another provisioning control plane before the repeated journey had justified it.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/aft-overview.html">Account Factory for Terraform</a>.</p>

<h2 id="what-control-tower-solves-well">What Control Tower solves well</h2>

<p>Control Tower is particularly strong when an organisation needs several capabilities together:</p>

<ul>
  <li>a supported starting point for a governed multi-account environment;</li>
  <li>consistent account onboarding through Account Factory or AFT;</li>
  <li>a broad catalogue of AWS-managed controls;</li>
  <li>account, OU, baseline, control and drift status;</li>
  <li>staged adoption into an existing AWS Organization;</li>
  <li>optional auto-enrolment when accounts move into governed OUs;</li>
  <li>control mappings that support wider compliance programmes;</li>
  <li>an account and control lifecycle that can scale across teams.</li>
</ul>

<p>None of these are small benefits.</p>

<p>Control Tower solves real platform work.</p>

<h2 id="what-control-tower-does-not-solve">What Control Tower does not solve</h2>

<p>A Control Tower landing zone is governed infrastructure.</p>

<p>It is not a complete cloud platform.</p>

<p>It does not decide bfstore’s:</p>

<ul>
  <li>business domains;</li>
  <li>Kubernetes architecture;</li>
  <li>database ownership;</li>
  <li>Kafka topology;</li>
  <li>application identity model;</li>
  <li>network segmentation;</li>
  <li>SLOs;</li>
  <li>backup strategy;</li>
  <li>observability backend;</li>
  <li>deployment model;</li>
  <li>incident response;</li>
  <li>cost allocation;</li>
  <li>multi-cloud architecture.</li>
</ul>

<p>It also does not remove the need to understand AWS permissions.</p>

<p>A preventive control implemented with an SCP still does not grant an IAM permission.</p>

<p>A detective control still requires someone to interpret and remediate the finding.</p>

<p>A compliant AWS Config rule does not prove the application is secure.</p>

<p>A vended account does not prove the workload belongs in that account.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTROL TOWER

governs the landing-zone machinery


PLATFORM ARCHITECTURE

decides how teams build and operate


APPLICATION ARCHITECTURE

decides how bfstore behaves
</code></pre></div></div>

<p>Control Tower shortens the first journey.</p>

<p>It does not complete the other two.</p>

<h2 id="why-i-chose-not-to-use-it-for-bfstore">Why I chose not to use it for bfstore</h2>

<p>The decision came from six project-specific considerations.</p>

<h3 id="1-the-landing-zone-is-part-of-the-curriculum">1. The landing zone is part of the curriculum</h3>

<p>bfstore is not only an ecommerce system.</p>

<p>It is also a deliberately inspectable cloud-engineering project.</p>

<p>I wanted to understand and document:</p>

<ul>
  <li>why each OU exists;</li>
  <li>why each account exists;</li>
  <li>how SCP inheritance works;</li>
  <li>how IAM Identity Center assignments work;</li>
  <li>how CloudTrail is centralised;</li>
  <li>how AWS Config should be organised;</li>
  <li>how delegated administration works;</li>
  <li>which responsibilities belong in the management account;</li>
  <li>how policies fail;</li>
  <li>how they are tested;</li>
  <li>how emergency access interacts with governance.</li>
</ul>

<p>Control Tower would still require knowledge of those subjects.</p>

<p>It would also make several of their initial implementations arrive as managed baseline resources.</p>

<p>For an enterprise trying to reduce setup work, that is an advantage.</p>

<p>For a learning project trying to expose the setup work, it is a layer of smoked glass.</p>

<blockquote>
  <p><strong>I did not merely want the answer. I wanted the workings in the margin.</strong></p>
</blockquote>

<h3 id="2-the-organisation-structure-was-already-deliberately-designed">2. The organisation structure was already deliberately designed</h3>

<p>bfstore’s structure is not a generic <code class="language-plaintext highlighter-rouge">Security</code>, <code class="language-plaintext highlighter-rouge">Sandbox</code> and <code class="language-plaintext highlighter-rouge">Workloads</code> arrangement.</p>

<p>The organisation separates concerns across OUs and accounts including:</p>

<ul>
  <li>management;</li>
  <li>security tooling;</li>
  <li>log archive;</li>
  <li>identity;</li>
  <li>network;</li>
  <li>shared services;</li>
  <li>observability;</li>
  <li>backup;</li>
  <li>development;</li>
  <li>staging;</li>
  <li>production;</li>
  <li>sandbox;</li>
  <li>policy testing.</li>
</ul>

<p>The design is based on:</p>

<ul>
  <li>failure boundaries;</li>
  <li>policy boundaries;</li>
  <li>operational ownership;</li>
  <li>data flows;</li>
  <li>recovery responsibilities;</li>
  <li>independent platform projects.</li>
</ul>

<p>Control Tower 4.0 now supports customer-defined organisational structures, so the mere existence of this structure is no longer a strong argument against adopting the service.</p>

<p>The relevant point is sequence.</p>

<p>The structure, accounts, identity assignments and initial policies were already being built deliberately.</p>

<p>Introducing a complete landing zone would have required deciding which existing resources should become Control Tower-managed, which OUs should be registered and how current logging and identity choices should be reconciled with the selected baseline.</p>

<p>That migration would have added product work without addressing an immediate bfstore problem.</p>

<h3 id="3-i-wanted-a-policy-testing-laboratory">3. I wanted a policy-testing laboratory</h3>

<p>bfstore has a dedicated <code class="language-plaintext highlighter-rouge">bfstore-policy-test</code> account.</p>

<p>Its purpose is to test:</p>

<ul>
  <li>SCP allow and deny behaviour;</li>
  <li>RCP behaviour;</li>
  <li>policy inheritance;</li>
  <li>exceptions;</li>
  <li>policy syntax;</li>
  <li>service interactions;</li>
  <li>expected and unexpected denial paths.</li>
</ul>

<p>The desired workflow is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WRITE POLICY
     │
     ▼
STATIC VALIDATION
     │
     ▼
POLICY-TEST OU OR ACCOUNT
     │
     ▼
POSITIVE TESTS
     │
     ▼
NEGATIVE TESTS
     │
     ▼
EVIDENCE
     │
     ▼
PROMOTE TO REAL OUs
</code></pre></div></div>

<p>Control Tower provides managed controls whose implementations are already tested and supported by AWS.</p>

<p>That is useful.</p>

<p>It is not the same learning exercise as constructing a custom policy, predicting its evaluation and proving its effect through an explicit harness.</p>

<p>bfstore needs both concepts:</p>

<ul>
  <li>awareness of AWS-managed controls;</li>
  <li>practical competence building and testing bespoke controls.</li>
</ul>

<p>The dedicated policy-test account places the second objective in the foreground.</p>

<h3 id="4-account-vending-is-not-yet-the-bottleneck">4. Account vending is not yet the bottleneck</h3>

<p>Account Factory becomes especially valuable when account creation is repeated frequently.</p>

<p>bfstore currently has a known, finite set of accounts with deliberately assigned purposes.</p>

<p>It is not yet serving:</p>

<ul>
  <li>dozens of product teams;</li>
  <li>frequent project onboarding;</li>
  <li>temporary account fleets;</li>
  <li>departmental account requests;</li>
  <li>customer-specific environments.</li>
</ul>

<p>A full account-vending platform would therefore optimise work that is currently infrequent.</p>

<p>The account lifecycle still needs documentation and automation.</p>

<p>It does not yet need a large self-service factory.</p>

<p>This may change later.</p>

<p>A platform should be introduced when its repeated journey exists, not because its architecture diagram is handsome.</p>

<h3 id="5-bfstore-already-has-an-infrastructure-control-plane-direction">5. bfstore already has an infrastructure control-plane direction</h3>

<p>bfstore selected:</p>

<ul>
  <li>OpenTofu-compatible infrastructure;</li>
  <li>TerraKube for infrastructure delivery and governance;</li>
  <li>GitHub Actions;</li>
  <li>reusable cloud modules;</li>
  <li>a private module-registry path;</li>
  <li>later Crossplane capabilities.</li>
</ul>

<p>AFT supports Terraform and GitOps-style account requests, so it is not philosophically opposed to this stack.</p>

<p>It would still introduce:</p>

<ul>
  <li>an AFT management account;</li>
  <li>AFT pipelines;</li>
  <li>Service Catalog relationships;</li>
  <li>AFT-specific account metadata;</li>
  <li>customization repositories;</li>
  <li>another lifecycle to upgrade and observe.</li>
</ul>

<p>That might be justified when AFT owns a large account-vending workload.</p>

<p>It would be excessive while bfstore is still establishing its broader infrastructure-delivery model.</p>

<p>I prefer one visible control plane for each responsibility.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ACCOUNT GOVERNANCE

currently:
explicit organisation and IaC workflow


INFRASTRUCTURE DELIVERY

TerraKube and OpenTofu direction


WORKLOAD DELIVERY

GitHub Actions and Kubernetes tooling
</code></pre></div></div>

<p>Adding AFT now would create another governance engine before bfstore had enough account churn to feed it.</p>

<h3 id="6-the-project-needs-cloud-neutral-reasoning">6. The project needs cloud-neutral reasoning</h3>

<p>bfstore is being implemented in this order:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS
 │
 ▼
AZURE
 │
 ▼
GCP
 │
 ▼
MULTI-CLOUD CAPSTONE
</code></pre></div></div>

<p>The objective is not to make all three providers identical.</p>

<p>Provider-native services should be used where they offer real value.</p>

<p>The objective is to understand the underlying pattern clearly enough to recognise its equivalents and differences elsewhere.</p>

<p>Building the AWS landing zone from primitives makes the pattern visible:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ORGANISATION HIERARCHY

ACCOUNT OR SUBSCRIPTION BOUNDARIES

CENTRAL IDENTITY

PREVENTIVE POLICY

DETECTIVE POLICY

CENTRAL LOGGING

ACCOUNT VENDING

DRIFT MANAGEMENT
</code></pre></div></div>

<p>Control Tower could still be documented and compared.</p>

<p>It simply would not be the machinery through which I first learned the pattern.</p>

<h2 id="manual-does-not-mean-unmanaged">Manual does not mean unmanaged</h2>

<p>The alternative to Control Tower is not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Click around the management account
until the organisation looks convincing.
</code></pre></div></div>

<p>bfstore’s alternative is an explicitly engineered landing zone.</p>

<p>It should include:</p>

<ul>
  <li>infrastructure as code;</li>
  <li>version-controlled policies;</li>
  <li>policy tests;</li>
  <li>documented account ownership;</li>
  <li>centralised CloudTrail;</li>
  <li>AWS Config design;</li>
  <li>IAM Identity Center;</li>
  <li>delegated administrators;</li>
  <li>account bootstrap procedures;</li>
  <li>drift detection;</li>
  <li>runbooks;</li>
  <li>evidence.</li>
</ul>

<p>The difference is ownership.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTROL TOWER MODEL

AWS manages landing-zone
baselines and control lifecycle


BFSTORE MODEL

bfstore defines, tests and operates
those mechanisms directly
</code></pre></div></div>

<p>A custom landing zone must reproduce the operational discipline Control Tower would otherwise help provide.</p>

<p>Declining the service does not decline the problem.</p>

<p>It accepts more of it.</p>

<h2 id="what-bfstore-built-instead">What bfstore built instead</h2>

<p>The bfstore organisation begins with explicit account boundaries.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE MANAGEMENT
        │
        ├── Security
        │   ├── security-tooling
        │   └── emergency-access
        │
        ├── Logging
        │   └── log-archive
        │
        ├── Identity
        │   └── identity
        │
        ├── Network
        │   └── network
        │
        ├── Shared Services
        │   └── shared-services
        │
        ├── Observability
        │   └── observability
        │
        ├── Backup
        │   └── backup
        │
        ├── Workloads
        │   ├── dev
        │   ├── staging
        │   └── prod
        │
        ├── Sandbox
        │   └── sandbox
        │
        └── Policy Test
            └── policy-test
</code></pre></div></div>

<p>The account names are less important than their responsibilities.</p>

<h3 id="aws-organizations">AWS Organizations</h3>

<p>Owns:</p>

<ul>
  <li>hierarchy;</li>
  <li>account membership;</li>
  <li>SCPs;</li>
  <li>RCPs;</li>
  <li>delegated-service access;</li>
  <li>consolidated billing.</li>
</ul>

<h3 id="iam-identity-center">IAM Identity Center</h3>

<p>Owns:</p>

<ul>
  <li>workforce sign-in;</li>
  <li>permission sets;</li>
  <li>group assignments;</li>
  <li>temporary account access.</li>
</ul>

<h3 id="log-archive">Log archive</h3>

<p>Owns:</p>

<ul>
  <li>organisation CloudTrail;</li>
  <li>selected AWS Config evidence;</li>
  <li>immutable or tightly controlled central logs;</li>
  <li>long-term security evidence.</li>
</ul>

<h3 id="security-tooling">Security tooling</h3>

<p>Owns:</p>

<ul>
  <li>delegated security services;</li>
  <li>organisation-wide findings;</li>
  <li>security operations;</li>
  <li>investigation access.</li>
</ul>

<h3 id="policy-test">Policy test</h3>

<p>Owns:</p>

<ul>
  <li>policy validation;</li>
  <li>expected-denial tests;</li>
  <li>expected-success tests;</li>
  <li>policy evidence before promotion.</li>
</ul>

<h3 id="infrastructure-repositories">Infrastructure repositories</h3>

<p>Own:</p>

<ul>
  <li>policy source;</li>
  <li>OU and account definitions;</li>
  <li>baseline modules;</li>
  <li>deployment workflows;</li>
  <li>change history.</li>
</ul>

<p>This is not automatically better than Control Tower.</p>

<p>It is more educational and more directly aligned with bfstore’s current purpose.</p>

<h2 id="the-costs-i-accepted">The costs I accepted</h2>

<p>Choosing not to use Control Tower creates work.</p>

<p>bfstore must:</p>

<ul>
  <li>maintain the account baseline, including roles, logging, access, tags and policies;</li>
  <li>detect drift through AWS Config, CloudTrail, EventBridge, IaC plans and reconciliation;</li>
  <li>place, bootstrap and verify every new account;</li>
  <li>maintain custom SCPs, RCPs, Config rules and other controls as AWS changes;</li>
  <li>build governance reporting rather than receive a consolidated Control Tower view;</li>
  <li>test module, service and policy changes without a Control Tower landing-zone upgrade workflow;</li>
  <li>prove that every account received the intended baseline.</li>
</ul>

<p>These are not footnotes.</p>

<p>They are the invoice for owning the machinery.</p>

<h2 id="control-tower-is-free-its-ingredients-are-not">Control Tower is free, its ingredients are not</h2>

<p>AWS does not charge an additional fee for Control Tower itself.</p>

<p>Customers pay for the services and resources it enables, including CloudTrail, Config, CloudWatch, Service Catalog and associated storage or processing.</p>

<p>The same is true of a custom landing zone.</p>

<p>Avoiding Control Tower does not avoid the cost of organisation trails, Config evaluations, log storage, security services, automation or engineering time.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTROL TOWER COST

underlying AWS services
+
managed design constraints


CUSTOM LANDING-ZONE COST

underlying AWS services
+
engineering and operational ownership
</code></pre></div></div>

<p>The cheaper option depends on scale, requirements and the value assigned to engineering time.</p>

<p>For bfstore, some of that engineering time is part of the project’s educational output. In a commercial platform team, it would be judged more sternly.</p>

<h2 id="where-i-would-choose-control-tower">Where I would choose Control Tower</h2>

<p>I would strongly consider Control Tower when:</p>

<ul>
  <li>the organisation is beginning its multi-account journey and needs a supported baseline quickly;</li>
  <li>accounts are created regularly enough for Account Factory or AFT to become a platform product;</li>
  <li>a small cloud team benefits from AWS owning more of the baseline lifecycle;</li>
  <li>managed control definitions and compliance mappings remove meaningful repeated work;</li>
  <li>governance drift is already difficult to see and repair;</li>
  <li>AWS is the organisation’s primary cloud;</li>
  <li>several teams need consistent account and control workflows;</li>
  <li>the company wants AWS support around the landing-zone product.</li>
</ul>

<p>Control Tower is not an admission that the platform team lacks skill.</p>

<p>It may be evidence that the team is applying its skill to the business-specific layers where differentiation matters.</p>

<h2 id="landing-zone-40-makes-future-adoption-easier">Landing Zone 4.0 makes future adoption easier</h2>

<p>The choice is not permanently binary.</p>

<p>Landing Zone 4.0 supports a controls-focused environment for organisations that already have an AWS Organizations structure and primarily want managed controls. It no longer requires every customer to adopt the complete earlier landing-zone shape.</p>

<p>That gives bfstore several future paths:</p>

<ul>
  <li>keep managing the custom landing zone;</li>
  <li>use the Control Catalog for research and mapping;</li>
  <li>adopt a controls-focused environment on selected OUs;</li>
  <li>register suitable OUs under fuller governance;</li>
  <li>introduce Account Factory or AFT when account demand justifies it.</li>
</ul>

<p>Architecture decisions should be revisited when their assumptions change.</p>

<p>See <a href="https://docs.aws.amazon.com/controltower/latest/userguide/landing-zone-v4-migration-guide.html">the Landing Zone 4.0 migration guide</a>.</p>

<h2 id="my-decision-record">My decision record</h2>

<p>The bfstore decision can be expressed compactly.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">decision</span><span class="pi">:</span>
  <span class="na">service</span><span class="pi">:</span> <span class="s">AWS Control Tower</span>
  <span class="na">outcome</span><span class="pi">:</span> <span class="s">not-adopted-for-initial-landing-zone</span>

<span class="na">context</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">organisation and accounts designed explicitly</span>
  <span class="pi">-</span> <span class="s">project requires deep AWS governance learning</span>
  <span class="pi">-</span> <span class="s">account creation frequency is currently low</span>
  <span class="pi">-</span> <span class="s">custom policy-test workflow is a primary objective</span>
  <span class="pi">-</span> <span class="s">OpenTofu and TerraKube form the infrastructure direction</span>
  <span class="pi">-</span> <span class="s">existing identity and account setup is already in progress</span>
  <span class="pi">-</span> <span class="s">AWS will later be compared with Azure and GCP</span>

<span class="na">benefits_declined</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">managed landing-zone baseline</span>
  <span class="pi">-</span> <span class="s">Account Factory</span>
  <span class="pi">-</span> <span class="s">managed drift workflows</span>
  <span class="pi">-</span> <span class="s">consolidated governance dashboard</span>
  <span class="pi">-</span> <span class="s">managed control lifecycle</span>

<span class="na">responsibilities_accepted</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">account bootstrap</span>
  <span class="pi">-</span> <span class="s">policy implementation</span>
  <span class="pi">-</span> <span class="s">policy testing</span>
  <span class="pi">-</span> <span class="s">drift detection</span>
  <span class="pi">-</span> <span class="s">governance reporting</span>
  <span class="pi">-</span> <span class="s">baseline upgrades</span>
  <span class="pi">-</span> <span class="s">account lifecycle automation</span>

<span class="na">revisit_when</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">account vending becomes frequent</span>
  <span class="pi">-</span> <span class="s">team count grows</span>
  <span class="pi">-</span> <span class="s">compliance-control mappings become operationally important</span>
  <span class="pi">-</span> <span class="s">custom baseline maintenance becomes disproportionate</span>
  <span class="pi">-</span> <span class="s">controls-focused adoption offers clear value</span>
</code></pre></div></div>

<p>That is more precise than:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>We prefer doing things ourselves.
</code></pre></div></div>

<p>Preferences are not architecture.</p>

<p>Trade-offs are.</p>

<h2 id="the-mental-model-i-am-keeping">The mental model I am keeping</h2>

<p>My earlier model was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS CONTROL TOWER

automatic AWS organisation


NO CONTROL TOWER

manual AWS organisation
</code></pre></div></div>

<p>The stronger model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 GOVERNED MULTI-ACCOUNT FOUNDATION
                              │
              ┌───────────────┴───────────────┐
              │                               │
              ▼                               ▼
       AWS CONTROL TOWER              CUSTOM LANDING ZONE
              │                               │
              ▼                               ▼
      AWS-MANAGED BASELINES          CUSTOMER-MANAGED BASELINES
      MANAGED CONTROLS               CUSTOM CONTROLS
      ACCOUNT FACTORY                CUSTOM ACCOUNT WORKFLOW
      GOVERNANCE DRIFT STATUS        CUSTOM RECONCILIATION
</code></pre></div></div>

<p>AWS Organizations defines the hierarchy.</p>

<p>Control Tower assembles and operates a managed governance layer around it.</p>

<p>A custom landing zone accepts direct ownership of the same governance responsibilities.</p>

<blockquote>
  <p><strong>I did not reject Control Tower because it hides poor engineering. I declined it because it successfully hides engineering that this project exists to learn.</strong></p>
</blockquote>

<p>For bfstore today, the custom route remains intentional.</p>

<p>Control Tower stays in the architectural catalogue, understood rather than dismissed. The service solves a real problem. I have chosen, for now, to keep that problem where I can see its gears.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/what-is-control-tower.html">What is AWS Control Tower?</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/landing-zone-v4-migration-guide.html">Landing Zone 4.0 migration guide</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/config-integration-feature-comparison.html">Feature comparison with and without AWS Config integration</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/controlreference/controls.html">About AWS Control Tower controls</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/controlreference/control-behavior.html">Control behaviour and guidance</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/nested-ous.html">Nested OUs and control inheritance</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/controlreference/controls-reference.html">The AWS Control Tower Control Catalog</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/types-of-baselines.html">Types of baselines</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/governance-drift.html">Types of governance drift</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/account-factory.html">Account Factory</a></li>
  <li><a href="https://docs.aws.amazon.com/controltower/latest/userguide/aft-overview.html">Account Factory for Terraform</a></li>
  <li><a href="https://aws.amazon.com/controltower/pricing/">AWS Control Tower pricing</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[AWS Control Tower can establish and govern a multi-account landing zone using AWS Organizations, IAM Identity Center, CloudTrail, Config, Service Catalog and managed controls. bfstore chose a manually engineered landing zone to preserve explicit ownership, learning depth and a custom policy-testing workflow.]]></summary></entry><entry><title type="html">Getting AWS telemetry into a self-hosted observability platform</title><link href="https://cloud.doublewords.net/writing/2026/07/18/getting-aws-telemetry-into-a-self-hosted-observability-platform/" rel="alternate" type="text/html" title="Getting AWS telemetry into a self-hosted observability platform" /><published>2026-07-18T00:00:00+01:00</published><updated>2026-07-18T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/18/getting-aws-telemetry-into-a-self-hosted-observability-platform</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/18/getting-aws-telemetry-into-a-self-hosted-observability-platform/"><![CDATA[<p>bfstore’s application services run in Amazon EKS.</p>

<p>The platform also uses AWS-managed services and control planes:</p>

<ul>
  <li>Application Load Balancers</li>
  <li>Network Load Balancers</li>
  <li>Amazon RDS for MySQL</li>
  <li>Amazon VPC</li>
  <li>AWS Transit Gateway</li>
  <li>AWS CloudTrail</li>
  <li>AWS Config</li>
  <li>AWS IAM</li>
  <li>Amazon EKS</li>
  <li>Route 53 Resolver</li>
</ul>

<p>The platform is self-hosted.</p>

<p>Its backends may include:</p>

<ul>
  <li>a Prometheus-compatible metrics system</li>
  <li>Thanos for durable metrics</li>
  <li>ClickHouse or OpenSearch for logs and events</li>
  <li>an OpenTelemetry-compatible trace store</li>
  <li>Grafana for visualisation</li>
</ul>

<p>The first architecture sketch is tempting:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS
 │
 ▼
OpenTelemetry Collector
 │
 ▼
self-hosted observability
</code></pre></div></div>

<p>It is also too vague to build.</p>

<p>AWS telemetry does not originate through one universal interface.</p>

<p>A bfstore Go service can emit OTLP directly.</p>

<p>An EKS control plane cannot.</p>

<p>An RDS instance exposes metrics through CloudWatch and selected engine logs through CloudWatch Logs.</p>

<p>VPC and Transit Gateway Flow Logs can be delivered to CloudWatch Logs, Amazon S3 or Amazon Data Firehose.</p>

<p>CloudTrail trails primarily deliver durable event files to S3, with optional CloudWatch Logs and EventBridge integrations.</p>

<p>The phrase:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>export AWS telemetry
</code></pre></div></div>

<p>therefore hides several different jobs.</p>

<p>A stronger architecture begins by separating three source classes.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKLOAD-NATIVE TELEMETRY

bfstore application logs
application metrics
distributed traces
runtime metrics


PLATFORM TELEMETRY

Kubernetes node metrics
Pod and container metrics
cluster object state
container logs


AWS-MANAGED TELEMETRY

CloudWatch metrics
managed-service logs
flow logs
CloudTrail
AWS Config
AWS service events
</code></pre></div></div>

<blockquote>
  <p><strong>Self-hosted observability does not mean pretending CloudWatch does not exist. It means using the most appropriate AWS collection path while retaining control of the platform where telemetry is stored, correlated and analysed.</strong></p>
</blockquote>

<p>The source determines the collection path.</p>

<p>The platform determines the final destination.</p>

<h2 id="collection-transport-and-storage-are-different-decisions">Collection, transport and storage are different decisions</h2>

<p>A telemetry pipeline has several distinct stages.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SOURCE
   │
   ▼
COLLECTION
   │
   ▼
TRANSPORT
   │
   ▼
BUFFER
   │
   ▼
PROCESSING
   │
   ▼
BACKEND
</code></pre></div></div>

<h3 id="source">Source</h3>

<p>Where did the signal originate?</p>

<p>Examples:</p>

<ul>
  <li>Go application</li>
  <li>Kubernetes node</li>
  <li>EKS control plane</li>
  <li>RDS MySQL</li>
  <li>VPC network interface</li>
  <li>AWS API call</li>
</ul>

<h3 id="collection">Collection</h3>

<p>Which system can obtain the signal?</p>

<p>Examples:</p>

<ul>
  <li>OpenTelemetry SDK</li>
  <li>OpenTelemetry Collector</li>
  <li>CloudWatch</li>
  <li>VPC Flow Logs</li>
  <li>CloudTrail</li>
  <li>AWS Config</li>
</ul>

<h3 id="transport">Transport</h3>

<p>How does the signal leave the collection system?</p>

<p>Examples:</p>

<ul>
  <li>OTLP</li>
  <li>Prometheus remote write</li>
  <li>CloudWatch Logs subscription</li>
  <li>CloudWatch metric stream</li>
  <li>Kinesis Data Streams</li>
  <li>Amazon Data Firehose</li>
  <li>S3 object delivery</li>
</ul>

<h3 id="buffer">Buffer</h3>

<p>Where can telemetry wait safely when the destination is unavailable?</p>

<p>Examples:</p>

<ul>
  <li>Collector sending queue</li>
  <li>Kinesis retention</li>
  <li>Firehose buffering</li>
  <li>S3 landing bucket</li>
  <li>local persistent storage</li>
</ul>

<h3 id="processing">Processing</h3>

<p>What must happen before storage?</p>

<p>Examples:</p>

<ul>
  <li>decompression</li>
  <li>schema parsing</li>
  <li>field removal</li>
  <li>resource enrichment</li>
  <li>deduplication</li>
  <li>sampling</li>
  <li>format conversion</li>
</ul>

<h3 id="backend">Backend</h3>

<p>Which system answers future questions?</p>

<p>Examples:</p>

<ul>
  <li>Thanos</li>
  <li>ClickHouse</li>
  <li>OpenSearch</li>
  <li>trace store</li>
  <li>long-term S3 archive</li>
</ul>

<p>Treating these stages separately prevents one product from owning every concern.</p>

<p>An OpenTelemetry Collector is excellent at receiving, enriching, batching and exporting telemetry, but it is not automatically a durable broker.</p>

<p>S3 provides durable retention, not low-latency metric queries.</p>

<p>CloudWatch is the native source for many AWS-managed signals.</p>

<p>It does not have to be bfstore’s final operational interface.</p>

<h2 id="application-telemetry-should-take-the-shortest-path">Application telemetry should take the shortest path</h2>

<p>bfstore controls the code of its Go services.</p>

<p>That makes application telemetry the easiest category to design cleanly.</p>

<p>The services can emit:</p>

<ul>
  <li>traces through OTLP</li>
  <li>metrics through OTLP or Prometheus</li>
  <li>structured logs through stdout or OTLP</li>
</ul>

<p>The preferred path is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GO SERVICE
    │
    ▼
LOCAL OR NODE COLLECTOR
    │
    ▼
CLUSTER GATEWAY COLLECTOR
    │
    ▼
PRIVATE OBSERVABILITY INGEST
    │
    ▼
SELF-HOSTED BACKENDS
</code></pre></div></div>

<p>OpenTelemetry documents an agent-to-gateway pattern in which agent Collectors run close to workloads and gateway Collectors provide centralised processing and export.</p>

<p>For bfstore, the node-level collector can handle:</p>

<ul>
  <li>container log collection</li>
  <li>node metadata</li>
  <li>kubelet metrics</li>
  <li>local OTLP reception</li>
  <li>initial batching</li>
  <li>node and Pod enrichment</li>
</ul>

<p>The gateway can handle:</p>

<ul>
  <li>central filtering</li>
  <li>sensitive-field removal</li>
  <li>tenant or environment attribution</li>
  <li>retry and queue policy</li>
  <li>backend routing</li>
  <li>trace sampling</li>
</ul>

<p>Tail sampling needs one important qualification.</p>

<p>A tail-sampling processor holds spans while it decides whether to retain the completed trace. When several gateway replicas receive different spans from the same trace, each replica can make an incomplete or conflicting decision.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TRACE SPANS
     │
     ▼
TRACE-ID-AWARE ROUTING
     │
     ▼
ONE TAIL-SAMPLING COLLECTOR
     │
     ▼
SAMPLING DECISION
</code></pre></div></div>

<p>A horizontally scaled tail-sampling tier should therefore sit behind trace-ID-aware routing, commonly an OpenTelemetry load-balancing exporter. Ordinary round-robin load balancing is not enough.</p>

<p>A simplified gateway configuration might look like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">receivers</span><span class="pi">:</span>
  <span class="na">otlp</span><span class="pi">:</span>
    <span class="na">protocols</span><span class="pi">:</span>
      <span class="na">grpc</span><span class="pi">:</span>
      <span class="na">http</span><span class="pi">:</span>

<span class="na">processors</span><span class="pi">:</span>
  <span class="na">memory_limiter</span><span class="pi">:</span>
    <span class="na">check_interval</span><span class="pi">:</span> <span class="s">1s</span>
    <span class="na">limit_percentage</span><span class="pi">:</span> <span class="m">75</span>
    <span class="na">spike_limit_percentage</span><span class="pi">:</span> <span class="m">15</span>

  <span class="na">batch</span><span class="pi">:</span>
    <span class="na">timeout</span><span class="pi">:</span> <span class="s">5s</span>
    <span class="na">send_batch_size</span><span class="pi">:</span> <span class="m">4096</span>

  <span class="na">resource</span><span class="pi">:</span>
    <span class="na">attributes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">cloud.provider</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">aws</span>
        <span class="na">action</span><span class="pi">:</span> <span class="s">upsert</span>

<span class="na">exporters</span><span class="pi">:</span>
  <span class="na">otlp/metrics</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">metrics-ingest.observability.internal:4317</span>

  <span class="na">otlp/traces</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">traces-ingest.observability.internal:4317</span>

  <span class="na">otlp/logs</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">logs-ingest.observability.internal:4317</span>

<span class="na">service</span><span class="pi">:</span>
  <span class="na">pipelines</span><span class="pi">:</span>
    <span class="na">metrics</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">,</span> <span class="nv">resource</span><span class="pi">,</span> <span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp/metrics</span><span class="pi">]</span>

    <span class="na">traces</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">,</span> <span class="nv">resource</span><span class="pi">,</span> <span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp/traces</span><span class="pi">]</span>

    <span class="na">logs</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">,</span> <span class="nv">resource</span><span class="pi">,</span> <span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp/logs</span><span class="pi">]</span>
</code></pre></div></div>

<p>This example shows pipeline separation only. A production configuration also needs:</p>

<ul>
  <li>TLS or mTLS</li>
  <li>client authentication</li>
  <li>bounded retry policies</li>
  <li>exporter sending queues</li>
  <li>persistent queue storage where required</li>
  <li>Collector health checks</li>
  <li>queue and rejection monitoring</li>
</ul>

<p>The Collector’s default in-memory queue protects only short interruptions. Data can still be lost when the queue fills, retries expire or the Collector restarts. Persistent <code class="language-plaintext highlighter-rouge">file_storage</code> can preserve queued data across a Collector restart, but it still does not turn the Collector into a dedicated durable broker.</p>

<p>AWS Distro for OpenTelemetry is AWS’s supported distribution of the upstream project and can send data to OTLP-compatible and other observability backends. It does not require CloudWatch to be the destination.</p>

<p>The important principle is:</p>

<blockquote>
  <p><strong>Do not send bfstore application telemetry through CloudWatch merely to export it again when the application can send OTLP directly to the self-hosted platform.</strong></p>
</blockquote>

<p>That extra path would add:</p>

<ul>
  <li>another ingestion charge</li>
  <li>another schema conversion</li>
  <li>another delivery dependency</li>
  <li>additional delay</li>
  <li>duplicate retention</li>
</ul>

<p>CloudWatch remains valuable for AWS-managed signals.</p>

<p>Application-native telemetry can take the more direct road.</p>

<h2 id="kubernetes-telemetry-belongs-close-to-the-node">Kubernetes telemetry belongs close to the node</h2>

<p>Application instrumentation does not reveal everything happening around the process.</p>

<p>The platform also needs:</p>

<ul>
  <li>node CPU and memory</li>
  <li>filesystem capacity</li>
  <li>Pod and container resource use</li>
  <li>container restarts</li>
  <li>kubelet behaviour</li>
  <li>Kubernetes object state</li>
  <li>container stdout and stderr</li>
  <li>scheduling and rollout state</li>
</ul>

<p>A node-level Collector or equivalent agent can collect much of this.</p>

<p>OpenTelemetry’s Kubernetes guidance identifies the <code class="language-plaintext highlighter-rouge">kubeletstats</code> receiver for node, Pod and container metrics, the file-log receiver for container logs, and host-metrics collection for operating-system resources. These node-specific receivers are normally deployed in a DaemonSet so each instance observes its own node.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EKS NODE
│
├── bfstore containers
├── container log files
├── kubelet
├── node filesystem
│
└── telemetry DaemonSet
    ├── file-log receiver
    ├── kubelet metrics
    ├── host metrics
    └── OTLP receiver
</code></pre></div></div>

<p>Cluster-wide state requires another view.</p>

<p>Metrics such as:</p>

<ul>
  <li>desired versus available replicas</li>
  <li>failed Jobs</li>
  <li>pending Pods</li>
  <li>node conditions</li>
  <li>resource requests and limits</li>
  <li>deployment generations</li>
</ul>

<p>are commonly exposed through <code class="language-plaintext highlighter-rouge">kube-state-metrics</code> and scraped by a Prometheus-compatible collector.</p>

<p>The useful split is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NODE-LOCAL OBSERVATION

DaemonSet collector


CLUSTER OBJECT STATE

kube-state-metrics


APPLICATION SEMANTICS

OpenTelemetry SDK
</code></pre></div></div>

<p>These sources overlap in places.</p>

<p>The platform should choose one authoritative source for each metric family rather than collecting the same series through several agents and later wondering which copy is the original goblin.</p>

<h2 id="aws-managed-signals-should-follow-their-native-exits">AWS-managed signals should follow their native exits</h2>

<p>AWS services expose telemetry through different collection and delivery surfaces.</p>

<p>A compact source map makes the architecture clearer than pretending every service begins in CloudWatch Logs.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Native collection surface</th>
      <th>Useful exits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>EKS control plane</td>
      <td>EKS control-plane logging</td>
      <td>CloudWatch Logs subscription</td>
    </tr>
    <tr>
      <td>RDS engine logs</td>
      <td>RDS log export</td>
      <td>CloudWatch Logs subscription</td>
    </tr>
    <tr>
      <td>AWS service metrics</td>
      <td>CloudWatch metrics</td>
      <td>Metric stream or API polling</td>
    </tr>
    <tr>
      <td>ALB logs</td>
      <td>ELB vended logging</td>
      <td>CloudWatch Logs, Firehose or S3</td>
    </tr>
    <tr>
      <td>NLB logs</td>
      <td>ELB vended logging</td>
      <td>CloudWatch Logs, Firehose or S3</td>
    </tr>
    <tr>
      <td>VPC Flow Logs</td>
      <td>VPC Flow Logs</td>
      <td>CloudWatch Logs, Firehose or S3</td>
    </tr>
    <tr>
      <td>Transit Gateway Flow Logs</td>
      <td>Transit Gateway Flow Logs</td>
      <td>CloudWatch Logs, Firehose or S3</td>
    </tr>
    <tr>
      <td>Route 53 Resolver queries</td>
      <td>Resolver query logging</td>
      <td>CloudWatch Logs, Firehose or S3</td>
    </tr>
    <tr>
      <td>IAM and AWS API activity</td>
      <td>CloudTrail</td>
      <td>S3, CloudWatch Logs or EventBridge</td>
    </tr>
    <tr>
      <td>Resource configuration</td>
      <td>AWS Config</td>
      <td>S3 and SNS</td>
    </tr>
  </tbody>
</table>

<p>The reusable patterns are:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS SERVICE
    → CLOUDWATCH LOGS
    → SUBSCRIPTION
    → KINESIS OR FIREHOSE


AWS SERVICE
    → FIREHOSE
    → S3 OR HTTPS ADAPTER


AWS SERVICE
    → S3
    → SQS
    → PRIVATE IMPORTER
</code></pre></div></div>

<h3 id="eks-control-plane-logs">EKS control-plane logs</h3>

<p>bfstore does not operate the EKS control-plane hosts.</p>

<p>The cluster can export five control-plane log types:</p>

<ul>
  <li>API server</li>
  <li>audit</li>
  <li>authenticator</li>
  <li>controller manager</li>
  <li>scheduler</li>
</ul>

<p>Amazon EKS sends enabled control-plane logs to CloudWatch Logs. The log types are disabled by default and can be enabled independently.</p>

<p>For bfstore, I would enable at least:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">audit</code></li>
  <li><code class="language-plaintext highlighter-rouge">authenticator</code></li>
  <li><code class="language-plaintext highlighter-rouge">api</code></li>
</ul>

<p>The controller-manager and scheduler logs should also be enabled where their operational value justifies the volume.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">eks_control_plane_logging</span><span class="pi">:</span>
  <span class="na">enabled</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">api</span>
    <span class="pi">-</span> <span class="s">audit</span>
    <span class="pi">-</span> <span class="s">authenticator</span>
    <span class="pi">-</span> <span class="s">controllerManager</span>
    <span class="pi">-</span> <span class="s">scheduler</span>

  <span class="na">cloudwatch_retention_days</span><span class="pi">:</span> <span class="m">14</span>
  <span class="na">export_to_observability</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">archive_policy</span><span class="pi">:</span> <span class="s">security-long-term</span>
</code></pre></div></div>

<p>CloudWatch is the collection surface here.</p>

<p>It does not have to become the permanent analytical home.</p>

<h3 id="rds-logs">RDS logs</h3>

<p>RDS can publish selected database logs to CloudWatch Logs.</p>

<p>For MySQL, this can include:</p>

<ul>
  <li>error log</li>
  <li>general log</li>
  <li>slow-query log</li>
  <li>audit log when the required option is configured</li>
</ul>

<p>RDS Enhanced Monitoring also writes operating-system metrics into CloudWatch Logs rather than ordinary CloudWatch metric series. Those records need to enter the log pipeline and be parsed into metrics if bfstore wants them in the metrics backend.</p>

<p>This is a useful reminder:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SIGNAL MEANING

does not always equal

AWS TRANSPORT TYPE
</code></pre></div></div>

<p>An operating-system metric can arrive inside a log stream.</p>

<h3 id="load-balancer-logs">Load-balancer logs</h3>

<blockquote>
  <p><strong>Publication note:</strong> When this article was originally published on 18 July 2026, the established ALB access-log path was S3. AWS added vended ALB delivery to CloudWatch Logs, Amazon Data Firehose and S3 on 23 July 2026.</p>
</blockquote>

<p>The current ALB integration covers access, connection and health-check logs.</p>

<p>NLB also provides vended delivery to CloudWatch Logs, Firehose and S3, while its legacy S3 path remains available. The choice depends on latency, cost and retention.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LOWER-LATENCY SEARCH

ALB OR NLB
    → CLOUDWATCH LOGS
    → SUBSCRIPTION


MANAGED DELIVERY

ALB OR NLB
    → FIREHOSE
    → S3 OR ADAPTER


DURABLE BATCH

ALB OR NLB
    → S3
    → SQS IMPORTER
</code></pre></div></div>

<h3 id="flow-logs-and-resolver-query-logs">Flow logs and Resolver query logs</h3>

<p>VPC Flow Logs, Transit Gateway Flow Logs and Route 53 Resolver query logs can all deliver to CloudWatch Logs, Firehose or S3.</p>

<p>For high-volume network records, I would avoid CloudWatch Logs unless CloudWatch querying or subscription processing is specifically useful.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FLOW OR DNS QUERY LOGS
          │
          ▼
      FIREHOSE OR S3
          │
          ▼
    SPECIALISED PARSER
          │
          ▼
CLICKHOUSE / SECURITY ANALYTICS
</code></pre></div></div>

<p>Flow logs are collected outside the packet path and do not affect traffic throughput or latency.</p>

<p>They are also aggregated and delivered on a best-effort basis.</p>

<p>They are excellent network evidence.</p>

<p>They are not packet capture.</p>

<h2 id="exporting-aws-managed-metrics">Exporting AWS-managed metrics</h2>

<p>Many AWS services publish operational metrics to CloudWatch.</p>

<p>Examples include:</p>

<ul>
  <li>ALB request and target metrics</li>
  <li>NLB flow metrics</li>
  <li>RDS CPU, storage and connection metrics</li>
  <li>NAT gateway errors and bytes</li>
  <li>Transit Gateway metrics</li>
  <li>EBS volume metrics</li>
  <li>S3 request metrics where enabled</li>
  <li>EKS and EC2 infrastructure metrics</li>
</ul>

<p>There are two primary export models.</p>

<h3 id="stream-broadly">Stream broadly</h3>

<p>CloudWatch metric streams continually send selected metrics to Amazon Data Firehose.</p>

<p>Metric streams support JSON and OpenTelemetry formats, including OpenTelemetry 1.0.0. The Firehose delivery stream exists in the same account and Region as the metric stream, although its final destination can be elsewhere.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLOUDWATCH METRICS
        │
        ▼
METRIC STREAM
        │
        ▼
AMAZON DATA FIREHOSE
        │
        ▼
S3 OR HTTPS ADAPTER
        │
        ▼
NORMALISATION
        │
        ▼
SELF-HOSTED METRICS
</code></pre></div></div>

<p>Streaming is the stronger choice when bfstore needs:</p>

<ul>
  <li>broad namespace coverage</li>
  <li>low delivery latency</li>
  <li>automatic inclusion of matching new metrics</li>
  <li>no repeated metric discovery</li>
</ul>

<p>Metric-stream payloads still need an ingestion component that understands the selected output format and maps AWS dimensions into the target model.</p>

<p>The words <strong>OpenTelemetry format</strong> do not mean that Firehose sends ordinary OTLP requests to any OTLP endpoint.</p>

<p>The consumer must implement the Firehose delivery contract and the metric-stream record format it actually receives.</p>

<h3 id="poll-selectively">Poll selectively</h3>

<p>A service can periodically call CloudWatch APIs such as <code class="language-plaintext highlighter-rouge">ListMetrics</code> and <code class="language-plaintext highlighter-rouge">GetMetricData</code>.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SELF-HOSTED COLLECTOR
        │
        ▼
CLOUDWATCH API
        │
        ▼
GET METRIC DATA
        │
        ▼
PROMETHEUS-COMPATIBLE SERIES
</code></pre></div></div>

<p>Polling may be suitable when:</p>

<ul>
  <li>the metric set is small</li>
  <li>a mature CloudWatch exporter already exists</li>
  <li>delivery can be delayed by the scrape interval</li>
  <li>bfstore wants precise metric selection</li>
  <li>a streaming decoder would be premature</li>
</ul>

<p>Polling becomes harder as the estate grows.</p>

<p>It introduces:</p>

<ul>
  <li>API request volume</li>
  <li>metric and dimension discovery</li>
  <li>delayed retrieval</li>
  <li>repeated historical windows</li>
  <li>duplicate-point handling</li>
  <li>cross-account credential management</li>
  <li>missing-series detection</li>
</ul>

<p>The decision is not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>streaming is modern

polling is old
</code></pre></div></div>

<p>It is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>STREAM

for broad continuous export


POLL

for a curated metric set
or an early implementation
</code></pre></div></div>

<h3 id="multi-account-metric-options">Multi-account metric options</h3>

<p>bfstore’s AWS estate spans several accounts.</p>

<p>Three CloudWatch mechanisms should not be treated as interchangeable.</p>

<h4 id="source-account-metric-streams">Source-account metric streams</h4>

<p>Each account owns its own metric stream and Firehose delivery path.</p>

<p>Advantages:</p>

<ul>
  <li>explicit source ownership</li>
  <li>independent failure domains</li>
  <li>straightforward account boundaries</li>
</ul>

<p>Costs:</p>

<ul>
  <li>repeated configuration</li>
  <li>more delivery streams</li>
  <li>more export monitoring</li>
</ul>

<h4 id="cross-account-observability-with-a-monitoring-account-stream">Cross-account observability with a monitoring-account stream</h4>

<p>CloudWatch cross-account observability links source accounts to a monitoring account within a Region.</p>

<p>A metric stream created in the monitoring account can include metrics from linked source accounts. This is the clearest central streaming option when bfstore needs AWS-vended service metrics from several accounts without creating a separate stream in every source account.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SOURCE ACCOUNTS
      │
      ▼
CROSS-ACCOUNT OBSERVABILITY
      │
      ▼
MONITORING ACCOUNT
METRIC STREAM
      │
      ▼
FIREHOSE
</code></pre></div></div>

<p>This still creates a central policy and failure boundary.</p>

<p>The stream also remains Regional.</p>

<h4 id="metrics-centralization">Metrics Centralization</h4>

<p>CloudWatch Metrics Centralization copies metric data from selected AWS Organization accounts and Regions into a destination account while preserving source account and Region metadata.</p>

<p>The current documentation states that all metrics from the selected source accounts are centralized and explicitly identifies custom metrics, Embedded Metric Format metrics and OpenTelemetry metrics. Selective metric filtering is not currently supported.</p>

<p>Centralized metrics can be used by CloudWatch features including metric streams.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SELECTED ORGANIZATION ACCOUNTS
             │
             ▼
METRICS CENTRALIZATION
             │
             ▼
DESTINATION ACCOUNT COPY
             │
             ▼
OPTIONAL METRIC STREAM
</code></pre></div></div>

<p>This is a replication model, not merely permission to view source-account metrics. Use it when a CloudWatch-owned central copy is valuable and centralizing every supported metric from the selected accounts is acceptable. It is not a universal replacement for source-account streams or cross-account observability.</p>

<p>The architecture should compare:</p>

<ul>
  <li>whether metrics are viewed or copied</li>
  <li>whether filtering is required</li>
  <li>whether AWS-vended metrics are in scope</li>
  <li>Regional coverage</li>
  <li>stream ownership</li>
  <li>replication cost</li>
  <li>shared failure boundaries</li>
</ul>

<h2 id="firehose-is-a-useful-buffer-with-one-private-network-catch">Firehose is a useful buffer, with one private-network catch</h2>

<p>Amazon Data Firehose can buffer, transform and deliver records to several destinations.</p>

<p>For custom HTTP delivery, it can:</p>

<ul>
  <li>send HTTPS requests</li>
  <li>retry failed delivery for a configured duration</li>
  <li>back up failed or all data to S3</li>
  <li>record delivery errors in CloudWatch Logs</li>
  <li>retrieve destination credentials from Secrets Manager</li>
</ul>

<p>When retry duration expires for an HTTP endpoint, Firehose writes failed data to the configured S3 backup location.</p>

<p>That makes it attractive as a managed edge buffer.</p>

<p>However, Firehose does not support delivery to custom HTTP endpoints inside a VPC. Custom HTTP delivery uses HTTPS on port 443 and requires a Firehose-compatible request and response contract.</p>

<p>This rules out the deceptively neat design:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIREHOSE
    │
    ▼
PRIVATE OTLP ENDPOINT
inside observability VPC
</code></pre></div></div>

<p>Firehose cannot simply call that private endpoint.</p>

<p>bfstore has three practical options.</p>

<h3 id="s3-landing-zone">S3 landing zone</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIREHOSE
    │
    ▼
S3 LANDING BUCKET
    │
    ▼
S3 EVENT
    │
    ▼
SQS
    │
    ▼
INGESTION WORKERS
    │
    ▼
SELF-HOSTED BACKEND
</code></pre></div></div>

<p>This provides durable replay and a private backend, at the cost of additional latency and importer logic.</p>

<h3 id="public-hardened-adapter">Public hardened adapter</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIREHOSE
    │
    ▼
PUBLIC HTTPS ADAPTER
    │
    ▼
PRIVATE OBSERVABILITY PIPELINE
</code></pre></div></div>

<p>The adapter is not the backend itself.</p>

<p>It implements:</p>

<ul>
  <li>Firehose request contract</li>
  <li>authentication</li>
  <li>request deduplication</li>
  <li>decompression</li>
  <li>payload validation</li>
  <li>rate limiting</li>
  <li>private forwarding</li>
</ul>

<h3 id="avoid-firehose-http-delivery">Avoid Firehose HTTP delivery</h3>

<p>For CloudWatch Logs, use Kinesis Data Streams and run private consumers.</p>

<p>For a small metric set, poll CloudWatch instead of creating a metric stream.</p>

<p>Metric streams require Firehose, so a private near-real-time metric destination still needs an S3 landing path, a controlled public adapter or a different export model.</p>

<p>The constraint belongs in the design early.</p>

<p>It should not be discovered after bfstore has built a beautiful internal OTLP endpoint that Firehose can admire only from afar.</p>

<h2 id="exporting-aws-logs-events-and-durable-evidence">Exporting AWS logs, events and durable evidence</h2>

<h3 id="cloudwatch-logs-subscriptions">CloudWatch Logs subscriptions</h3>

<p>CloudWatch Logs subscriptions can forward newly ingested events to:</p>

<ul>
  <li>Kinesis Data Streams</li>
  <li>Amazon Data Firehose</li>
  <li>Lambda</li>
  <li>Amazon OpenSearch Service</li>
</ul>

<p>The subscription payload is Base64-encoded and GZIP-compressed.</p>

<p>Delivery is at least once, so duplicate events can occur. Retryable failures are retried for a limited period, after which undelivered events can be dropped.</p>

<p>A private self-hosted path can use Kinesis:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLOUDWATCH LOG GROUPS
        │
        ▼
ACCOUNT-LEVEL SUBSCRIPTION
        │
        ▼
CROSS-ACCOUNT KINESIS STREAM
        │
        ▼
PRIVATE CONSUMER
        │
        ▼
DECOMPRESS AND NORMALISE
        │
        ▼
CLICKHOUSE OR OPENSEARCH
</code></pre></div></div>

<p>The consumer must decode and decompress the payload, parse the CloudWatch Logs envelope, preserve source metadata, deduplicate where useful, submit events to the backend and checkpoint only after durable acceptance.</p>

<p>Account-level subscriptions reduce the chance that a newly created log group remains unexported.</p>

<p>They also increase the risk of:</p>

<ul>
  <li>sending unwanted high-volume logs</li>
  <li>exceeding stream capacity</li>
  <li>creating recursive logging loops</li>
</ul>

<p>The export workflow’s own log groups must be excluded, otherwise the pipeline can ingest its own operational logs and manufacture a surprisingly energetic invoice.</p>

<h3 id="central-searchable-logs-and-the-archive-are-different-systems">Central searchable logs and the archive are different systems</h3>

<p>bfstore already has separate accounts for:</p>

<ul>
  <li>log archive</li>
  <li>observability</li>
</ul>

<p>Those accounts should not collapse into one vague “logging account”.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS SOURCE
    │
    ├──────────► LOG ARCHIVE
    │             durable evidence
    │             restricted mutation
    │
    └──────────► OBSERVABILITY
                  indexed and queryable
                  operational retention
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bfstore-observability</code> exists for:</p>

<ul>
  <li>dashboards</li>
  <li>alerts</li>
  <li>operational search</li>
  <li>correlations</li>
  <li>service troubleshooting</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">bfstore-log-archive</code> exists for:</p>

<ul>
  <li>durable security evidence</li>
  <li>policy-driven retention</li>
  <li>restricted deletion</li>
  <li>central CloudTrail files</li>
  <li>AWS Config history</li>
  <li>selected network and service records</li>
  <li>forensic recovery</li>
</ul>

<p>An S3 bucket is not immutable merely because it lives in a log-archive account.</p>

<p>Where immutability is required, the design should explicitly include:</p>

<ul>
  <li>S3 Versioning</li>
  <li>S3 Object Lock</li>
  <li>compliance-mode retention where policy requires it</li>
  <li>restricted bucket and KMS administration</li>
  <li>lifecycle controls</li>
  <li>CloudTrail log-file integrity validation</li>
</ul>

<p>Object Lock prevents protected object versions from being overwritten or deleted for the retention period. CloudTrail integrity validation helps detect whether delivered files were changed or deleted. It does not itself prevent modification.</p>

<p>Searchability is not durability.</p>

<p>Durability is not immutability.</p>

<p>Each property needs its own control.</p>

<h3 id="cloudtrail-and-aws-config">CloudTrail and AWS Config</h3>

<p>The primary CloudTrail path should remain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS API ACTIVITY
       │
       ▼
ORGANISATION CLOUDTRAIL
       │
       ▼
bfstore-log-archive S3
</code></pre></div></div>

<p>Selected operational events can also travel through CloudWatch Logs or EventBridge.</p>

<p>The indexed copy improves search and correlation.</p>

<p>It should not replace the authoritative S3 trail and digest files.</p>

<p>AWS Config records resource configuration state and change history.</p>

<p>Its delivery channel can send:</p>

<ul>
  <li>configuration histories and snapshots to S3</li>
  <li>change notifications to SNS</li>
</ul>

<p>Config records answer questions such as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which security-group rule changed
before the connection failures began?

When was this bucket policy altered?

Which resource configuration existed
during the incident?
</code></pre></div></div>

<p>They are structured evidence, not ordinary application logs.</p>

<h2 id="the-ingestion-platform-must-provide-guarantees">The ingestion platform must provide guarantees</h2>

<p>The observability platform will sometimes fail.</p>

<p>Reasons include:</p>

<ul>
  <li>upgrade</li>
  <li>cluster failure</li>
  <li>network partition</li>
  <li>backend overload</li>
  <li>storage outage</li>
  <li>certificate problem</li>
  <li>schema rejection</li>
</ul>

<p>The source workload should not fail merely because the telemetry destination is unavailable.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>APPLICATION
    │
    ▼
TELEMETRY PIPELINE FAILURE
    │
    ▼
APPLICATION CONTINUES
within defined limits
</code></pre></div></div>

<h3 id="bounded-buffering-and-replay">Bounded buffering and replay</h3>

<p>A mature design often uses two levels.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SHORT INTERRUPTION

Collector queue


LONGER INTERRUPTION

Kinesis or S3
</code></pre></div></div>

<p>Collector queues are useful for brief failures.</p>

<p>Kinesis provides a replayable near-real-time stream with consumer lag and checkpoint responsibilities.</p>

<p>Firehose provides managed buffering, retry and S3 backup.</p>

<p>S3 provides the durable landing zone and the clearest replay source.</p>

<p>No buffer should grow without a limit.</p>

<p>An observability outage should not become a workload outage through an infinitely enthusiastic telemetry queue.</p>

<h3 id="at-least-once-delivery-and-deduplication">At-least-once delivery and deduplication</h3>

<p>CloudWatch Logs subscriptions, Firehose, S3 notifications, Kinesis consumers and OpenTelemetry exporters can all retry.</p>

<p>A record may therefore arrive more than once.</p>

<p>A useful CloudWatch log identity can combine:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>source account
source Region
log group
log stream
CloudWatch event ID
</code></pre></div></div>

<p>A Firehose adapter can retain the stable request ID used across retries.</p>

<p>Trace spans carry trace and span identifiers.</p>

<p>Metric duplicates need series, timestamp and value-aware handling.</p>

<p>The platform should state explicitly:</p>

<ul>
  <li>where deduplication happens</li>
  <li>which signals are deduplicated</li>
  <li>how long the deduplication window lasts</li>
  <li>what happens when the same timestamp carries conflicting values</li>
</ul>

<p>At-least-once delivery is a reliability feature.</p>

<p>It moves exactly-once fantasies into the consumer’s data model, where they can be dealt with honestly.</p>

<h3 id="source-identity-and-schema">Source identity and schema</h3>

<p>Every AWS record should preserve enough context to answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which account?

Which Region?

Which environment?

Which service?

Which cluster?

Which resource?

Which original source?
</code></pre></div></div>

<p>A common envelope could include:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resource</span><span class="pi">:</span>
  <span class="na">cloud.provider</span><span class="pi">:</span> <span class="s">aws</span>
  <span class="na">cloud.account.id</span><span class="pi">:</span> <span class="s2">"</span><span class="s">123456789012"</span>
  <span class="na">cloud.region</span><span class="pi">:</span> <span class="s">eu-west-2</span>
  <span class="na">deployment.environment.name</span><span class="pi">:</span> <span class="s">production</span>
  <span class="na">service.name</span><span class="pi">:</span> <span class="s">order-service</span>
  <span class="na">service.namespace</span><span class="pi">:</span> <span class="s">bfstore</span>
  <span class="na">k8s.cluster.name</span><span class="pi">:</span> <span class="s">bfstore-prod</span>

<span class="na">source</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">aws.eks.audit</span>
  <span class="na">aws.log.group</span><span class="pi">:</span> <span class="s">/aws/eks/bfstore-prod/cluster</span>
  <span class="na">pipeline</span><span class="pi">:</span> <span class="s">cloudwatch-kinesis</span>
</code></pre></div></div>

<p>The common envelope enables cross-source queries.</p>

<p>The original payload preserves source-specific evidence.</p>

<p>Normalisation should not discard fields merely because the first shared schema does not understand them.</p>

<h3 id="cardinality-controls">Cardinality controls</h3>

<p>Useful AWS and application dimensions include:</p>

<ul>
  <li>load balancer</li>
  <li>target group</li>
  <li>database instance</li>
  <li>Availability Zone</li>
  <li>service</li>
  <li>route</li>
  <li>status</li>
  <li>cluster</li>
  <li>namespace</li>
</ul>

<p>Dangerous metric labels include:</p>

<ul>
  <li>customer ID</li>
  <li>order ID</li>
  <li>trace ID</li>
  <li>request ID</li>
  <li>full URL</li>
  <li>exception message</li>
  <li>unbounded SQL text</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>METRIC LABEL

bounded grouping field


LOG FIELD

searchable event context


TRACE ATTRIBUTE

request-scoped diagnostic context
</code></pre></div></div>

<p>An order ID is useful in a trace or log.</p>

<p>It is usually catastrophic as a Prometheus label.</p>

<p>Self-hosting does not make cardinality free.</p>

<p>It changes the person receiving the storage invoice and the 03:00 alert.</p>

<h3 id="security-and-cost">Security and cost</h3>

<p>Private OTLP paths still need authentication.</p>

<p>A private route proves network reachability.</p>

<p>It does not prove the caller is an authorised producer.</p>

<p>The ingestion boundary should use:</p>

<ul>
  <li>mTLS or short-lived workload identity</li>
  <li>scoped IAM roles</li>
  <li>narrow S3 prefixes and KMS permissions</li>
  <li>request validation</li>
  <li>sensitive-field redaction</li>
  <li>source-specific retention</li>
  <li>access auditing for security telemetry</li>
</ul>

<p>The architecture should also count every handoff.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EKS AUDIT EVENT
      │
      ▼
CLOUDWATCH INGESTION
      │
      ▼
CLOUDWATCH RETENTION
      │
      ▼
SUBSCRIPTION DELIVERY
      │
      ▼
KINESIS
      │
      ▼
SELF-HOSTED STORAGE
</code></pre></div></div>

<p>Each copy should have a purpose.</p>

<p>A duplicate without a purpose is just telemetry practising mitosis on the invoice.</p>

<h2 id="my-proposed-bfstore-architecture">My proposed bfstore architecture</h2>

<p>I would use several ingestion lanes rather than one universal pipe.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE APPLICATIONS
      │
      ▼
OTEL AGENTS AND GATEWAYS
      │
      ▼
PRIVATE OTLP INGEST
      │
      ▼
SELF-HOSTED BACKENDS


AWS CLOUDWATCH METRICS
      │
      ▼
METRIC STREAM OR POLLER
      │
      ▼
NORMALISATION
      │
      ▼
METRICS BACKEND


AWS CLOUDWATCH LOGS
      │
      ▼
CENTRAL SUBSCRIPTION
      │
      ▼
KINESIS
      │
      ▼
PRIVATE LOG CONSUMERS
      │
      ▼
LOG BACKEND


AWS DURABLE SOURCES
      │
      ├── CLOUDTRAIL
      ├── CONFIG
      ├── LOAD-BALANCER LOGS
      ├── FLOW LOGS
      └── RESOLVER QUERY LOGS
              │
              ▼
          CENTRAL S3
              │
              ▼
         SQS IMPORTERS
              │
              ▼
    SEARCH AND ARCHIVE SYSTEMS
</code></pre></div></div>

<h3 id="lane-1-application-and-kubernetes-telemetry">Lane 1: Application and Kubernetes telemetry</h3>

<ul>
  <li>Go services instrumented with OpenTelemetry</li>
  <li>Collector DaemonSet on each EKS node</li>
  <li>gateway Collectors</li>
  <li>trace-ID-aware routing before tail sampling</li>
  <li>private OTLP ingestion</li>
  <li>direct export to self-hosted metrics, log and trace backends</li>
  <li>no CloudWatch round trip</li>
</ul>

<h3 id="lane-2-aws-service-metrics">Lane 2: AWS service metrics</h3>

<p>Initial phase:</p>

<ul>
  <li>curated CloudWatch polling</li>
  <li>limited namespaces</li>
  <li>explicit metric inventory</li>
</ul>

<p>Later phase:</p>

<ul>
  <li>cross-account observability into <code class="language-plaintext highlighter-rouge">bfstore-observability</code></li>
  <li>central monitoring-account metric stream for broad AWS metrics</li>
  <li>Firehose to an S3 landing zone</li>
  <li>private importer converts records into the self-hosted metric model</li>
</ul>

<p>Metrics Centralization would be evaluated separately where a CloudWatch-owned copy of all supported metrics from selected accounts and Regions is genuinely required.</p>

<h3 id="lane-3-managed-service-logs">Lane 3: Managed-service logs</h3>

<ul>
  <li>EKS control-plane and RDS logs to CloudWatch Logs</li>
  <li>selected service logs to CloudWatch Logs</li>
  <li>organization-aware subscription into central Kinesis</li>
  <li>consumers in the observability cluster</li>
  <li>source metadata retained</li>
  <li>duplicates tolerated and removed where useful</li>
</ul>

<h3 id="lane-4-network-and-dns-telemetry">Lane 4: Network and DNS telemetry</h3>

<ul>
  <li>VPC Flow Logs to Firehose or S3</li>
  <li>Transit Gateway Flow Logs to Firehose or S3</li>
  <li>Route 53 Resolver query logs to Firehose or S3</li>
  <li>source-specific schemas and parsers</li>
  <li>indexed operational copy</li>
  <li>durable archive copy</li>
</ul>

<h3 id="lane-5-security-and-configuration-evidence">Lane 5: Security and configuration evidence</h3>

<ul>
  <li>organization CloudTrail to <code class="language-plaintext highlighter-rouge">bfstore-log-archive</code></li>
  <li>log-file integrity validation</li>
  <li>S3 Object Lock where immutability is required</li>
  <li>selected CloudTrail events copied into the operational backend</li>
  <li>AWS Config histories and snapshots to archive S3</li>
  <li>Config change notifications ingested as structured events</li>
</ul>

<p>This design uses AWS services where they are the only or strongest source.</p>

<p>It keeps the analytical destination self-hosted.</p>

<h2 id="the-pipeline-needs-its-own-slos">The pipeline needs its own SLOs</h2>

<p>An observability platform cannot be trusted merely because its Grafana home page loads.</p>

<p>The ingestion system needs service-level indicators.</p>

<h3 id="application-otlp">Application OTLP</h3>

<ul>
  <li>accepted spans per second</li>
  <li>rejected telemetry</li>
  <li>exporter queue length</li>
  <li>export failure rate</li>
  <li>end-to-end delay</li>
</ul>

<h3 id="cloudwatch-logs-export">CloudWatch Logs export</h3>

<ul>
  <li>subscription delivery throttling</li>
  <li>Kinesis iterator age</li>
  <li>consumer lag</li>
  <li>decode failures</li>
  <li>duplicate rate</li>
  <li>last event received per source</li>
</ul>

<h3 id="metric-streams">Metric streams</h3>

<ul>
  <li>Firehose delivery freshness</li>
  <li>S3 backup count</li>
  <li>decoder failures</li>
  <li>sample delay</li>
  <li>missing expected namespaces</li>
</ul>

<h3 id="s3-imports">S3 imports</h3>

<ul>
  <li>oldest unprocessed object</li>
  <li>SQS queue age</li>
  <li>failed object count</li>
  <li>replay backlog</li>
  <li>parser-version mismatch</li>
</ul>

<h3 id="source-coverage">Source coverage</h3>

<ul>
  <li>expected log groups exporting</li>
  <li>expected EKS clusters reporting</li>
  <li>expected accounts represented</li>
  <li>expected Regions represented</li>
  <li>expected CloudTrail files arriving</li>
</ul>

<p>A meta-signal is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time since last telemetry
from every expected source
</code></pre></div></div>

<p>A green ingestion service means little when one production account quietly stopped sending logs three days ago.</p>

<p>The platform should detect absence.</p>

<p>Silence is sometimes excellent news.</p>

<p>In telemetry pipelines, it is often a severed wire wearing a calm expression.</p>

<h2 id="a-staged-implementation-path">A staged implementation path</h2>

<h3 id="phase-1-application-and-kubernetes-telemetry">Phase 1: Application and Kubernetes telemetry</h3>

<ul>
  <li>instrument two bfstore services</li>
  <li>deploy Collector DaemonSet and gateway</li>
  <li>configure private, authenticated OTLP</li>
  <li>establish common resource attributes</li>
  <li>validate loss, retry and restart behaviour</li>
  <li>add node, kubelet and cluster-state metrics</li>
</ul>

<h3 id="phase-2-eks-and-selected-managed-logs">Phase 2: EKS and selected managed logs</h3>

<ul>
  <li>enable EKS audit, API and authenticator logs</li>
  <li>publish selected RDS logs</li>
  <li>create CloudWatch Logs subscriptions</li>
  <li>run private Kinesis consumers</li>
  <li>preserve source metadata</li>
  <li>exclude export-pipeline log groups</li>
</ul>

<h3 id="phase-3-selected-aws-metrics">Phase 3: Selected AWS metrics</h3>

<p>Begin with:</p>

<ul>
  <li>ALB</li>
  <li>NLB</li>
  <li>RDS</li>
  <li>NAT Gateway</li>
  <li>Transit Gateway</li>
  <li>EBS</li>
</ul>

<p>Use polling initially when it provides the quickest testable route.</p>

<p>Measure API cost, delay and maintenance burden.</p>

<h3 id="phase-4-durable-aws-sources">Phase 4: Durable AWS sources</h3>

<ul>
  <li>organization CloudTrail</li>
  <li>AWS Config</li>
  <li>VPC Flow Logs</li>
  <li>Transit Gateway Flow Logs</li>
  <li>Route 53 Resolver query logs</li>
  <li>load-balancer logs</li>
</ul>

<p>Use central S3, SQS and replayable importers where batch ingestion is appropriate.</p>

<h3 id="phase-5-broader-metric-streaming">Phase 5: Broader metric streaming</h3>

<p>Introduce cross-account observability and a monitoring-account metric stream when:</p>

<ul>
  <li>the curated poller becomes difficult to maintain</li>
  <li>broader namespace coverage is required</li>
  <li>delivery latency matters</li>
  <li>the Firehose landing and decoding path has been tested</li>
</ul>

<p>Evaluate Metrics Centralization only as a separate replication decision.</p>

<h3 id="phase-6-test-failure">Phase 6: Test failure</h3>

<p>Simulate:</p>

<ul>
  <li>backend outage</li>
  <li>Collector restart</li>
  <li>tail-sampling routing failure</li>
  <li>Kinesis consumer failure</li>
  <li>Firehose delivery failure</li>
  <li>malformed record</li>
  <li>duplicate event</li>
  <li>S3 replay</li>
  <li>one source account stopping export</li>
</ul>

<p>The pipeline is ready when telemetry failure is:</p>

<ul>
  <li>detected</li>
  <li>bounded</li>
  <li>recoverable</li>
  <li>unable to break the applications it observes</li>
</ul>

<h2 id="the-mental-model-i-am-keeping">The mental model I am keeping</h2>

<p>My earlier model was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS TELEMETRY
      │
      ▼
CLOUDWATCH
      │
      ▼
GRAFANA
</code></pre></div></div>

<p>The stronger model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         TELEMETRY SOURCE
                                │
              ┌─────────────────┼─────────────────┐
              │                 │                 │
              ▼                 ▼                 ▼
         APPLICATION        KUBERNETES       AWS-MANAGED
              │                 │                 │
              ▼                 ▼                 ▼
             OTLP         NODE COLLECTOR      NATIVE AWS
                                                COLLECTION
              │                 │                 │
              └─────────────────┼─────────────────┘
                                ▼
                         TRANSPORT CHOICE
                                │
        ┌───────────────┬───────┼────────┬───────────────┐
        │               │       │        │               │
        ▼               ▼       ▼        ▼               ▼
       OTLP          KINESIS  FIREHOSE    S3         API POLLING
        │               │       │        │               │
        └───────────────┴───────┼────────┴───────────────┘
                                ▼
                         DURABLE BUFFER
                                │
                                ▼
                           PROCESSING
                                │
                 ┌──────────────┼──────────────┐
                 │              │              │
                 ▼              ▼              ▼
             ENRICHMENT     DEDUPLICATION    REDACTION
                 │              │              │
                 └──────────────┼──────────────┘
                                ▼
                       SELF-HOSTED BACKENDS
                                │
                 ┌──────────────┼──────────────┐
                 │              │              │
                 ▼              ▼              ▼
              METRICS          LOGS          TRACES
                                │
                                ▼
                             EVIDENCE
</code></pre></div></div>

<p>OpenTelemetry answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How should bfstore-owned
applications describe telemetry?
</code></pre></div></div>

<p>The node Collector answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How does the platform observe
what surrounds the process?
</code></pre></div></div>

<p>CloudWatch answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where does AWS expose telemetry
for managed resources?
</code></pre></div></div>

<p>A metric stream answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How can broad CloudWatch metrics
leave continuously?
</code></pre></div></div>

<p>A subscription answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How can newly ingested CloudWatch
log events enter another stream?
</code></pre></div></div>

<p>Kinesis answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where can near-real-time logs wait
for a private consumer?
</code></pre></div></div>

<p>Firehose answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where can AWS manage buffering,
batching, retry and delivery?
</code></pre></div></div>

<p>S3 answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which telemetry can be replayed
after the analytical system fails?
</code></pre></div></div>

<p>Object Lock answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which protected archive objects
must resist deletion or overwrite?
</code></pre></div></div>

<p>The self-hosted platform answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where are AWS, Kubernetes and
application signals queried together?
</code></pre></div></div>

<p>And the pipeline SLO answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How do we know the observer
has not quietly gone blind?
</code></pre></div></div>

<p>Getting AWS telemetry into a self-hosted platform does not require rejecting AWS-native observability mechanisms.</p>

<p>It requires refusing to confuse <strong>collection</strong> with <strong>destination</strong>.</p>

<blockquote>
  <p><strong>CloudWatch can be the source without being the home. OpenTelemetry can be the common language without being the durable queue. S3 can be the archive without being the dashboard.</strong></p>
</blockquote>

<p>bfstore application traces can travel directly through OTLP.</p>

<p>Kubernetes telemetry can be collected beside the workloads.</p>

<p>EKS control-plane and RDS logs can leave CloudWatch through subscriptions.</p>

<p>AWS metrics can be streamed or polled.</p>

<p>Flow logs and Resolver query logs can land in Firehose or S3.</p>

<p>CloudTrail and Config can retain authoritative evidence in the log-archive account.</p>

<p>Each path can end in the same self-hosted operational platform without pretending that every signal began in the same place.</p>

<p>The result is not one pipe.</p>

<p>It is a small, deliberate railway system.</p>

<p>Every source has the right carriage.</p>

<p>Every important journey has a buffer.</p>

<p>Every record retains its origin.</p>

<p>And when one line closes, the telemetry waits somewhere safer than application memory and wishful thinking.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li><a href="https://aws-otel.github.io/">AWS Distro for OpenTelemetry</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/deployment/">OpenTelemetry Collector deployment patterns</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/scaling/">Scaling the OpenTelemetry Collector</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/resiliency/">OpenTelemetry Collector resiliency</a></li>
  <li><a href="https://opentelemetry.io/docs/platforms/kubernetes/collector/components/">OpenTelemetry Collector Kubernetes components</a></li>
  <li><a href="https://docs.aws.amazon.com/eks/latest/userguide/control-plane-logs.html">Amazon EKS control-plane logging</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Metric-Streams.html">CloudWatch metric streams</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html">CloudWatch cross-account observability</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatchMetrics_Centralization.html">CloudWatch Metrics Centralization</a></li>
  <li><a href="https://docs.aws.amazon.com/firehose/latest/dev/httpdeliveryrequestresponse.html">Amazon Data Firehose HTTP delivery</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Subscriptions.html">CloudWatch Logs subscriptions</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CrossAccountSubscriptions.html">CloudWatch Logs cross-account subscriptions</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatchLogs_Centralization.html">CloudWatch Logs centralisation</a></li>
  <li><a href="https://aws.amazon.com/about-aws/whats-new/2026/07/amazon-cloudwatch-logs/">Application Load Balancer vended logs announcement, 23 July 2026</a></li>
  <li><a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-access-logs.html">Network Load Balancer access logs</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_LogAccess.Concepts.PublishingCloudWatch.html">RDS log publication</a></li>
  <li><a href="https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html">VPC Flow Logs</a></li>
  <li><a href="https://docs.aws.amazon.com/vpc/latest/tgw/tgw-flow-logs.html">Transit Gateway Flow Logs</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-query-logs.html">Route 53 Resolver query logging</a></li>
  <li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-create-and-update-a-trail.html">CloudTrail trails</a></li>
  <li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-intro.html">CloudTrail log-file integrity validation</a></li>
  <li><a href="https://docs.aws.amazon.com/config/latest/developerguide/manage-delivery-channel.html">AWS Config delivery channels</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">S3 Object Lock</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[AWS telemetry does not emerge through one universal export path. Application signals can travel through OpenTelemetry, while CloudWatch metrics, managed-service logs, flow logs, CloudTrail and configuration evidence require different streaming, polling and durable-ingestion patterns.]]></summary></entry><entry><title type="html">DNS failover: where Route 53 failover routing earns its keep</title><link href="https://cloud.doublewords.net/writing/2026/07/14/dns-failover-where-route-53-failover-routing-earns-its-keep/" rel="alternate" type="text/html" title="DNS failover: where Route 53 failover routing earns its keep" /><published>2026-07-14T00:00:00+01:00</published><updated>2026-07-14T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/14/dns-failover-where-route-53-failover-routing-earns-its-keep</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/14/dns-failover-where-route-53-failover-routing-earns-its-keep/"><![CDATA[<p>The bfstore production workloads send telemetry to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
        │
        ▼
primary internal load balancer
        │
        ▼
OpenTelemetry ingestion gateway
        │
        ▼
self-hosted observability platform
</code></pre></div></div>

<p>The service is private. Its name lives in a Route 53 private hosted zone associated with the production VPC, and the network path is available through bfstore’s private routing architecture.</p>

<p>Then the primary observability environment suffers a serious failure.</p>

<p>bfstore could change DNS manually, restore the primary in place, accept telemetry loss, or direct clients to a separately prepared recovery service. Route 53 failover routing addresses that last option.</p>

<p>Two records share the same name and type:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example

primary
    → primary observability endpoint

secondary
    → recovery observability endpoint
</code></pre></div></div>

<p>When Route 53 considers the primary healthy, it returns the primary record. When the primary is unhealthy and the secondary is healthy, it returns the secondary. Failover routing is Route 53’s active-passive policy and is supported in private hosted zones.</p>

<p>That is useful, but incomplete.</p>

<p>DNS failover can change the answer returned to a future query. It cannot create or warm the recovery environment, replicate data, establish routes, copy credentials, repair dependencies, move existing connections, flush cached answers or prove that the secondary can carry production load.</p>

<blockquote>
  <p><strong>DNS failover redirects discovery. Disaster recovery makes the discovered destination worth reaching.</strong></p>
</blockquote>

<p>Route 53 earns its keep when both sides of that sentence have been built.</p>

<h2 id="what-route-53-actually-decides">What Route 53 actually decides</h2>

<h3 id="failover-routing-is-active-passive">Failover routing is active-passive</h3>

<p>Route 53 supports several routing policies that can consider health. Failover routing specifically models an active-passive relationship.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NORMAL OPERATION

clients
   │
   ▼
primary endpoint


FAILOVER OPERATION

clients
   │
   ▼
secondary endpoint
</code></pre></div></div>

<p>Only the primary should normally be returned. The secondary waits until the primary is considered unhealthy.</p>

<p>Active-active designs use other health-aware policies, such as weighted or latency routing, where several healthy records can be eligible at once. Those environments receive ordinary traffic and are exercised continuously.</p>

<p>An active-passive secondary may receive little or no ordinary traffic. Its readiness must therefore be demonstrated by probes, drills and operational controls.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ACTIVE-PASSIVE

one destination is ordinary

one destination is a promise
about future readiness
</code></pre></div></div>

<p>Route 53 implements the selection. It does not prove that the promise is true.</p>

<h3 id="two-records-form-one-decision">Two records form one decision</h3>

<p>A basic failover group contains one primary record and one secondary record with the same name and type, distinct identifiers, and health behaviour that lets Route 53 choose between them.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">records</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">otel.prod.aws.internal.bfstore.example</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">A</span>
    <span class="na">routing_policy</span><span class="pi">:</span> <span class="s">failover</span>
    <span class="na">failover_role</span><span class="pi">:</span> <span class="s">primary</span>
    <span class="na">identifier</span><span class="pi">:</span> <span class="s">primary-observability</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">primary-observability-endpoint</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">otel.prod.aws.internal.bfstore.example</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">A</span>
    <span class="na">routing_policy</span><span class="pi">:</span> <span class="s">failover</span>
    <span class="na">failover_role</span><span class="pi">:</span> <span class="s">secondary</span>
    <span class="na">identifier</span><span class="pi">:</span> <span class="s">recovery-observability</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">recovery-observability-endpoint</span>
</code></pre></div></div>

<p>The pair can use alias or non-alias records, and one may be an alias while the other is not, provided the names and record types match.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IS PRIMARY HEALTHY?
        │
    ┌───┴───┐
   YES      NO
    │        │
    ▼        ▼
PRIMARY   IS SECONDARY HEALTHY?
              │
          ┌───┴───┐
         YES      NO
          │        │
          ▼        ▼
      SECONDARY  SPECIAL FAILURE BEHAVIOUR
</code></pre></div></div>

<p>That final branch is easy to overlook.</p>

<h3 id="if-both-destinations-are-unhealthy-route-53-returns-the-primary">If both destinations are unhealthy, Route 53 returns the primary</h3>

<p>If both failover records have health checks and both are considered unhealthy, Route 53 still returns the primary record.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY UNHEALTHY

SECONDARY UNHEALTHY
        │
        ▼
ROUTE 53 RETURNS PRIMARY
</code></pre></div></div>

<p>Route 53 needs to return a DNS response and treats all records as healthy as a last resort. The failover policy then selects the primary.</p>

<p>The operational consequence is important:</p>

<blockquote>
  <p><strong>Failover routing does not guarantee that every returned endpoint is healthy.</strong></p>
</blockquote>

<p>When all options are unhealthy, clients still need deadlines, bounded retries, circuit breaking and useful error handling. DNS is one selector in the reliability system, not its final safety mechanism.</p>

<h3 id="a-secondary-with-no-health-evaluation-is-considered-healthy">A secondary with no health evaluation is considered healthy</h3>

<p>If the primary becomes unhealthy and the secondary has neither an associated health check nor alias target-health evaluation, Route 53 considers the secondary healthy and returns it.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY

health checked
unhealthy


SECONDARY

no health check
no target-health evaluation
therefore considered healthy
        │
        ▼
SECONDARY ANSWER RETURNED
</code></pre></div></div>

<p>An alias record is different when <code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code> is enabled: Route 53 inherits health from the alias target.</p>

<p>An unchecked secondary can be appropriate for a deliberately simple destination, such as a static maintenance page. It deserves more caution for a private database, payment service or telemetry pipeline that may be under-sized, stale, missing credentials or blocked by a route.</p>

<p>An unchecked secondary means:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>If the primary fails,
send traffic here without asking
whether this destination can receive it.
</code></pre></div></div>

<p>That should be an explicit architectural decision, not an accidental omission from the Terraform.</p>

<h2 id="how-to-create-a-meaningful-health-signal">How to create a meaningful health signal</h2>

<h3 id="health-must-represent-the-capability-being-failed-over">Health must represent the capability being failed over</h3>

<p>The key question is not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What metric do we already have?
</code></pre></div></div>

<p>It is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What must be true for this endpoint
to fulfil the service contract?
</code></pre></div></div>

<p>For observability ingestion, useful health may require the load balancer to accept connections, at least one gateway target to be ready, authentication to work, the queue to be writable and downstream storage to remain within safe capacity.</p>

<p>A weak signal such as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EC2 instance is running
</code></pre></div></div>

<p>can stay green while the process has crashed, the disk is full, the certificate has expired or every request fails.</p>

<p>A stronger signal asks whether a production-shaped telemetry request can be accepted and durably handed to the next stage.</p>

<p>Health checks should be meaningful enough to represent the capability, cheap enough to run continually, independent enough not to amplify an outage and stable enough not to flap. The best check is the one that answers whether traffic should continue arriving.</p>

<h3 id="alias-health-can-follow-a-load-balancer">Alias health can follow a load balancer</h3>

<p>When a failover alias points to an Application Load Balancer or Network Load Balancer, <code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code> can make Route 53 inherit the health of that destination.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FAILOVER ALIAS RECORD
        │
        ▼
EVALUATE TARGET HEALTH
        │
        ▼
INTERNAL LOAD BALANCER
        │
        ▼
TARGET-GROUP HEALTH
</code></pre></div></div>

<p>For an ALB or NLB to be considered healthy, every target group that contains targets must contain at least one healthy target. A target group with no registered targets is considered unhealthy.</p>

<p>This works well when load-balancer target health accurately represents the DNS name’s service contract.</p>

<p>It can be too broad when one load balancer serves several capabilities. A target group unrelated to the specific DNS name can influence the alias health decision. The health boundary and the DNS name should describe the same service.</p>

<h3 id="highly-available-aws-endpoints-may-need-an-application-health-check">Highly available AWS endpoints may need an application health check</h3>

<p><code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code> is not equally useful for every alias target.</p>

<p>AWS notes that highly available services such as VPC interface endpoints, API Gateway, S3 and VPC Lattice may provide little operational benefit from alias target evaluation. Failover involving these services may need a Route 53 health check that represents the application’s ability to use them.</p>

<p>For a bfstore PrivateLink endpoint, the interface endpoint may remain infrastructurally healthy while the provider application is unavailable, authentication fails, storage cannot be read or every request returns an error.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFRASTRUCTURE HEALTH

interface endpoint exists
network interfaces available


APPLICATION HEALTH

approved module can be retrieved
and authenticated successfully
</code></pre></div></div>

<p>Failover should normally follow the second when that is the service promise being protected.</p>

<h3 id="standard-endpoint-health-checks-cannot-target-private-addresses">Standard endpoint health checks cannot target private addresses</h3>

<p>Route 53 endpoint health checkers operate outside the VPC and cannot target local, private, non-routable or multicast IP address ranges.</p>

<p>For private hosted-zone failover, a common alternative is a Route 53 health check based on a CloudWatch alarm data stream.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE SERVICE
      │
      ▼
IN-VPC PROBE OR SERVICE METRIC
      │
      ▼
CLOUDWATCH ALARM
      │
      ▼
ROUTE 53 HEALTH CHECK
      │
      ▼
FAILOVER RECORD
</code></pre></div></div>

<p>A purpose-built probe runs where the private service is reachable, publishes a metric and lets CloudWatch evaluate it. The probe can include private routing, transport, authentication and dependency behaviour without exposing the service merely so external health checkers can reach it.</p>

<h3 id="the-cloudwatch-alarm-needs-an-explicit-insufficient-data-policy">The CloudWatch alarm needs an explicit insufficient-data policy</h3>

<p>A Route 53 health check based on a CloudWatch alarm monitors the alarm’s data stream rather than simply reading its current label.</p>

<p>The health check is healthy when the stream indicates <code class="language-plaintext highlighter-rouge">OK</code>, unhealthy when it indicates <code class="language-plaintext highlighter-rouge">ALARM</code>, and follows the configured insufficient-data behaviour when the stream lacks enough evidence:</p>

<ul>
  <li>healthy;</li>
  <li>unhealthy;</li>
  <li>last known status.</li>
</ul>

<p>Route 53 does not support cross-account CloudWatch alarms for this relationship. The alarm must also use a standard-resolution metric. Metric math and “M out of N” alarms are not supported.</p>

<p>If several signals must be combined, publish a purpose-built readiness metric or use a calculated health check.</p>

<h4 id="what-should-missing-data-mean">What should missing data mean?</h4>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HEALTHY

Assume the service is healthy
unless failure is proven.


UNHEALTHY

Assume missing evidence means
the service cannot be trusted.


LAST KNOWN

Continue using the previous state.
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Healthy</code> can keep traffic on an unobservable failure. <code class="language-plaintext highlighter-rouge">Unhealthy</code> can trigger failover because the monitoring path failed. <code class="language-plaintext highlighter-rouge">Last known</code> can preserve a stale decision. The choice should reflect the independence and reliability of the monitoring path.</p>

<h4 id="which-account-owns-the-alarm">Which account owns the alarm?</h4>

<p>If the private hosted zone and health check live in <code class="language-plaintext highlighter-rouge">bfstore-network</code> while the metric lives in <code class="language-plaintext highlighter-rouge">bfstore-observability</code> or <code class="language-plaintext highlighter-rouge">bfstore-prod</code>, the design must account for the same-account requirement. Options include publishing a purpose-built metric into the health-check account, moving the health check and alarm with the workload, or revisiting which account owns the DNS decision.</p>

<p>Central ownership is useful only when the required signals can reach it cleanly.</p>

<h3 id="calculated-health-checks-can-express-quorum">Calculated health checks can express quorum</h3>

<p>A calculated health check can monitor several child health checks and compare the number of healthy children with a threshold. The published Route 53 quota allows up to 255 child checks.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY OBSERVABILITY HEALTH

child 1:
    primary-specific probe from production VPC

child 2:
    primary-specific probe from staging VPC

child 3:
    ingestion backlog below threshold

child 4:
    durable storage writable


parent healthy when:
    at least 3 children are healthy
</code></pre></div></div>

<p>This can reduce sensitivity to one faulty probe, but it can also create a formula nobody understands during an incident. Calculated health checks cannot monitor other calculated health checks, so the design must remain shallow.</p>

<p>Combine only independent evidence that genuinely determines whether the service can receive traffic, and make the formula explainable in a runbook.</p>

<h2 id="what-dns-cannot-do">What DNS cannot do</h2>

<h3 id="ttl-determines-how-quickly-new-queries-learn-the-decision">TTL determines how quickly new queries learn the decision</h3>

<p>Route 53 may mark the primary unhealthy quickly, while resolvers and clients continue using a cached primary answer.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T0  primary fails
T1  health state changes
T2  Route 53 returns secondary
T3  cache expires
T4  client queries again
T5  client connects to secondary
</code></pre></div></div>

<p>Experienced failover time is closer to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>health detection
+ health-state evaluation
+ remaining DNS cache lifetime
+ application retry or reconnect
+ secondary connection establishment
</code></pre></div></div>

<p>For non-alias health-checked records, AWS recommends a TTL of 60 seconds or less when clients need to react quickly. Shorter TTLs increase query frequency, so the setting must match the recovery objective.</p>

<p>Alias records do not set their own TTL. Route 53 uses the TTL of the AWS resource or record that the alias targets.</p>

<h3 id="existing-connections-do-not-move">Existing connections do not move</h3>

<p>Suppose a Go service has an established gRPC connection to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10.96.12.45
</code></pre></div></div>

<p>DNS later begins returning:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10.160.12.45
</code></pre></div></div>

<p>The existing TCP or HTTP/2 connection remains attached to the original destination until it fails, the client closes it, an idle timeout expires or the application refreshes it.</p>

<p>DNS failover redirects future discovery. It does not migrate live sessions.</p>

<p>Client behaviour therefore belongs in the recovery design:</p>

<ul>
  <li>connection deadlines;</li>
  <li>bounded retries;</li>
  <li>reconnect logic;</li>
  <li>appropriate DNS refresh;</li>
  <li>idempotent operations;</li>
  <li>awareness of long-lived pools.</li>
</ul>

<p>Telemetry collectors may keep persistent export connections. MySQL pools may retain sessions. Kafka clients may rely on broker metadata after the initial bootstrap lookup. The failover test must use the real client, not only <code class="language-plaintext highlighter-rouge">dig</code>.</p>

<h3 id="route-53-cannot-repair-a-missing-route">Route 53 cannot repair a missing route</h3>

<p>A workload may receive the secondary answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
    → 10.160.12.45
</code></pre></div></div>

<p>The production VPC still needs a route to the recovery network, a return route, security-group and firewall permission, the correct hosted-zone association and authentication accepted by the secondary.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DNS ANSWER
    │
    ▼
ROUTE TABLE
    │
    ▼
TRANSIT OR OTHER PRIVATE PATH
    │
    ▼
SECURITY CONTROLS
    │
    ▼
SECONDARY SERVICE
</code></pre></div></div>

<p>A correct failover record can return an unreachable address. Private recovery paths should exist and be tested before the incident. Creating them during the incident is emergency network construction, not automated failover.</p>

<h3 id="the-secondary-must-be-able-to-carry-the-traffic">The secondary must be able to carry the traffic</h3>

<p>A cold standby may contain no running replicas, stale data, missing secrets, outdated infrastructure or untested network policy. DNS can still direct production traffic to it.</p>

<p>Before enabling a secondary failover record, bfstore should know:</p>

<ul>
  <li><strong>Capacity:</strong> can it handle full production demand?</li>
  <li><strong>Data:</strong> is required state replicated, restored or deliberately absent?</li>
  <li><strong>Identity:</strong> will current workloads and clients be accepted?</li>
  <li><strong>Secrets:</strong> are certificates, tokens and keys available?</li>
  <li><strong>Dependencies:</strong> can it reach the services it needs?</li>
  <li><strong>Operations:</strong> are dashboards, alerts and runbooks active?</li>
  <li><strong>Ownership:</strong> who declares it ready?</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DNS SECONDARY

must represent

RECOVERY CAPABILITY


not merely

ANOTHER ADDRESS
</code></pre></div></div>

<p>For stateful order processing, readiness may also require database promotion, write fencing, event-offset reconciliation and idempotency state. Route 53 should move traffic only after those concerns have been handled.</p>

<h3 id="multi-az-load-balancing-usually-does-not-need-route-53-failover">Multi-AZ load balancing usually does not need Route 53 failover</h3>

<p>An internal ALB or NLB can already operate across Availability Zones and route traffic to healthy targets. An Application Load Balancer normally spans at least two enabled zones.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders.prod.aws.internal.bfstore.example
        │
        ▼
internal ALB
        │
        ├── targets in AZ A
        ├── targets in AZ B
        └── targets in AZ C
</code></pre></div></div>

<p>Adding Route 53 failover between two load balancers that duplicate the same multi-AZ failure boundary often adds complexity without creating meaningful independence.</p>

<p>Route 53 is more useful between independent service cells:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY CELL              SECONDARY CELL

load balancer             load balancer
compute                    compute
dependencies               dependencies
</code></pre></div></div>

<p>The boundary may be another Region, VPC, service cell, recovery stack or maintenance destination. Two records pointing into the same failure domain do not create redundancy.</p>

<h3 id="shared-dependencies-can-defeat-failover">Shared dependencies can defeat failover</h3>

<p>Suppose primary and secondary order-service endpoints both depend on the same regional database, Kafka cluster, KMS key, identity provider, Transit Gateway route or egress path.</p>

<p>If the primary fails because the shared dependency is unavailable, the secondary fails for the same reason.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY ENDPOINT ───┐
                    ▼
               SHARED KAFKA
                    ▲
SECONDARY ENDPOINT ─┘
</code></pre></div></div>

<p>DNS failover protects only against failures outside the shared dependency set.</p>

<p>A recovery review should ask:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which failure makes
the primary unhealthy?

Can that same failure
disable the secondary?
</code></pre></div></div>

<p>The secondary does not need to duplicate every component. It needs enough independence to survive the failure scenarios the policy claims to handle.</p>

<h2 id="how-to-operate-the-recovery-system">How to operate the recovery system</h2>

<h3 id="automated-health-and-operator-controlled-failover-solve-different-problems">Automated health and operator-controlled failover solve different problems</h3>

<p>Automated failover is attractive: the service fails, health changes and Route 53 redirects future resolutions.</p>

<p>The difficult question is whether every detected failure should move traffic automatically.</p>

<p>Some failures are clear:</p>

<ul>
  <li>the primary endpoint is unreachable;</li>
  <li>all application targets are unhealthy;</li>
  <li>the environment has been deliberately isolated.</li>
</ul>

<p>Others are ambiguous:</p>

<ul>
  <li>latency is elevated;</li>
  <li>one dependency is degraded;</li>
  <li>replication is behind;</li>
  <li>the recovery environment may be less ready;</li>
  <li>the monitoring system is unstable.</li>
</ul>

<p>Amazon Application Recovery Controller can provide operator-controlled routing switches integrated with Route 53 health checks. ARC routing controls change the associated health-check state and can use safety rules to prevent dangerous combinations. They are traffic switches, not application-health monitors.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AUTOMATED FAILOVER

observed service health
        │
        ▼
DNS changes automatically


CONTROLLED FAILOVER

operator or recovery automation
        │
        ▼
routing-control state changes
        │
        ▼
DNS changes deliberately
</code></pre></div></div>

<p>A stateless private ingestion endpoint may tolerate automatic failover. A stateful order system with asynchronous cross-Region replication may require verification before writes move.</p>

<p>Mature automation is not merely fast. It moves traffic only when the destination is ready.</p>

<h3 id="failback-can-be-more-dangerous-than-failover">Failback can be more dangerous than failover</h3>

<p>The primary recovers and its health check turns green. Automatic failback may still be premature.</p>

<p>During the outage, the secondary may have accepted writes, advanced event consumers, changed caches or reversed replication direction. The primary may have missed events.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY HEALTHY AGAIN
        │
        ▼
IS ITS STATE CURRENT?
IS REPLICATION SAFE?
IS IT READY FOR AUTHORITY?
</code></pre></div></div>

<p>Failback should consider data reconciliation, replication direction, event offsets, connection draining, customer impact and operational approval.</p>

<p>For stateful services, automated detection with controlled failback is often safer. A green process or load-balancer target does not prove that the primary should reclaim authority.</p>

<h3 id="health-checks-need-hysteresis">Health checks need hysteresis</h3>

<p>If a service alternates between healthy and unhealthy, Route 53 can repeatedly change the returned destination. Clients hold answers for different TTL windows, so traffic may split across primary and secondary.</p>

<p>A stable design should account for check interval, failure threshold, CloudWatch evaluation periods, missing-data behaviour, client TTL, cooldown and the evidence required before failback.</p>

<p>Route 53 endpoint health checks can run at 10-second or 30-second intervals and use a configured failure threshold. A compatible CloudWatch alarm can require consecutive breaching periods, but Route 53 supports only standard-resolution metrics and does not support metric math or “M out of N” alarms.</p>

<p>If bfstore needs asymmetric failure and recovery thresholds, implement them upstream and publish one binary readiness metric or controlled routing state.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Conceptual upstream readiness policy,</span>
<span class="c1"># not a direct Route 53-compatible alarm definition.</span>

<span class="na">readiness_policy</span><span class="pi">:</span>
  <span class="na">declare_unready</span><span class="pi">:</span>
    <span class="na">success_rate_below_percent</span><span class="pi">:</span> <span class="m">95</span>
    <span class="na">sustained_minutes</span><span class="pi">:</span> <span class="m">3</span>

  <span class="na">declare_ready_again</span><span class="pi">:</span>
    <span class="na">success_rate_at_or_above_percent</span><span class="pi">:</span> <span class="m">99</span>
    <span class="na">sustained_minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">operator_approval</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<p>The numbers require testing. The principle is to prevent one noisy measurement from moving production traffic repeatedly.</p>

<h3 id="testing-must-include-cached-answers">Testing must include cached answers</h3>

<p>A failover drill should test the complete path.</p>

<h4 id="baseline">Baseline</h4>

<p>Confirm that the primary is returned, clients connect, the secondary is independently healthy and both network paths work.</p>

<h4 id="trigger-failure">Trigger failure</h4>

<p>Use a controlled health-check override, failed synthetic probe, ARC routing control or target withdrawal in a test environment.</p>

<h4 id="observe-route-53-and-caches">Observe Route 53 and caches</h4>

<p>Confirm that the primary health changes, the secondary remains healthy and fresh queries return the secondary.</p>

<p>Run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig otel.prod.aws.internal.bfstore.example
getent hosts otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>from several clients and resolvers. Record the answer, TTL and time until cached primary addresses disappear.</p>

<h4 id="observe-real-applications">Observe real applications</h4>

<p>Confirm that long-lived clients reconnect, retries remain bounded, authentication works, no duplicate operation is created and latency remains acceptable.</p>

<h4 id="observe-the-secondary">Observe the secondary</h4>

<p>Check capacity, queue depth, throttling and downstream storage.</p>

<h4 id="test-failback-separately">Test failback separately</h4>

<p>Restore the primary without immediately returning traffic. Validate it, perform controlled failback and test clients again.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>THE TEST IS COMPLETE WHEN

real clients reach a valid secondary

and return safely
</code></pre></div></div>

<p>A DNS response proves routing intent. A completed operational or business transaction proves recovery.</p>

<h3 id="metrics-and-evidence">Metrics and evidence</h3>

<p>The design should expose several layers.</p>

<h4 id="route-53-health">Route 53 health</h4>

<ul>
  <li>health-check state and transition time;</li>
  <li>child-check state;</li>
  <li>CloudWatch alarm data stream;</li>
  <li>insufficient-data events.</li>
</ul>

<h4 id="dns">DNS</h4>

<ul>
  <li>Resolver query logs;</li>
  <li>response data and code;</li>
  <li>source VPC;</li>
  <li>old and new answers;</li>
  <li>observed TTL.</li>
</ul>

<p>Resolver query logging records queries that reach Route 53 VPC Resolver. Queries answered from a resolver cache are not logged individually.</p>

<h4 id="client-and-recovery">Client and recovery</h4>

<ul>
  <li>connection failures, retries and reconnect time;</li>
  <li>successful requests by destination;</li>
  <li>secondary target health, capacity and queue depth;</li>
  <li>replication state and failover approvals.</li>
</ul>

<p>A useful incident timeline distinguishes detection, DNS selection, cache expiry, client reconnect and secondary stabilisation. That reveals where the recovery time was actually spent.</p>

<h3 id="route-53s-dns-data-plane-is-separate-from-configuration-work">Route 53’s DNS data plane is separate from configuration work</h3>

<p>Route 53 separates the DNS-serving data plane from the control plane used to create and update resources. AWS recommends relying on preconfigured health checks and failover records during recovery rather than making control-plane changes at the moment of failure.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WEAK RECOVERY

incident
   │
   ▼
engineer creates records
and health checks


STRONGER RECOVERY

incident
   │
   ▼
preconfigured health changes
   │
   ▼
existing records select secondary
</code></pre></div></div>

<p>The secondary configuration should exist before the primary fails. Recovery should execute prepared decisions rather than discover missing permissions, invalid targets or untested scripts during the incident.</p>

<h2 id="applying-the-model-at-bfstore">Applying the model at bfstore</h2>

<h3 id="a-bfstore-private-failover-design">A bfstore private failover design</h3>

<p>A useful first bfstore case is the observability ingestion endpoint.</p>

<p>Telemetry is important, but its front-door ingestion path can be largely stateless. Moving it between service cells is more manageable than moving the authoritative order database.</p>

<h4 id="stable-service-name">Stable service name</h4>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<h4 id="destinations">Destinations</h4>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>primary observability ingestion
eu-west-2

recovery observability ingestion
separate VPC or Region
</code></pre></div></div>

<p>The production, staging and platform VPCs can reach both destinations through approved private paths.</p>

<h4 id="separate-control-loop-probes">Separate control-loop probes</h4>

<p>Each branch must be tested directly. A branch health probe must not resolve the failover name whose answer it controls.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel-primary.prod.aws.internal.bfstore.example
        │
        ▼
primary ingestion endpoint
        │
        ▼
primary capability metric


otel-recovery.prod.aws.internal.bfstore.example
        │
        ▼
recovery ingestion endpoint
        │
        ▼
recovery capability metric
</code></pre></div></div>

<p>Each synthetic OpenTelemetry probe:</p>

<ol>
  <li>resolves its endpoint-specific name;</li>
  <li>establishes the required transport;</li>
  <li>authenticates with a probe identity;</li>
  <li>submits a marked signal;</li>
  <li>verifies acceptance by the intended gateway;</li>
  <li>publishes a branch-specific metric.</li>
</ol>

<p>Using <code class="language-plaintext highlighter-rouge">otel.prod.aws.internal.bfstore.example</code> for branch health would create a circular control loop. After failover, that name may resolve to the recovery endpoint, so the probe would no longer prove the health of the primary.</p>

<h4 id="end-to-end-service-probe">End-to-end service probe</h4>

<p>A separate probe resolves the stable production name and measures the client path and service SLO. It confirms which branch Route 53 selected and whether clients can use it, but it does not directly determine either branch’s health.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BRANCH-SPECIFIC PROBES
        │
        ▼
FAILOVER DECISION


STABLE-NAME PROBE
        │
        ▼
END-TO-END RECOVERY EVIDENCE
</code></pre></div></div>

<h4 id="failover-records">Failover records</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">dns_failover</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">otel.prod.aws.internal.bfstore.example</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">A</span>

  <span class="na">primary</span><span class="pi">:</span>
    <span class="na">identifier</span><span class="pi">:</span> <span class="s">primary-observability</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">primary-ingest-load-balancer</span>
    <span class="na">health</span><span class="pi">:</span> <span class="s">primary-capability-health</span>

  <span class="na">secondary</span><span class="pi">:</span>
    <span class="na">identifier</span><span class="pi">:</span> <span class="s">recovery-observability</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">recovery-ingest-load-balancer</span>
    <span class="na">health</span><span class="pi">:</span> <span class="s">recovery-capability-health</span>
</code></pre></div></div>

<p>The recovery gateway maintains enough baseline capacity to accept production telemetry or can scale within the recovery window. It may use an independent queue and storage, or an explicitly accepted period of reduced retention.</p>

<p>Failback is operator-approved after the primary-specific probe stays healthy, storage and queues are reconciled, capacity is confirmed and the stable-name probe succeeds after traffic returns.</p>

<p>This is a realistic use of Route 53 failover: both destinations accept the same protocol and identity, the secondary is independently useful, and DNS has a precise job.</p>

<h3 id="a-weaker-bfstore-use-case-failing-over-the-order-database-name">A weaker bfstore use case: failing over the order database name</h3>

<p>Consider:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mysql.orders.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>Changing its DNS answer from a primary RDS MySQL writer to a recovery writer does not prove that the secondary has been promoted, contains current data, matches the schema or has fenced the old primary from writes. It also does not reconnect existing pools or reconcile event consumers and idempotency state.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>replicate or restore
        │
        ▼
promote secondary
        │
        ▼
fence old primary
        │
        ▼
validate state
        │
        ▼
change DNS
        │
        ▼
reconnect and verify writes
</code></pre></div></div>

<p>DNS may be one step in the procedure. It should not be the entire procedure. A record that moves before the database can safely change authority is unsafe.</p>

<h3 id="where-failover-routing-earns-its-keep">Where failover routing earns its keep</h3>

<p>Route 53 failover is a strong fit when:</p>

<ul>
  <li>clients must keep one stable name;</li>
  <li>both destinations accept the same protocol and identity;</li>
  <li>consuming networks can already reach both;</li>
  <li>health represents the service capability;</li>
  <li>TTL and reconnect behaviour fit the recovery objective;</li>
  <li>the secondary has tested capacity, state and dependencies;</li>
  <li>failback requires deliberate evidence.</li>
</ul>

<p>It is weaker when the secondary shares the primary’s failure domain, needs a long rebuild, cannot be reached privately, or must complete stateful promotion before accepting traffic. It also adds little when a load balancer already handles the relevant boundary.</p>

<p>Failover routing is a selector between prepared alternatives.</p>

<h3 id="pre-deployment-review">Pre-deployment review</h3>

<h4 id="failure-domain">Failure domain</h4>

<ul>
  <li>Which failure should move traffic?</li>
  <li>Can it also disable the secondary?</li>
  <li>Which critical dependencies are shared?</li>
</ul>

<h4 id="health">Health</h4>

<ul>
  <li>Does each branch-specific check represent the service contract?</li>
  <li>Is the secondary checked, target-evaluated or intentionally unchecked?</li>
  <li>What does insufficient data mean?</li>
  <li>What happens when both branches are unhealthy?</li>
</ul>

<h4 id="reachability-state-and-capacity">Reachability, state and capacity</h4>

<ul>
  <li>Can every consuming VPC reach both destinations?</li>
  <li>Will the same identities and credentials work?</li>
  <li>Is the secondary’s state current enough?</li>
  <li>Can it carry the failover load?</li>
  <li>What promotion, fencing or reconciliation must happen first?</li>
</ul>

<h4 id="clients-and-operations">Clients and operations</h4>

<ul>
  <li>How long may resolvers retain the old answer?</li>
  <li>When do long-lived clients resolve again?</li>
  <li>Are retries bounded and operations idempotent?</li>
  <li>Is failover automatic or controlled?</li>
  <li>What evidence permits failback?</li>
  <li>Has the complete path been tested with real clients?</li>
</ul>

<h3 id="the-mental-model">The mental model</h3>

<p>The incomplete model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY FAILS
      │
      ▼
ROUTE 53 NOTICES
      │
      ▼
EVERYTHING MOVES
TO SECONDARY
</code></pre></div></div>

<p>The useful model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY CAPABILITY
        │
        ▼
BRANCH-SPECIFIC HEALTH
        │
        ▼
ROUTE 53 DECISION
        │
        ├── primary healthy
        │       └── primary answer
        │
        └── primary unhealthy
                ├── secondary healthy
                │       └── secondary answer
                └── secondary unhealthy
                        └── primary may still return
        │
        ▼
CACHE EXPIRES
        │
        ▼
CLIENT RESOLVES AND RECONNECTS
        │
        ▼
DESTINATION ACCEPTS WORK
</code></pre></div></div>

<p>The records decide where traffic may go. Health decides whether each destination can fulfil the contract. TTL and clients determine when the change takes effect. Network reachability and recovery readiness determine whether it succeeds.</p>

<blockquote>
  <p><strong>Route 53 is the switch in a recovery system, not the recovery system itself.</strong></p>
</blockquote>

<p>It earns its keep between two already viable destinations.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-failover.html">Route 53 failover routing</a><br />
Defines the failover policy and confirms that it can be used in private hosted zones.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-types.html">Active-active and active-passive failover</a><br />
Explains how failover routing implements active-passive service selection.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-how-route-53-chooses-records.html">How Route 53 chooses records when health checking is configured</a><br />
Documents unchecked records, alias target-health evaluation and the result when both failover records are unhealthy.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-values-failover-alias.html">Values specific for failover alias records</a><br />
Covers primary and secondary alias records and <code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code>.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/APIReference/API_AliasTarget.html">AliasTarget API reference</a><br />
Documents how Route 53 evaluates ALB and NLB target-group health and why highly available alias targets may need application-level health checks.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html">Configuring failover in a private hosted zone</a><br />
Describes CloudWatch-based health checks for resources reachable only through private addresses.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-creating-values.html">Values for Route 53 health checks</a><br />
Covers private-address restrictions, endpoint-specific names, calculated checks, CloudWatch alarm limitations and insufficient-data behaviour.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html">Route 53 quotas</a><br />
Lists the published limit of 255 child checks for a calculated health check.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/best-practices-dns.html">Route 53 DNS best practices</a><br />
Discusses TTL choices and preconfigured recovery mechanisms.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html">Choosing between alias and non-alias records</a><br />
Explains how alias records inherit the TTL of their target.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/userguide/how-elastic-load-balancing-works.html">How Elastic Load Balancing works</a><br />
Describes multi-Availability-Zone load balancing and zonal target handling.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/r53recovery/latest/dg/routing-control.html">Application Recovery Controller routing controls</a><br />
Describes operator-controlled traffic movement through routing-control health checks.</p>
  </li>
  <li>
    <p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-query-logs.html">Route 53 Resolver query logging</a><br />
Documents DNS query evidence and the effect of resolver caching on logged requests.</p>
  </li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[Route 53 failover routing can redirect new DNS resolutions from a primary endpoint to a secondary endpoint. It is valuable when the secondary is independently usable, the health signal represents the service capability, and caching, data recovery, failback and network reachability have all been designed.]]></summary></entry><entry><title type="html">Route 53 Resolver endpoints: when you actually need them</title><link href="https://cloud.doublewords.net/writing/2026/07/10/route-53-resolver-endpoints-when-you-actaully-need-them/" rel="alternate" type="text/html" title="Route 53 Resolver endpoints: when you actually need them" /><published>2026-07-10T00:00:00+01:00</published><updated>2026-07-10T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/10/route-53-resolver-endpoints-when-you-actaully-need-them</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/10/route-53-resolver-endpoints-when-you-actaully-need-them/"><![CDATA[<p>bfstore has several Route 53 private hosted zones.</p>

<p>Production workloads can resolve:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example

modules.platform.aws.internal.bfstore.example

orders.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>The VPCs use AmazonProvidedDNS. Their DNS attributes are enabled, and the appropriate private hosted zones are associated with them.</p>

<p>Private DNS already works.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE WORKLOAD
       │
       ▼
AmazonProvidedDNS
       │
       ▼
ASSOCIATED PRIVATE HOSTED ZONE
       │
       ▼
PRIVATE ANSWER
</code></pre></div></div>

<p>No Resolver endpoint is required.</p>

<p>Then bfstore begins its Azure implementation.</p>

<p>An Azure-hosted platform component needs to resolve:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>modules.platform.aws.internal.bfstore.example
</code></pre></div></div>

<p>AWS workloads also need to resolve:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>registry.platform.azure.internal.bfstore.example
</code></pre></div></div>

<p>The DNS questions now cross between two resolver environments.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AZURE DNS
    │
    │ asks for an AWS-private name
    ▼
AWS DNS


AWS DNS
    │
    │ asks for an Azure-private name
    ▼
AZURE DNS
</code></pre></div></div>

<p>This is where Route 53 Resolver endpoints become useful.</p>

<p>Route 53 VPC Resolver is available by default in every VPC. It answers public records, VPC-specific names and associated private hosted zones. Resolver endpoints extend that service so DNS queries can cross between VPC Resolver and resolvers on another connected network.</p>

<blockquote>
  <p><strong>Resolver endpoints are DNS bridges. They become necessary when the question or answer lives on the other side of a resolver boundary.</strong></p>
</blockquote>

<p>An inbound endpoint carries questions into AWS.</p>

<p>An outbound endpoint carries selected questions away from AWS.</p>

<p>A forwarding or delegation rule decides which names should take the outbound bridge.</p>

<p>That is the architecture in one paragraph.</p>

<p>The rest is deciding where the bridges belong, who may use them and how to stop them quietly forwarding questions in circles.</p>

<h2 id="start-with-the-authority-not-the-endpoint">Start with the authority, not the endpoint</h2>

<p>Before creating an endpoint, I need to know which DNS system owns each namespace.</p>

<p>Suppose bfstore eventually has:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS Route 53

aws.internal.bfstore.example


Azure private DNS

azure.internal.bfstore.example


GCP Cloud DNS

gcp.internal.bfstore.example


External identity platform

id.internal.bfstore.example
</code></pre></div></div>

<p>The DNS architecture should answer three questions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which resolver receives
the client query first?


Which DNS system is authoritative
for the requested namespace?


How does the first resolver
reach that authority?
</code></pre></div></div>

<p>A Resolver endpoint is useful only when the answer to the final question crosses an AWS network boundary.</p>

<p>If an AWS workload queries a private hosted zone associated with its VPC, Route 53 already owns and can answer the name.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS CLIENT
    │
    ▼
AmazonProvidedDNS
    │
    ▼
AWS PRIVATE HOSTED ZONE
</code></pre></div></div>

<p>No endpoint is needed.</p>

<p>If an external resolver needs the AWS answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EXTERNAL CLIENT
    │
    ▼
EXTERNAL RESOLVER
    │
    ▼
AWS INBOUND ENDPOINT
    │
    ▼
AWS PRIVATE HOSTED ZONE
</code></pre></div></div>

<p>An inbound endpoint is needed.</p>

<p>If an AWS client needs an externally owned answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS CLIENT
    │
    ▼
AmazonProvidedDNS
    │
    ▼
OUTBOUND RULE
    │
    ▼
AWS OUTBOUND ENDPOINT
    │
    ▼
EXTERNAL DNS AUTHORITY
</code></pre></div></div>

<p>An outbound endpoint is needed.</p>

<p>The endpoint follows the authority relationship.</p>

<p>It should not be deployed first and assigned a purpose afterwards.</p>

<h2 id="inbound-another-network-asks-aws">Inbound: another network asks AWS</h2>

<p>An inbound Resolver endpoint creates private IP addresses inside an AWS VPC. AWS creates a managed network interface for each endpoint address.</p>

<p>DNS resolvers on another connected network send queries to those addresses. VPC Resolver then answers using the DNS context available to the endpoint VPC, including private hosted zones associated with that VPC.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OTHER NETWORK
      │
      ▼
EXTERNAL DNS RESOLVER
      │
      │ conditional forward
      ▼
INBOUND ENDPOINT IP ADDRESSES
      │
      ▼
ROUTE 53 VPC RESOLVER
      │
      ▼
AWS-PRIVATE DNS ANSWER
</code></pre></div></div>

<p>A practical enterprise resolver configuration might be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws.internal.bfstore.example
    → 10.112.10.25
    → 10.112.11.25
</code></pre></div></div>

<p>Those two addresses belong to an inbound endpoint in two Availability Zones.</p>

<p>When the external resolver receives:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>it forwards the query to one of the endpoint addresses. VPC Resolver finds the matching private hosted zone and returns the record.</p>

<p>The external client can use the returned address only if a separate network route and security policy allow it.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INBOUND ENDPOINT

external DNS
    → AWS DNS


VPN, DIRECT CONNECT
OR OTHER NETWORK PATH

external client
    → returned AWS address
</code></pre></div></div>

<p>The endpoint provides DNS reachability.</p>

<p>It does not provide application reachability.</p>

<h3 id="the-endpoint-is-a-narrow-service-boundary">The endpoint is a narrow service boundary</h3>

<p>An inbound endpoint accepts DNS queries. It is not a router into the endpoint VPC and does not grant access to:</p>

<ul>
  <li>application subnets;</li>
  <li>Kubernetes Pods;</li>
  <li>databases;</li>
  <li>arbitrary VPC addresses;</li>
  <li>AWS control-plane APIs.</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EXTERNAL RESOLVER
        │
        ▼
DNS QUERY ONLY
        │
        ▼
INBOUND ENDPOINT
        │
        ▼
ROUTE 53 RESOLVER
</code></pre></div></div>

<p>The endpoint security group determines which source resolvers and protocols may reach it.</p>

<p>The associated private hosted zones determine which private answers VPC Resolver can provide.</p>

<p>The returned destination is governed separately.</p>

<p>This is a service-shaped boundary:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The external network may ask
approved DNS questions.

It does not join the AWS VPC
because those questions receive answers.
</code></pre></div></div>

<h3 id="the-inbound-endpoint-sees-its-vpcs-dns-view">The inbound endpoint sees its VPC’s DNS view</h3>

<p>An inbound endpoint does not automatically see every private hosted zone in every AWS account.</p>

<p>Suppose the endpoint lives in:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-dns-hub VPC
</code></pre></div></div>

<p>but the production zone is associated only with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore-prod VPC
</code></pre></div></div>

<p>An external query entering through the DNS hub does not inherit the production VPC’s zone association. The DNS hub also needs an appropriate association or shared DNS configuration.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE HOSTED ZONE
prod.aws.internal.bfstore.example
          │
          ├── bfstore-prod VPC
          └── bfstore-dns-hub VPC
</code></pre></div></div>

<p>That association is also an audience decision. External resolver populations reaching the hub may be able to resolve records from the associated zone, subject to the forwarding and access design.</p>

<p>Zone associations should therefore express intended visibility, not merely make troubleshooting easier.</p>

<h2 id="outbound-aws-asks-another-authority">Outbound: AWS asks another authority</h2>

<p>An outbound Resolver endpoint handles the opposite direction.</p>

<p>An AWS workload sends its normal query to AmazonProvidedDNS. VPC Resolver compares the queried name with its rules. When a matching forwarding rule exists, Resolver sends the query through the outbound endpoint to the configured target DNS servers.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS WORKLOAD
      │
      ▼
AmazonProvidedDNS
      │
      ▼
MATCHING RESOLVER RULE
      │
      ▼
OUTBOUND ENDPOINT
      │
      ▼
EXTERNAL DNS SERVER
      │
      ▼
EXTERNAL PRIVATE ZONE
</code></pre></div></div>

<p>Suppose bfstore’s Azure DNS environment owns:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>azure.internal.bfstore.example
</code></pre></div></div>

<p>The AWS network account creates a forwarding rule:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">rule</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">azure-private-dns</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">FORWARD</span>
  <span class="na">domain</span><span class="pi">:</span> <span class="s">azure.internal.bfstore.example</span>

  <span class="na">targets</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">ip</span><span class="pi">:</span> <span class="s">10.160.10.53</span>
      <span class="na">port</span><span class="pi">:</span> <span class="m">53</span>

    <span class="pi">-</span> <span class="na">ip</span><span class="pi">:</span> <span class="s">10.160.11.53</span>
      <span class="na">port</span><span class="pi">:</span> <span class="m">53</span>
</code></pre></div></div>

<p>The rule is associated with the VPCs whose workloads need that namespace.</p>

<p>A production Pod asks AmazonProvidedDNS for:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>registry.platform.azure.internal.bfstore.example
</code></pre></div></div>

<p>Resolver finds the matching rule and sends the query through the outbound endpoint. The Azure-side resolver answers, and the result returns through VPC Resolver to the Pod.</p>

<p>The Pod continues using the resolver it always used. It does not need to know the addresses of the Azure DNS servers.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>APPLICATION

asks for a name


ROUTE 53 RESOLVER

chooses the authority


OUTBOUND ENDPOINT

carries the question
</code></pre></div></div>

<h2 id="outbound-rules-decide-which-authority-wins">Outbound rules decide which authority wins</h2>

<p>An outbound endpoint does not forward every DNS query automatically.</p>

<p>Resolver rules determine which namespaces use it.</p>

<p>A rule can forward:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>id.internal.bfstore.example
</code></pre></div></div>

<p>without forwarding:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws.internal.bfstore.example
</code></pre></div></div>

<p>or ordinary public DNS.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QUERY

login.id.internal.bfstore.example
        │
        ▼
MATCHING RULE
        │
        ▼
OUTBOUND ENDPOINT


QUERY

orders.prod.aws.internal.bfstore.example
        │
        ▼
NO EXTERNAL RULE
        │
        ▼
ROUTE 53 PRIVATE HOSTED ZONE
</code></pre></div></div>

<p>When several rules match, Route 53 uses the rule containing the most-specific domain name. A rule for:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>platform.azure.internal.bfstore.example
</code></pre></div></div>

<p>therefore takes precedence over:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>azure.internal.bfstore.example
</code></pre></div></div>

<p>That resembles longest-prefix routing, except DNS labels rather than address bits determine the most-specific match.</p>

<p>The rule set should be reviewed as a routing table for names.</p>

<h3 id="forwarding-rules-can-shadow-private-hosted-zones">Forwarding rules can shadow private hosted zones</h3>

<p>Suppose a VPC has:</p>

<ul>
  <li>a private hosted zone for <code class="language-plaintext highlighter-rouge">corp.example</code>;</li>
  <li>a forwarding rule for <code class="language-plaintext highlighter-rouge">corp.example</code>.</li>
</ul>

<p>The forwarding rule takes precedence. Resolver sends the query to the external DNS servers instead of answering it from the private hosted zone.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE HOSTED ZONE

corp.example


OUTBOUND FORWARDING RULE

corp.example
    → external DNS


WINNER

outbound forwarding rule
</code></pre></div></div>

<p>This can be intentional. The external DNS environment may be the authoritative source while the private hosted zone is being retired.</p>

<p>It can also produce a baffling incident:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The record is clearly present
in Route 53.

The client still receives
the external answer.
</code></pre></div></div>

<p>The record is present. It is simply not on the winning resolution path.</p>

<p>Before creating a forwarding rule, I now ask:</p>

<ul>
  <li>Does a private hosted zone use the same name?</li>
  <li>Does an AWS-defined system rule already own part of the namespace?</li>
  <li>Will a more-specific subdomain need to remain inside Route 53?</li>
  <li>Which VPCs should inherit the external authority?</li>
</ul>

<p>A DNS forwarding rule is an authority decision. It should receive the same care as a network route.</p>

<h3 id="system-rules-create-deliberate-exceptions">System rules create deliberate exceptions</h3>

<p>A forwarding rule normally applies to its domain and subdomains.</p>

<p>Suppose bfstore forwards:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>example.com
    → external DNS
</code></pre></div></div>

<p>but wants VPC Resolver to use its normal resolution behaviour for:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws.example.com
</code></pre></div></div>

<p>A more-specific <code class="language-plaintext highlighter-rouge">SYSTEM</code> rule can create that exception.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FORWARD RULE

example.com
    → external DNS


SYSTEM RULE

aws.example.com
    → Route 53 Resolver
</code></pre></div></div>

<p>This can preserve:</p>

<ul>
  <li>a Route 53 private hosted zone;</li>
  <li>AWS-provided private naming;</li>
  <li>ordinary recursive behaviour for one branch.</li>
</ul>

<p>The final answer may therefore depend on:</p>

<ul>
  <li>private hosted zones;</li>
  <li>custom forwarding rules;</li>
  <li>delegation rules;</li>
  <li>custom system rules;</li>
  <li>AWS-defined system rules;</li>
  <li>the most-specific domain match.</li>
</ul>

<p>DNS troubleshooting should inspect the complete rule set, not only the record the engineer expected to win.</p>

<h2 id="forwarding-and-delegation-are-different-relationships">Forwarding and delegation are different relationships</h2>

<p>Conditional forwarding says:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>For names beneath this domain,
send the query to these resolver IPs.
</code></pre></div></div>

<p>DNS delegation says:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>These name servers are authoritative
for this child namespace.
</code></pre></div></div>

<p>Route 53 Resolver supports both models, but they are not interchangeable.</p>

<p>A <code class="language-plaintext highlighter-rouge">FORWARD</code> rule redirects matching queries to configured target resolver addresses.</p>

<p>A <code class="language-plaintext highlighter-rouge">DELEGATE</code> rule follows the DNS delegation chain. Resolver uses the outbound endpoint only when the delegation configuration matches the NS records returned during resolution. For private-hosted-zone-to-outbound delegation, the private hosted zone must contain the relevant NS records.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FORWARD

domain match
    → configured target resolvers


DELEGATE

DNS response contains matching NS records
    → reach those delegated name servers
      through the outbound endpoint
</code></pre></div></div>

<p>Inbound delegation is also distinct from ordinary inbound forwarding. It uses a delegation-category inbound endpoint, and the parent DNS environment needs glue NS records pointing at the endpoint addresses. Delegation inbound endpoints currently support Do53 only.</p>

<p>Forwarding is often simpler when integrating existing resolvers whose authority cannot be changed easily.</p>

<p>Delegation can create a cleaner authority model when parent and child namespaces are deliberately divided between teams or environments.</p>

<p>The architectural question is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Are we telling the resolver
where to forward a question?


Or are we declaring
who owns the child zone?
</code></pre></div></div>

<p>Both can involve Resolver endpoints.</p>

<p>They express different governance and require different DNS records.</p>

<h2 id="a-shared-regional-dns-hub">A shared Regional DNS hub</h2>

<p>A central DNS design does not normally need one inbound endpoint per private hosted zone or one outbound endpoint per rule.</p>

<p>Within a Region, one inbound endpoint and one outbound endpoint are typically sufficient for their respective directions. Multiple rules can use the same outbound endpoint, and additional capacity can usually be added by adding endpoint addresses rather than creating another endpoint.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ONE INBOUND ENDPOINT
        │
        ├── queries for aws.internal...
        ├── queries for platform.internal...
        └── queries for other approved AWS-private names


ONE OUTBOUND ENDPOINT
        │
        ├── rule for azure.internal...
        ├── rule for gcp.internal...
        └── rule for id.internal...
</code></pre></div></div>

<p>Separate endpoints may still be justified by different:</p>

<ul>
  <li>security boundaries;</li>
  <li>source networks;</li>
  <li>protocols;</li>
  <li>owning teams;</li>
  <li>routing paths;</li>
  <li>availability requirements;</li>
  <li>Regions.</li>
</ul>

<p>The default should not be one endpoint per domain.</p>

<p>The endpoint is shared DNS transport. The rules provide namespace-specific routing.</p>

<h3 id="the-endpoint-vpc-becomes-an-audience-and-routing-boundary">The endpoint VPC becomes an audience and routing boundary</h3>

<p>For bfstore, I would evaluate central Resolver endpoints in the <code class="language-plaintext highlighter-rouge">bfstore-network</code> account.</p>

<p>A dedicated DNS VPC or carefully designed network-services VPC could host:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE-NETWORK ACCOUNT

DNS SERVICES VPC
│
├── inbound endpoint
│   ├── subnet-a
│   └── subnet-b
│
├── outbound endpoint
│   ├── subnet-a
│   └── subnet-b
│
└── external connectivity
    ├── Transit Gateway
    ├── VPN
    └── future cloud connections
</code></pre></div></div>

<p>The endpoint VPC is not selected merely because it is central. It needs:</p>

<ul>
  <li>private hosted-zone associations for inbound answers;</li>
  <li>routes towards external resolvers;</li>
  <li>return routes from those resolvers;</li>
  <li>suitable subnets in multiple Availability Zones;</li>
  <li>security groups permitting the selected protocols;</li>
  <li>operational ownership.</li>
</ul>

<p>A subnet labelled <code class="language-plaintext highlighter-rouge">dns</code> does not create connectivity through the force of nominative optimism.</p>

<p>The complete query and response route must exist.</p>

<h3 id="shared-rules-centralise-outbound-behaviour">Shared rules centralise outbound behaviour</h3>

<p>A central networking account can create outbound Resolver rules and share them with other AWS accounts using AWS Resource Access Manager.</p>

<p>Consuming accounts associate the shared rules with their VPCs. Queries from those VPCs then use the centrally owned outbound endpoint named by the rule.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE-NETWORK

outbound endpoint

shared rules:
    azure.internal...
    gcp.internal...
    id.internal...
          │
          ▼
AWS RAM
          │
          ├── bfstore-dev
          ├── bfstore-staging
          ├── bfstore-prod
          └── bfstore-shared-services
</code></pre></div></div>

<p>This creates a useful platform boundary.</p>

<p>The network team controls:</p>

<ul>
  <li>external target resolvers;</li>
  <li>endpoint security;</li>
  <li>DNS transport;</li>
  <li>common namespace rules.</li>
</ul>

<p>Workload accounts control:</p>

<ul>
  <li>which shared rules are associated with their VPCs;</li>
  <li>which applications use the resulting names;</li>
  <li>application access to the resolved services.</li>
</ul>

<p>A shared rule remains centrally owned. Deleting or unsharing it changes how associated VPCs process matching queries, so its lifecycle is a cross-account production concern.</p>

<h3 id="resolver-endpoints-and-rules-are-regional">Resolver endpoints and rules are Regional</h3>

<p>A rule created in one Region is not automatically available in another.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EU-WEST-2

inbound endpoint
outbound endpoint
regional rules


EU-CENTRAL-1

inbound endpoint
outbound endpoint
regional rules
</code></pre></div></div>

<p>A multi-Region design should decide whether:</p>

<ul>
  <li>each Region forwards directly to external resolvers;</li>
  <li>one Region acts as a DNS hub;</li>
  <li>a hub failure affects other Regions;</li>
  <li>authorities and private zones exist in both Regions;</li>
  <li>rules and target addresses are kept consistent.</li>
</ul>

<p>Forwarding every Region through one distant DNS hub may reduce infrastructure while adding latency and a central dependency.</p>

<p>A Regional service should not be made global through wishful arrows.</p>

<h2 id="operating-resolver-endpoints">Operating Resolver endpoints</h2>

<h3 id="availability-starts-with-endpoint-addresses">Availability starts with endpoint addresses</h3>

<p>Each endpoint requires at least two IP addresses. AWS recommends placing them in at least two Availability Zones. Each address creates a Resolver-managed network interface.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DNS SERVICES VPC

Availability Zone A
    inbound endpoint ENI
    outbound endpoint ENI


Availability Zone B
    inbound endpoint ENI
    outbound endpoint ENI
</code></pre></div></div>

<p>External resolvers using an inbound endpoint should be configured with every endpoint address.</p>

<p>The target DNS environment behind an outbound endpoint should also provide multiple healthy resolvers where availability requires it.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS OUTBOUND ENDPOINT
        │
        ├── external DNS server A
        └── external DNS server B
</code></pre></div></div>

<p>Two AWS interfaces forwarding towards one fragile external DNS server still produce one very carefully approached failure domain.</p>

<h3 id="match-security-groups-to-the-endpoint-protocol">Match security groups to the endpoint protocol</h3>

<p>Do53 uses DNS over UDP or TCP. A Do53 endpoint policy should normally account for both transports on port 53 because DNS can fall back to TCP for large or truncated responses and other protocol behaviours.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DO53 INBOUND ENDPOINT

allow UDP/53 and TCP/53
from approved external resolver ranges


DO53 OUTBOUND ENDPOINT

allow UDP/53 and TCP/53
to approved target resolver addresses
</code></pre></div></div>

<p>DoH uses HTTPS over TCP port 443. DoH-FIPS is supported for inbound endpoints only. Delegation inbound endpoints currently support Do53 only.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DOH INBOUND ENDPOINT

allow TCP/443
from approved external resolver ranges
</code></pre></div></div>

<p>The exact policy should follow the protocols enabled on the endpoint and supported by the connected resolver.</p>

<p>Encryption does not replace DNS access control. It protects the query in transit. Security groups and resolver configuration still decide who may submit it.</p>

<p>Overly broad rules increase the number of clients capable of using the bridge. Restrictive, connection-tracked security-group paths can also reduce effective endpoint throughput, so security and scaling should be reviewed together.</p>

<h3 id="address-family-and-dns-transport-are-separate-from-the-answer">Address family and DNS transport are separate from the answer</h3>

<p>Resolver endpoints can use IPv4, IPv6 or dual-stack addressing. Inbound endpoints can also support DNS64 for IPv6-only clients that need synthesized AAAA answers for IPv4-only destinations, when paired with NAT64.</p>

<p>The design should ask:</p>

<ul>
  <li>Are external resolvers reachable over IPv4, IPv6 or both?</li>
  <li>Which address family does the endpoint expose?</li>
  <li>Does the network path carry that family?</li>
  <li>Do security groups permit it?</li>
  <li>Does the returned application path support the address family advertised by DNS?</li>
</ul>

<p>A dual-stack DNS endpoint does not make an application dual-stack.</p>

<p>It only allows the DNS transport to use both address families.</p>

<h3 id="prevent-forwarding-loops">Prevent forwarding loops</h3>

<p>Consider:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPC Resolver
      │
      ▼
outbound rule
      │
      ▼
external DNS server
      │
      ▼
inbound endpoint
      │
      ▼
VPC Resolver
</code></pre></div></div>

<p>If the external resolver sends the same namespace back into AWS, and the AWS rule sends it out again, the query begins a circular tour.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS asks external

external asks AWS

AWS asks external

external asks AWS
</code></pre></div></div>

<p>The solution begins with clear authority:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>For each namespace,
one resolver path must terminate
at an authority capable of answering it.
</code></pre></div></div>

<p>The design record should state:</p>

<ul>
  <li>authoritative DNS system;</li>
  <li>forwarding origin;</li>
  <li>forwarding targets;</li>
  <li>permitted return behaviour;</li>
  <li>more-specific exceptions;</li>
  <li>expected terminal response.</li>
</ul>

<p>A diagram containing two arrows is not automatically resilient.</p>

<p>Sometimes it is a very tidy loop.</p>

<h3 id="scale-per-endpoint-address-not-per-diagram">Scale per endpoint address, not per diagram</h3>

<p>As of July 2026, AWS documents up to 10,000 UDP DNS queries per second per endpoint IP address under qualifying conditions. Actual capacity depends on query type, response size, latency, target health, protocol and connection tracking. AWS notes that tracked paths can reduce effective capacity substantially.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ENDPOINT

IP address A
    → DNS query capacity

IP address B
    → DNS query capacity

IP address C
    → additional capacity
</code></pre></div></div>

<p>DNS traffic can be bursty:</p>

<ul>
  <li>workloads restarting after an outage;</li>
  <li>short-lived Kubernetes Jobs;</li>
  <li>low TTLs on popular names;</li>
  <li>an external resolver losing its cache;</li>
  <li>repeated negative lookups during a migration;</li>
  <li>search-suffix mistakes;</li>
  <li>random-name traffic.</li>
</ul>

<p>Scaling should consider peak query rate per endpoint address, not only average daily volume.</p>

<h3 id="observe-the-bridge-and-the-authority-behind-it">Observe the bridge and the authority behind it</h3>

<p>Route 53 Resolver publishes CloudWatch metrics for inbound and outbound endpoints, including endpoint-interface health and query volume. Optional detailed metrics include latency and response outcomes such as <code class="language-plaintext highlighter-rouge">SERVFAIL</code>, <code class="language-plaintext highlighter-rouge">NXDOMAIN</code>, refusals and timeouts.</p>

<p>Outbound endpoints can also publish target name-server metrics, including request count, latency and timeouts for each target.</p>

<p>A useful bfstore dashboard would show:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INBOUND

queries by endpoint IP
healthy endpoint interfaces
response latency
SERVFAIL
NXDOMAIN
timeouts


OUTBOUND

queries by endpoint IP
target resolver request count
target resolver latency
target resolver timeouts
healthy endpoint interfaces
</code></pre></div></div>

<p>Resolver query logs can add:</p>

<ul>
  <li>queried name;</li>
  <li>source VPC;</li>
  <li>source address;</li>
  <li>response code;</li>
  <li>returned answer.</li>
</ul>

<p>Application telemetry shows whether failed resolution affected a customer operation.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLOUDWATCH ENDPOINT METRICS

Is the bridge operating?


QUERY LOGS

Which questions crossed it?


APPLICATION TELEMETRY

What did DNS failure do
to the customer journey?
</code></pre></div></div>

<p>A healthy endpoint with a misspelled forwarding rule is still a beautifully monitored wrong turn.</p>

<h3 id="cost-and-quotas-favour-shared-infrastructure">Cost and quotas favour shared infrastructure</h3>

<p>Resolver endpoint pricing is based on provisioned endpoint addresses and processed DNS queries. Because each endpoint requires at least two addresses, even an idle endpoint has a baseline cost.</p>

<p>Related architecture may also cost money:</p>

<ul>
  <li>VPN;</li>
  <li>Transit Gateway;</li>
  <li>Direct Connect;</li>
  <li>cross-Region traffic;</li>
  <li>external resolver infrastructure;</li>
  <li>optional detailed CloudWatch metrics;</li>
  <li>log storage.</li>
</ul>

<p>As of July 2026, AWS documents default quotas including:</p>

<ul>
  <li>four Resolver endpoints per account per Region;</li>
  <li>six IP addresses per endpoint;</li>
  <li>1,000 rules per account per Region;</li>
  <li>2,000 associations between rules and VPCs per account per Region;</li>
  <li>six target addresses per rule.</li>
</ul>

<p>Several quotas can be increased.</p>

<p>The intended shape remains:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>few shared endpoints

many namespace rules

many VPC associations
</code></pre></div></div>

<p>The endpoint is transport infrastructure.</p>

<p>The rule is the unit of DNS-routing policy.</p>

<h2 id="the-bfstore-architecture-now">The bfstore architecture now</h2>

<p>bfstore currently has:</p>

<ul>
  <li>AWS VPCs;</li>
  <li>Route 53 private hosted zones;</li>
  <li>AmazonProvidedDNS;</li>
  <li>no on-premises data centre;</li>
  <li>no active Azure or GCP private network;</li>
  <li>no external DNS authority requiring conditional forwarding.</li>
</ul>

<p>The current design remains:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">route53_resolver</span><span class="pi">:</span>
  <span class="na">inbound_endpoints</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">outbound_endpoints</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">forwarding_rules</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">delegation_rules</span><span class="pi">:</span> <span class="pi">[]</span>
</code></pre></div></div>

<p>Private hosted zones are associated directly with the VPCs that consume them.</p>

<p>That is enough.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE AWS TODAY

workload
   → AmazonProvidedDNS
   → associated private hosted zone
</code></pre></div></div>

<p>Resolver endpoints should be introduced when an external DNS relationship becomes real.</p>

<h3 id="future-aws-to-azure-or-aws-to-gcp-integration">Future AWS-to-Azure or AWS-to-GCP integration</h3>

<p>AWS workloads need cloud-private names:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AWS Resolver
    → outbound rule
    → outbound endpoint
    → cloud-side resolver
</code></pre></div></div>

<p>Cloud workloads need AWS-private names:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cloud-side resolver
    → inbound endpoint
    → AWS Resolver
</code></pre></div></div>

<p>The design requires both directions only when the DNS relationship is genuinely bidirectional.</p>

<h3 id="external-identity-platform">External identity platform</h3>

<p>An externally hosted identity platform may need to resolve a private AWS service, or AWS may need to resolve a private identity namespace.</p>

<p>The product integration should determine whether private DNS is necessary. A public authenticated endpoint does not require a private Resolver bridge merely because bfstore enjoys network diagrams.</p>

<h3 id="hybrid-enterprise-network">Hybrid enterprise network</h3>

<p>An on-premises DNS environment may forward AWS-private names inbound while AWS forwards corporate domains outbound.</p>

<p>That is the classic bidirectional Resolver endpoint use case.</p>

<h2 id="a-practical-implementation-sequence">A practical implementation sequence</h2>

<h3 id="1-document-the-authority">1. Document the authority</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">namespace</span><span class="pi">:</span> <span class="s">azure.internal.bfstore.example</span>
<span class="na">authority</span><span class="pi">:</span> <span class="s">azure-private-dns</span>
<span class="na">consumers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">bfstore-dev</span>
  <span class="pi">-</span> <span class="s">bfstore-staging</span>
  <span class="pi">-</span> <span class="s">bfstore-prod</span>
</code></pre></div></div>

<h3 id="2-establish-network-connectivity">2. Establish network connectivity</h3>

<p>Prove that the endpoint VPC can reach each external resolver and receive the reply.</p>

<h3 id="3-create-endpoint-subnets">3. Create endpoint subnets</h3>

<p>Use at least two Availability Zones and reserve addresses for growth.</p>

<h3 id="4-create-protocol-specific-security-groups">4. Create protocol-specific security groups</h3>

<p>Permit only the required Do53 or DoH traffic between intended resolver ranges.</p>

<h3 id="5-create-the-endpoint">5. Create the endpoint</h3>

<p>Choose inbound or outbound according to where the query originates.</p>

<h3 id="6-create-narrow-rules">6. Create narrow rules</h3>

<p>Begin with the smallest domain requiring external authority. Use delegation only when the DNS authority model and required NS records support it.</p>

<h3 id="7-share-and-associate-rules">7. Share and associate rules</h3>

<p>Associate only the VPCs that genuinely consume the namespace.</p>

<h3 id="8-configure-the-external-resolver">8. Configure the external resolver</h3>

<p>For ordinary inbound use, configure conditional forwarding to every endpoint address. For inbound delegation, create the required parent-side NS and glue records.</p>

<h3 id="9-enable-evidence">9. Enable evidence</h3>

<p>Add query logging, endpoint metrics and target name-server metrics where useful.</p>

<h3 id="10-test-success-failure-and-isolation">10. Test success, failure and isolation</h3>

<p>Confirm:</p>

<ul>
  <li>the intended name resolves;</li>
  <li>a child name uses the correct authority;</li>
  <li>an unrelated name remains on its ordinary path;</li>
  <li>an unapproved network cannot query the endpoint;</li>
  <li>loss of one endpoint interface does not remove resolution;</li>
  <li>loss of one target resolver is tolerated;</li>
  <li>no forwarding loop exists.</li>
</ul>

<p>The test is not complete when:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig returned an address once.
</code></pre></div></div>

<p>It is complete when the authority, failure behaviour and isolation match the design.</p>

<h2 id="the-mental-model-i-am-keeping">The mental model I am keeping</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                       DNS QUERY
                           │
                           ▼
                  WHERE DID IT ORIGINATE?
                           │
             ┌─────────────┴─────────────┐
             │                           │
             ▼                           ▼
          INSIDE AWS                 OUTSIDE AWS
             │                           │
             ▼                           ▼
      AmazonProvidedDNS          EXTERNAL RESOLVER
             │                           │
             ▼                           ▼
       WHO OWNS THE NAME?         WHO OWNS THE NAME?
             │                           │
    ┌────────┴────────┐          ┌───────┴────────┐
    │                 │          │                │
    ▼                 ▼          ▼                ▼
ROUTE 53         EXTERNAL DNS  AWS DNS       EXTERNAL DNS
    │                 │          │                │
    ▼                 ▼          ▼                ▼
ANSWER         OUTBOUND RULE  INBOUND         ANSWER
DIRECTLY            │         ENDPOINT        DIRECTLY
                    ▼             │
             OUTBOUND ENDPOINT    ▼
                    │        ROUTE 53 RESOLVER
                    ▼
              EXTERNAL RESOLVER
</code></pre></div></div>

<p>AmazonProvidedDNS answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Can AWS already resolve
this name locally?
</code></pre></div></div>

<p>The private hosted-zone association answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Does this VPC receive
the AWS-private namespace?
</code></pre></div></div>

<p>The inbound endpoint answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How can another DNS environment
ask Route 53 Resolver a question?
</code></pre></div></div>

<p>The outbound endpoint answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How can Route 53 Resolver
ask another authority a question?
</code></pre></div></div>

<p>The Resolver rule answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which namespace should cross
the outbound bridge?
</code></pre></div></div>

<p>The system rule answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which more-specific branch
should remain with Route 53?
</code></pre></div></div>

<p>Delegation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which DNS authority owns
this child namespace?
</code></pre></div></div>

<p>The endpoint security group answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which resolvers may send
or receive these questions?
</code></pre></div></div>

<p>CloudWatch answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is the bridge healthy,
and is the authority responding?
</code></pre></div></div>

<p>Query logging answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which names actually crossed it?
</code></pre></div></div>

<p>And the lack of an endpoint answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is every current DNS question
already being answered inside AWS?
</code></pre></div></div>

<p>For bfstore today, most of them are.</p>

<p>For bfstore’s future multi-cloud environment, that answer will change.</p>

<blockquote>
  <p><strong>Create an inbound endpoint when another resolver needs AWS answers. Create an outbound endpoint when AWS needs another resolver’s answers. Create both when the DNS relationship is genuinely bidirectional.</strong></p>
</blockquote>

<p>Do not create either merely because the record is private.</p>

<p>Do not create one endpoint per hosted zone.</p>

<p>Do not forward a namespace before deciding who owns it.</p>

<p>Do not join two forwarding paths without checking whether they form a circle.</p>

<p>Resolver endpoints are not the foundation of VPC DNS.</p>

<p>AmazonProvidedDNS already holds that job.</p>

<p>The endpoints are the carefully guarded doors added when DNS must finally leave the room.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver.html">What is Route 53 VPC Resolver?</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-choose-vpc.html">Considerations when creating inbound and outbound endpoints</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-forwarding-inbound-queries.html">Forwarding inbound DNS queries to your VPCs</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-forwarding-inbound-queries-values.html">Values for inbound endpoints</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-forwarding-outbound-queries.html">Forwarding outbound DNS queries to your network</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-forwarding-outbound-queries-endpoint-values.html">Values for outbound endpoints</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-forward-vpc-to-network-using-rules.html">Using rules to control outbound DNS</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-forward-vpc-to-network-domain-name-matches.html">How VPC Resolver matches domain names to rules</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html">Private hosted-zone considerations</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/outbound-delegation-tutorial.html">Resolver delegation rules tutorial</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/best-practices-resolver-endpoint-high-availability.html">High availability for Resolver endpoints</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/best-practices-resolver-endpoint-scaling.html">Resolver endpoint scaling</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/monitoring-resolver-with-cloudwatch.html">Monitoring Resolver endpoints with CloudWatch</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html">Route 53 Resolver quotas</a></li>
  <li><a href="https://aws.amazon.com/route53/pricing/">Amazon Route 53 pricing</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[Route 53 Resolver endpoints extend AWS DNS across hybrid, multi-cloud and external resolver boundaries. Inbound endpoints let other networks query AWS private DNS, while outbound endpoints let AWS forward selected namespaces to external DNS authorities.]]></summary></entry><entry><title type="html">Route 53 private hosted zones: DNS inside a VPC</title><link href="https://cloud.doublewords.net/writing/2026/07/02/route53-private-hosted-zones-dns-inside-a-vpc/" rel="alternate" type="text/html" title="Route 53 private hosted zones: DNS inside a VPC" /><published>2026-07-02T00:00:00+01:00</published><updated>2026-07-02T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/07/02/route53-private-hosted-zones-dns-inside-a-vpc</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/07/02/route53-private-hosted-zones-dns-inside-a-vpc/"><![CDATA[<p>The bfstore production application needs to send telemetry to its self-hosted observability platform.</p>

<p>The observability service currently has this private address:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10.96.12.45
</code></pre></div></div>

<p>I could place that address directly in the application configuration:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">telemetry</span><span class="pi">:</span>
  <span class="na">endpoint</span><span class="pi">:</span> <span class="s">10.96.12.45:4317</span>
</code></pre></div></div>

<p>That works until the service moves, a load balancer replaces it, recovery activates another destination or somebody must remember what <code class="language-plaintext highlighter-rouge">10.96.12.45</code> represents.</p>

<p>An address identifies a current network location.</p>

<p>The application needs a stable service identity:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>The name can point to:</p>

<ul>
  <li>an internal load balancer;</li>
  <li>a PrivateLink interface endpoint;</li>
  <li>one or more intentional private addresses;</li>
  <li>another Route 53 record;</li>
  <li>a failover destination.</li>
</ul>

<p>The application keeps the service name.</p>

<p>DNS absorbs the change in location.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE APPLICATION
        │
        │ query:
        │ otel.prod.aws.internal.bfstore.example
        ▼
ROUTE 53 VPC RESOLVER
        │
        ▼
PRIVATE DNS VIEW
        │
        ▼
PRIVATE SERVICE DESTINATION
</code></pre></div></div>

<p>A Route 53 private hosted zone contains records that Route 53 VPC Resolver can use to answer queries for a domain and its subdomains. The private answer is available through the resolver contexts to which the zone is distributed, traditionally by direct VPC association and now also through Route 53 Profiles. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-private.html">AWS describes the basic private hosted-zone model here.</a></p>

<blockquote>
  <p><strong>A private hosted zone gives selected resolver views a private interpretation of a DNS namespace.</strong></p>
</blockquote>

<p>This article covers VPC DNS through direct associations, Profiles and Resolver rules and endpoints. Route 53 Global Resolver is outside scope. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/gr-what-is-global-resolver.html">AWS documents it separately.</a></p>

<h2 id="private-dns-is-one-part-of-private-service-access">Private DNS is one part of private service access</h2>

<p>Suppose bfstore creates:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>with these records:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders.prod.aws.internal.bfstore.example
payments.prod.aws.internal.bfstore.example
otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>The zone tells Route 53 how to answer those names.</p>

<p>It does not establish a route to the returned address.</p>

<p>If:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>resolves to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10.96.12.45
</code></pre></div></div>

<p>the client still needs a network path to <code class="language-plaintext highlighter-rouge">10.96.12.45</code>.</p>

<p>That path may use:</p>

<ul>
  <li>local VPC routing;</li>
  <li>Transit Gateway;</li>
  <li>VPC peering;</li>
  <li>AWS PrivateLink;</li>
  <li>another private network connection.</li>
</ul>

<p>Security groups, network ACLs, firewalls and application authentication must also permit the connection.</p>

<table>
  <thead>
    <tr>
      <th>Control</th>
      <th>Question</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Private DNS</td>
      <td>Where should this name lead?</td>
    </tr>
    <tr>
      <td>Route table</td>
      <td>Can the destination address be reached?</td>
    </tr>
    <tr>
      <td>Network policy</td>
      <td>May this source establish the connection?</td>
    </tr>
    <tr>
      <td>Application identity</td>
      <td>May this caller perform the operation?</td>
    </tr>
  </tbody>
</table>

<p>DNS is not private access control. A strong design provides both a stable name and an intentional access path.</p>

<h2 id="how-the-private-namespace-reaches-a-vpc">How the private namespace reaches a VPC</h2>

<p>A private hosted zone must be associated with a VPC when it is created. More VPCs can be associated afterwards. The associated VPCs require <code class="language-plaintext highlighter-rouge">enableDnsSupport</code> and <code class="language-plaintext highlighter-rouge">enableDnsHostnames</code> to be enabled for private hosted-zone resolution. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html">AWS documents those VPC requirements and the matching behaviour.</a></p>

<p>There are now two principal ways to distribute the namespace.</p>

<h3 id="direct-hosted-zone-association">Direct hosted-zone association</h3>

<p>The zone is associated directly with each consuming VPC.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE HOSTED ZONE
prod.aws.internal.bfstore.example
            │
            ├── bfstore-prod VPC
            ├── bfstore-observability VPC
            └── bfstore-backup VPC
</code></pre></div></div>

<p>This model is explicit and works well when the VPC inventory is modest or when each association needs individual approval.</p>

<h3 id="route-53-profile">Route 53 Profile</h3>

<p>A Route 53 Profile can collect DNS configuration and apply it to many VPCs, including VPCs in other accounts through AWS Resource Access Manager.</p>

<p>Profiles can distribute:</p>

<ul>
  <li>private hosted-zone associations;</li>
  <li>Resolver rules;</li>
  <li>DNS Firewall rule groups;</li>
  <li>Resolver query-logging configurations.</li>
</ul>

<p>A VPC can have one Profile association. When local VPC DNS configuration conflicts with Profile-delivered configuration, the local configuration takes precedence; the most-specific matching domain still determines the winner where names differ in specificity. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/profiles.html">AWS documents Profile resources and precedence here.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ROUTE 53 PROFILE
production-dns
        │
        ├── private hosted zones
        ├── Resolver rules
        ├── DNS Firewall policies
        └── query logging
                │
                ├── prod VPC
                ├── observability VPC
                └── backup VPC
</code></pre></div></div>

<p>A useful choice is:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Best fit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Direct association</td>
      <td>Smaller estate, explicit per-VPC control, exceptional namespace</td>
    </tr>
    <tr>
      <td>Route 53 Profile</td>
      <td>Repeated DNS configuration across many VPCs and accounts</td>
    </tr>
  </tbody>
</table>

<p>The association mechanism defines <strong>resolution scope</strong>: which resolver views receive the namespace. It does not authorise workloads to use the services behind the records.</p>

<h2 id="transit-gateway-does-not-distribute-dns">Transit Gateway does not distribute DNS</h2>

<p>Suppose the production and observability VPCs are attached to the same Transit Gateway.</p>

<p>The routes work:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BFSTORE PROD VPC
       │
       ▼
TRANSIT GATEWAY
       │
       ▼
OBSERVABILITY VPC
</code></pre></div></div>

<p>That does not automatically make a private hosted zone available to production.</p>

<p>Transit Gateway provides Layer 3 connectivity.</p>

<p>A direct zone association, Route 53 Profile or deliberate forwarding design determines whether the source resolver view receives the private answer.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TRANSIT GATEWAY

Can the returned address
be reached?


DNS CONFIGURATION

Can this resolver view
obtain the private answer?
</code></pre></div></div>

<blockquote>
  <p><strong>Routing joins address spaces. DNS configuration joins naming views.</strong></p>
</blockquote>

<p>They must often be designed together, but they are not the same control plane.</p>

<h2 id="workloads-query-route-53-vpc-resolver">Workloads query Route 53 VPC Resolver</h2>

<p>Workloads do not normally query the private zone’s displayed name servers directly.</p>

<p>They send queries to Route 53 VPC Resolver, the recursive DNS service available in each VPC. VPC Resolver answers local VPC names, associated private hosted zones and public recursive queries, and applies matching Resolver rules. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver.html">AWS describes the resolver path here.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKLOAD
   │
   ▼
ROUTE 53 VPC RESOLVER
   │
   ├── local VPC names
   ├── private hosted zones
   ├── Resolver rules
   └── public recursive resolution
</code></pre></div></div>

<p>The ordinary DHCP option set normally directs resources towards AmazonProvidedDNS. Custom first-hop DNS servers need conditional forwarding, commonly through Resolver inbound endpoints, for names AWS should answer.</p>

<h2 id="how-vpc-resolver-chooses-the-winning-answer">How VPC Resolver chooses the winning answer</h2>

<p>Most private DNS mistakes become easier to understand when resolution is treated as a selection process.</p>

<p>The resolver considers:</p>

<ol>
  <li>local and Profile-delivered DNS configuration;</li>
  <li>matching Resolver rules;</li>
  <li>matching private hosted zones;</li>
  <li>namespace specificity;</li>
  <li>record presence;</li>
  <li>public recursion only when no private namespace or rule claims the query.</li>
</ol>

<h3 id="split-view-dns">Split-view DNS</h3>

<p>Route 53 can use public and private hosted zones with the same name.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PUBLIC ZONE
bfstore.example

PRIVATE ZONE
bfstore.example
</code></pre></div></div>

<p>An internet client receives the public view.</p>

<p>A client using VPC Resolver in a resolver view containing the private zone receives the private view.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INTERNET CLIENT
      │
      ▼
PUBLIC ZONE
      │
      ▼
PUBLIC ENDPOINT


VPC CLIENT
      │
      ▼
PRIVATE ZONE
      │
      ▼
PRIVATE ENDPOINT
</code></pre></div></div>

<p>The zones do not share or inherit records.</p>

<h3 id="a-matching-private-zone-prevents-public-fallback">A matching private zone prevents public fallback</h3>

<p>Suppose the public zone contains:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docs.bfstore.example
</code></pre></div></div>

<p>The production resolver view also contains a private zone:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bfstore.example
</code></pre></div></div>

<p>but that private zone does not contain the <code class="language-plaintext highlighter-rouge">docs</code> record.</p>

<p>VPC Resolver finds a matching private namespace, searches it and returns <code class="language-plaintext highlighter-rouge">NXDOMAIN</code>.</p>

<p>It does not retry against the public zone. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html">AWS documents this behaviour explicitly.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QUERY
docs.bfstore.example
        │
        ▼
MATCHING PRIVATE ZONE
bfstore.example
        │
        ▼
RECORD ABSENT
        │
        ▼
NXDOMAIN
</code></pre></div></div>

<p>A broad same-name private zone claims authority over the complete branch for that resolver view.</p>

<p>If internal clients still need public records, bfstore must:</p>

<ul>
  <li>reproduce the required records privately;</li>
  <li>use a narrower private namespace;</li>
  <li>redesign forwarding;</li>
  <li>avoid creating the overlapping private zone.</li>
</ul>

<h3 id="the-most-specific-namespace-wins">The most-specific namespace wins</h3>

<p>A resolver view may contain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>internal.bfstore.example

prod.internal.bfstore.example

payments.prod.internal.bfstore.example
</code></pre></div></div>

<p>For:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>authorise.payments.prod.internal.bfstore.example
</code></pre></div></div>

<p>the most-specific matching zone is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>payments.prod.internal.bfstore.example
</code></pre></div></div>

<p>VPC Resolver searches that zone.</p>

<p>If the record is absent, it does not continue upwards and search the less-specific zones for the same record. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html">AWS documents most-specific matching for overlapping private zones.</a></p>

<p>This allows <strong>independent authority</strong> for narrower namespaces, but can also create accidental shadowing. Review a new, more-specific zone like a routing change.</p>

<p>The word “delegation” should be reserved for designs that actually use DNS delegation semantics. A separately associated child private hosted zone can become the winning authority without the parent zone containing an NS delegation.</p>

<h3 id="a-resolver-rule-can-override-the-hosted-zone">A Resolver rule can override the hosted zone</h3>

<p>Suppose a VPC has:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Private hosted zone:
corp.example

Forwarding rule:
corp.example
    → enterprise DNS
</code></pre></div></div>

<p>The forwarding rule takes precedence over the private hosted zone.</p>

<p>Resolver also chooses the most-specific matching rule when several rules apply. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-DSN-queries-to-vpc.html">AWS documents rule selection and private-zone interactions here.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QUERY
service.corp.example
        │
        ▼
MATCHING FORWARD RULE
corp.example
        │
        ▼
ENTERPRISE DNS
</code></pre></div></div>

<p>A correct private record can remain unused because a rule intercepted the namespace. Troubleshooting must inspect both zones and rules.</p>

<h2 id="choose-zone-boundaries-around-ownership-and-audience">Choose zone boundaries around ownership and audience</h2>

<p>One possible design is a broad zone:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>internal.bfstore.example
</code></pre></div></div>

<p>It could contain production, non-production and platform records.</p>

<p>That minimises the number of zones.</p>

<p>It also centralises every change beneath one authority.</p>

<p>A more separated design might use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>prod.aws.internal.bfstore.example

nonprod.aws.internal.bfstore.example

platform.aws.internal.bfstore.example
</code></pre></div></div>

<p>Separate a zone when the namespace needs a different owner, audience, IAM policy, change lifecycle or incident boundary. Production records should not normally share the everyday development DNS role.</p>

<p>The displayed hierarchy does not imply that the zones are automatically delegated to one another. Each zone becomes authoritative in resolver views where it is directly or indirectly associated, subject to the resolver’s selection rules.</p>

<p>Names should express durable service identity rather than incidental infrastructure.</p>

<p>Prefer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>over:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders-eks-cluster-3-subnet-a.prod...
</code></pre></div></div>

<p>The service may move between clusters without requiring every client to learn a new name.</p>

<h2 id="records-should-point-to-durable-service-boundaries">Records should point to durable service boundaries</h2>

<p>Private hosted zones support ordinary Route 53 record types and alias records.</p>

<p>Common bfstore patterns include:</p>

<h3 id="internal-load-balancer">Internal load balancer</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders.prod.aws.internal.bfstore.example
    → internal ALB or NLB alias
</code></pre></div></div>

<h3 id="privatelink-endpoint">PrivateLink endpoint</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>otel.prod.aws.internal.bfstore.example
    → interface endpoint alias
</code></pre></div></div>

<h3 id="database-endpoint">Database endpoint</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mysql.orders.prod.aws.internal.bfstore.example
    → RDS endpoint through CNAME where appropriate
</code></pre></div></div>

<h3 id="intentional-private-address">Intentional private address</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>legacy.prod.aws.internal.bfstore.example
    → 10.80.40.25
</code></pre></div></div>

<p>Alias records can target supported AWS resources and can be used at a zone apex. A CNAME cannot be used at the apex and cannot coexist with other data at the same owner name. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html">AWS compares aliases and CNAME records here.</a></p>

<p>Use direct IP records only for intentionally stable, owned addresses, not as an inventory of ephemeral Kubernetes Pods.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KUBERNETES-INTERNAL DISCOVERY

use Kubernetes Service and cluster DNS


VPC OR CROSS-VPC BOUNDARY

consider Route 53 private DNS
</code></pre></div></div>

<p>Private hosted zones are strongest at durable service boundaries. Publishing a record does not grant permission to use the service.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DNS

discover the destination


NETWORK POLICY

permit the connection


SERVICE IDENTITY

authorise the operation
</code></pre></div></div>

<p>A useful internal name is a stable label, not an invitation.</p>

<h2 id="ttl-and-alias-records-need-different-treatment">TTL and alias records need different treatment</h2>

<p>DNS resolvers cache ordinary record answers according to the record’s time to live.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RECORD CHANGED
      │
      ▼
ROUTE 53 HAS NEW ANSWER
      │
      ▼
CACHE MAY HOLD OLD ANSWER
      │
      ▼
TTL EXPIRES
      │
      ▼
CLIENT QUERIES AGAIN
</code></pre></div></div>

<p>Shorter TTLs make changes visible sooner; longer TTLs reduce query frequency. Alias records do not always expose a configurable TTL.</p>

<ul>
  <li>When an alias points to an AWS resource, Route 53 uses the target resource’s default TTL.</li>
  <li>When an alias points to another record in the same hosted zone, Route 53 uses the target record’s TTL.</li>
  <li>Ordinary non-alias records such as A, AAAA and CNAME records have an explicitly configured TTL.</li>
</ul>

<p><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html">AWS documents these alias TTL rules here.</a></p>

<p>A team cannot assume it can lower an ALB alias TTL before migration; the target controls it.</p>

<p>The change process should distinguish:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ROUTE 53 CHANGE STATUS

Has Route 53 applied the new record?


CACHE LIFETIME

How long may resolvers and clients
retain the previous answer?
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">INSYNC</code> confirms the Route 53 change has propagated through Route 53.</p>

<p>It does not flush answers already cached by clients or recursive resolvers.</p>

<h2 id="health-and-failover-depend-on-the-target">Health and failover depend on the target</h2>

<p>Private hosted zones support failover and other routing policies.</p>

<p>The health source should match the target and the capability being protected.</p>

<table>
  <thead>
    <tr>
      <th>Destination</th>
      <th>Possible health source</th>
      <th>Important limitation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ALB or NLB alias</td>
      <td><code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code></td>
      <td>Inherits supported load-balancer and target-group health</td>
    </tr>
    <tr>
      <td>Private application endpoint</td>
      <td>CloudWatch alarm-backed Route 53 health check</td>
      <td>Alarm must represent the application capability</td>
    </tr>
    <tr>
      <td>Publicly reachable endpoint</td>
      <td>Direct Route 53 endpoint health check</td>
      <td>Health checker must be able to reach it</td>
    </tr>
    <tr>
      <td>Interface endpoint</td>
      <td>Application or synthetic metric</td>
      <td>Alias target health may not prove the provider service works</td>
    </tr>
  </tbody>
</table>

<p>When <code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code> is enabled for a supported alias target, the alias inherits the health of the referenced AWS resource or record. For load balancers, Route 53 can use load-balancer and target-group health. <a href="https://docs.aws.amazon.com/Route53/latest/APIReference/API_AliasTarget.html">AWS documents alias target health here.</a></p>

<p>This differs from a standalone Route 53 health check. Health checkers cannot directly monitor private, non-routable addresses, so private services can use a CloudWatch alarm-backed health check. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html">AWS documents both points here.</a> <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-creating-values.html">health-check-addresses</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIVATE SERVICE
      │
      ▼
APPLICATION OR SYNTHETIC METRIC
      │
      ▼
CLOUDWATCH ALARM
      │
      ▼
ROUTE 53 HEALTH CHECK
      │
      ▼
FAILOVER DECISION
</code></pre></div></div>

<p>The signal should represent the capability the DNS decision is meant to protect.</p>

<p>An instance status check may prove that an instance is running.</p>

<p>It may not prove that:</p>

<ul>
  <li>the service can process an order;</li>
  <li>its database is available;</li>
  <li>the recovery endpoint is ready;</li>
  <li>required credentials remain valid.</li>
</ul>

<p>DNS failover affects future lookups.</p>

<p>It does not move established connections, and cached answers may remain in use until their TTL expires.</p>

<h2 id="private-routing-policies-use-aws-resolver-context">Private routing policies use AWS resolver context</h2>

<p>Private hosted zones support several routing policies, including failover, weighted, latency and geolocation.</p>

<p>For private geolocation records, Route 53 uses the AWS Region of the originating VPC, not necessarily the human user’s location. Resolver endpoints can therefore shape the location Route 53 observes. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-geo-phz.html">AWS documents the private-zone behaviour here.</a></p>

<p>Before using private latency or geolocation routing, state explicitly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which resolver location does Route 53 see?

Does that location represent
the client population we intend to steer?
</code></pre></div></div>

<p>For many internal services, explicit regional names or application-level routing may be easier to reason about.</p>

<h2 id="multi-account-ownership">Multi-account ownership</h2>

<p>bfstore may place the private hosted zone in <code class="language-plaintext highlighter-rouge">bfstore-network</code> while workload VPCs live in separate accounts.</p>

<p>For a direct cross-account association:</p>

<ol>
  <li>the zone-owning account authorises one VPC association;</li>
  <li>the VPC-owning account completes the association;</li>
  <li>the unused authorisation should be removed.</li>
</ol>

<p>The cross-account workflow requires the API, CLI, SDK or infrastructure code rather than the Route 53 console alone. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-associate-vpcs-different-accounts.html">AWS documents the handshake here.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ZONE ACCOUNT
     │
     ▼
AUTHORISE VPC
     │
     ▼
VPC ACCOUNT
     │
     ▼
ASSOCIATE ZONE
</code></pre></div></div>

<p>For repeated organisation-wide configuration, a shared Route 53 Profile may remove much of that per-zone, per-VPC association work.</p>

<p>Profiles are shared through AWS RAM, and updates to the Profile propagate to its associated VPCs. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/sharing-profiles.html">AWS documents Profile sharing here.</a></p>

<p>The choice should be intentional:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DIRECT ASSOCIATION

explicit exceptional relationship


ROUTE 53 PROFILE

repeatable resolver view
for a class of VPCs
</code></pre></div></div>

<h2 id="dns-write-access-is-data-path-authority">DNS write access is data-path authority</h2>

<p>Changing:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>payments.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>can redirect payment traffic towards:</p>

<ul>
  <li>the wrong environment;</li>
  <li>an obsolete endpoint;</li>
  <li>a compromised destination;</li>
  <li>a recovery service that is not ready.</li>
</ul>

<p>DNS write permission is production data-path authority.</p>

<p>The change model should distinguish:</p>

<table>
  <thead>
    <tr>
      <th>Role</th>
      <th>Capabilities</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Zone administrator</td>
      <td>Create zones, associate resolver views, change ownership boundaries</td>
    </tr>
    <tr>
      <td>Record administrator</td>
      <td>Change approved records, TTLs, routing and health associations</td>
    </tr>
    <tr>
      <td>Read-only investigator</td>
      <td>Inspect zones, rules, Profiles, associations and change status</td>
    </tr>
  </tbody>
</table>

<p>Route 53 IAM conditions can restrict changes by normalised record name, record type and change action, and can restrict zone associations to particular VPCs. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-conditions-route53.html">AWS documents these fine-grained conditions here.</a></p>

<p>That supports a model such as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>APPLICATION TEAM

declares a service-name requirement


PLATFORM PIPELINE

validates the namespace and destination


ROUTE 53 ROLE

updates only approved names and types
</code></pre></div></div>

<p>The key question is who may redirect the name and where that decision is reviewed.</p>

<h2 id="logging-and-inventory-must-cover-the-resolver-view">Logging and inventory must cover the resolver view</h2>

<p>VPC Resolver query logging can record queries from associated VPCs and deliver them to CloudWatch Logs, S3 or Firehose.</p>

<p>Useful fields include:</p>

<ul>
  <li>VPC and source information;</li>
  <li>queried name and record type;</li>
  <li>response code;</li>
  <li>returned answer;</li>
  <li>DNS Firewall action.</li>
</ul>

<p>The logs can identify clients using retired names, explain <code class="language-plaintext highlighter-rouge">NXDOMAIN</code>, show failover lookups and reveal unexpected domains.</p>

<p>VPC Resolver caches answers.</p>

<p>Query logging records unique queries that reach the resolver, not repeated queries answered from the resolver cache. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-query-logs.html">AWS documents that limitation here.</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ONE QUERY-LOG ENTRY

does not equal

ONE APPLICATION REQUEST
</code></pre></div></div>

<p>The inventory must also cover every way DNS configuration reaches the VPC:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resolver_view</span><span class="pi">:</span>
  <span class="na">vpc_id</span><span class="pi">:</span> <span class="s">vpc-example</span>
  <span class="na">region</span><span class="pi">:</span> <span class="s">eu-west-2</span>

<span class="na">private_zones</span><span class="pi">:</span>
  <span class="na">direct</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">zone_id</span><span class="pi">:</span> <span class="s">Z123</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">prod.aws.internal.bfstore.example</span>

  <span class="na">profiles</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">profile_id</span><span class="pi">:</span> <span class="s">rp-example</span>
      <span class="na">private_zones</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">platform.aws.internal.bfstore.example</span>

<span class="na">resolver_rules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">domain</span><span class="pi">:</span> <span class="s">corp.example</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">FORWARD</span>

<span class="na">query_logging</span><span class="pi">:</span>
  <span class="na">destination</span><span class="pi">:</span> <span class="s">central-dns-logs</span>

<span class="na">dns_firewall</span><span class="pi">:</span>
  <span class="na">policy</span><span class="pi">:</span> <span class="s">production-egress</span>

<span class="na">endpoints</span><span class="pi">:</span>
  <span class="na">inbound</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="na">outbound</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">rslvr-out-example</span>
</code></pre></div></div>

<p>This distinction matters because <code class="language-plaintext highlighter-rouge">ListHostedZonesByVPC</code> and <code class="language-plaintext highlighter-rouge">GetHostedZone</code> do not report private hosted-zone associations delivered through Route 53 Profiles. Profile resource-association APIs must also be queried. <a href="https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListHostedZonesByVPC.html">AWS documents the inventory limitation here.</a></p>

<p>The architecture must know which rule, association or Profile actually supplies the resolver view.</p>

<h2 id="quotas-should-inform-not-dictate-the-design">Quotas should inform, not dictate, the design</h2>

<p>Route 53 applies quotas to hosted zones, records, associations, Profiles and other resources.</p>

<p>AWS currently documents:</p>

<ul>
  <li>10,000 records per hosted zone by default;</li>
  <li>300 direct VPC associations per private hosted zone;</li>
  <li>Route 53 Profiles as the recommended model when more associations are required.</li>
</ul>

<p>Because quotas can change or be increased, runbooks should link to the current page. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html">Current quotas are documented here.</a></p>

<p>The design tension remains:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TOO BROAD

one zone
many unrelated owners
large blast radius


TOO FRAGMENTED

one zone per record
large inventory
complex authority tree


USEFUL

one zone per meaningful
owner and audience boundary
</code></pre></div></div>

<p>The objective is not the fewest zones.</p>

<p>It is the clearest authority model.</p>

<h2 id="a-bfstore-private-dns-design">A bfstore private DNS design</h2>

<p>I would place private DNS governance in the <code class="language-plaintext highlighter-rouge">bfstore-network</code> account and use a combination of Route 53 Profiles and exceptional direct associations.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws.internal.bfstore.example
│
├── prod.aws.internal.bfstore.example
├── nonprod.aws.internal.bfstore.example
└── platform.aws.internal.bfstore.example
</code></pre></div></div>

<p>These are separate authority zones, not automatically delegated children.</p>

<h3 id="production-profile">Production Profile</h3>

<p>A <code class="language-plaintext highlighter-rouge">bfstore-production-dns</code> Profile could contain:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">prod.aws.internal.bfstore.example</code>;</li>
  <li>approved platform-zone access;</li>
  <li>central query logging;</li>
  <li>standard DNS Firewall policy;</li>
  <li>common enterprise forwarding rules.</li>
</ul>

<p>It would be shared with production-class VPCs through AWS RAM.</p>

<h3 id="non-production-profile">Non-production Profile</h3>

<p>A separate Profile could provide:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">nonprod.aws.internal.bfstore.example</code>;</li>
  <li>development forwarding rules;</li>
  <li>non-production logging and firewall policy.</li>
</ul>

<p>Development and staging can split when their ownership diverges.</p>

<h3 id="exceptional-direct-associations">Exceptional direct associations</h3>

<p>A narrow zone or rule can be associated directly when one VPC needs an exceptional resolver view.</p>

<p>Because local configuration overrides conflicting Profile configuration, the exception must be visible in inventory.</p>

<p>A conceptual record could look like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">private_hosted_zone</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">prod.aws.internal.bfstore.example</span>
  <span class="na">owner_account</span><span class="pi">:</span> <span class="s">bfstore-network</span>
  <span class="na">owner_team</span><span class="pi">:</span> <span class="s">network-platform</span>

<span class="na">distribution</span><span class="pi">:</span>
  <span class="na">profile</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">bfstore-production-dns</span>
    <span class="na">shared_with</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">bfstore-prod</span>
      <span class="pi">-</span> <span class="s">bfstore-observability</span>
      <span class="pi">-</span> <span class="s">bfstore-backup</span>

<span class="na">records</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">otel.prod.aws.internal.bfstore.example</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">A_ALIAS</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">observability-ingest-nlb</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">observability-platform</span>
    <span class="na">health</span><span class="pi">:</span>
      <span class="na">evaluate_target_health</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">ttl</span><span class="pi">:</span>
      <span class="na">managed_by</span><span class="pi">:</span> <span class="s">alias-target</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">orders.prod.aws.internal.bfstore.example</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">A_ALIAS</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">internal-orders-alb</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">order-service</span>
    <span class="na">health</span><span class="pi">:</span>
      <span class="na">evaluate_target_health</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">ttl</span><span class="pi">:</span>
      <span class="na">managed_by</span><span class="pi">:</span> <span class="s">alias-target</span>

<span class="na">controls</span><span class="pi">:</span>
  <span class="na">query_logging</span><span class="pi">:</span> <span class="s">central-dns-logs</span>
  <span class="na">public_dnssec_validation</span><span class="pi">:</span> <span class="s">evaluated-separately</span>
  <span class="na">record_changes</span><span class="pi">:</span> <span class="s">infrastructure-pipeline</span>
  <span class="na">resolver_view_inventory</span><span class="pi">:</span> <span class="s">required</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">public_dnssec_validation</code> is deliberately scoped.</p>

<p>VPC Resolver DNSSEC validation applies to recursively resolved signed public domains. It is not a mechanism that signs or authenticates records inside this private hosted zone. <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-dnssec-validation.html">AWS documents VPC DNSSEC validation here.</a></p>

<h2 id="test-from-the-consuming-vpc">Test from the consuming VPC</h2>

<p>A hosted zone showing the correct record in the console does not prove that a client receives it.</p>

<p>Test from the resolver view that consumes the namespace.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig otel.prod.aws.internal.bfstore.example A

dig missing.prod.aws.internal.bfstore.example A

getent hosts otel.prod.aws.internal.bfstore.example
</code></pre></div></div>

<p>Record the source VPC and Region, resolver configuration, VPC DNS attributes, direct and Profile associations, matching rules, winning namespace, effective TTL, route and application result.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME RESOLVES
      │
      ▼
ADDRESS RETURNED
      │
      ▼
ROUTE EXISTS
      │
      ▼
NETWORK POLICY PERMITS
      │
      ▼
SERVICE AUTHORISES
</code></pre></div></div>

<p>A successful answer proves only the first two steps; a failed connection does not automatically mean DNS is broken.</p>

<h2 id="common-failure-patterns">Common failure patterns</h2>

<h3 id="public-name-works-vpc-receives-nxdomain">Public name works, VPC receives <code class="language-plaintext highlighter-rouge">NXDOMAIN</code></h3>

<p>Likely cause:</p>

<ul>
  <li>a matching private zone claims the namespace;</li>
  <li>the record is absent privately;</li>
  <li>Resolver does not fall back to public DNS.</li>
</ul>

<h3 id="name-resolves-in-production-but-not-development">Name resolves in production but not development</h3>

<p>Likely cause:</p>

<ul>
  <li>the production resolver view includes the zone;</li>
  <li>the development resolver view does not.</li>
</ul>

<p>This may be intentional.</p>

<h3 id="correct-record-exists-but-is-ignored">Correct record exists but is ignored</h3>

<p>Likely causes:</p>

<ul>
  <li>a forwarding rule takes precedence;</li>
  <li>local VPC configuration overrides a Profile;</li>
  <li>a more-specific zone owns the branch;</li>
  <li>the client uses a custom resolver without the required forwarding path.</li>
</ul>

<h3 id="name-resolves-to-the-wrong-environment">Name resolves to the wrong environment</h3>

<p>Likely causes:</p>

<ul>
  <li>overly broad association or Profile;</li>
  <li>copied record points to staging;</li>
  <li>local override conflicts with the shared Profile;</li>
  <li>stale client or resolver cache.</li>
</ul>

<h3 id="name-resolves-connection-times-out">Name resolves, connection times out</h3>

<p>Likely causes:</p>

<ul>
  <li>missing route;</li>
  <li>incorrect Transit Gateway propagation;</li>
  <li>security-group or firewall denial;</li>
  <li>return-path failure;</li>
  <li>service not listening.</li>
</ul>

<p>DNS may have succeeded.</p>

<h3 id="failover-does-not-switch-as-expected">Failover does not switch as expected</h3>

<p>Likely causes:</p>

<ul>
  <li>the health source does not represent the application;</li>
  <li><code class="language-plaintext highlighter-rouge">EvaluateTargetHealth</code> is unsupported or ineffective for the target;</li>
  <li>a CloudWatch-alarm health check is misconfigured;</li>
  <li>the old answer remains cached;</li>
  <li>existing connections do not repeat DNS resolution.</li>
</ul>

<h3 id="inventory-says-the-vpc-has-no-zone-but-it-resolves">Inventory says the VPC has no zone, but it resolves</h3>

<p>Likely cause:</p>

<ul>
  <li>the zone arrived through a Route 53 Profile;</li>
  <li>the inventory queried only direct VPC associations.</li>
</ul>

<p>Diagnose namespace selection, cache state, routing and service acceptance separately.</p>

<h2 id="the-decision-model">The decision model</h2>

<p>The recurring questions are:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which namespace are we claiming?

Which resolver views receive it?

Does a local setting, Profile or rule win?

Which zone is the most-specific authority?

What durable service boundary
does the record represent?

Who may redirect the name?

How is health evaluated?

What does the client cache?

Can the returned address be reached
and used by the caller?
</code></pre></div></div>

<p>The model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                       PRIVATE NAMESPACE
                               │
                               ▼
                      PRIVATE HOSTED ZONE
                               │
                 ┌─────────────┴─────────────┐
                 │                           │
                 ▼                           ▼
              RECORDS               RESOLUTION SCOPE
                                         │
                           ┌─────────────┴─────────────┐
                           │                           │
                           ▼                           ▼
                   DIRECT ASSOCIATION             PROFILE
                           │                           │
                           └─────────────┬─────────────┘
                                         ▼
                               VPC RESOLVER VIEW
                                         │
                         ┌───────────────┼───────────────┐
                         │               │               │
                         ▼               ▼               ▼
                    LOCAL OVERRIDE   RESOLVER RULE   PRIVATE ZONE
                         │               │               │
                         └───────────────┼───────────────┘
                                         ▼
                              MOST-SPECIFIC WINNER
                                         │
                                         ▼
                                   DNS RESPONSE
                                         │
                                         ▼
                                  CACHE AND TTL
                                         │
                                         ▼
                                  NETWORK ROUTE
                                         │
                                         ▼
                               NETWORK AUTHORISATION
                                         │
                                         ▼
                              APPLICATION AUTHORISATION
</code></pre></div></div>

<p>A private hosted zone creates private meaning, not private infrastructure.</p>

<p>The zone defines authority. Direct associations and Profiles define the resolver views. Rules can redirect the question, and the most-specific namespace decides which authority wins. Alias targets may control caching, while health determines which future answers remain eligible.</p>

<p>The network carries the resulting packet. Network and application controls decide whether the request should succeed.</p>

<p>DNS lets the architecture move without requiring every application to memorise its current address.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<p>Reviewed against official AWS documentation on 31 July 2026.</p>

<ul>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-private.html">Working with private hosted zones</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html">Private hosted-zone considerations</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver.html">What is Route 53 VPC Resolver?</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/profiles.html">What are Route 53 Profiles?</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/sharing-profiles.html">Working with shared Route 53 Profiles</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/gr-what-is-global-resolver.html">Route 53 Global Resolver</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-DSN-queries-to-vpc.html">Resolver rule behaviour</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html">Choosing between alias and non-alias records</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/APIReference/API_AliasTarget.html">Alias target health</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html">Failover in private hosted zones</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-creating-values.html">Health-check address restrictions</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-geo-phz.html">Geolocation routing in private hosted zones</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-associate-vpcs-different-accounts.html">Cross-account private hosted-zone associations</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-conditions-route53.html">Route 53 IAM conditions</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-query-logs.html">VPC Resolver query logging</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListHostedZonesByVPC.html">ListHostedZonesByVPC and Profile inventory</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html">Route 53 quotas</a></li>
  <li><a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-dnssec-validation.html">VPC Resolver DNSSEC validation</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[Route 53 private hosted zones give selected resolver views a private DNS namespace. Their architecture depends on zone ownership, direct VPC associations or Route 53 Profiles, resolver-rule precedence, split-view behaviour, record design, caching, health, routing and operational evidence.]]></summary></entry><entry><title type="html">Threat modelling bfstore with STRIDE</title><link href="https://cloud.doublewords.net/writing/2026/06/24/threat-modelling-bfstore-with-stride/" rel="alternate" type="text/html" title="Threat modelling bfstore with STRIDE" /><published>2026-06-24T00:00:00+01:00</published><updated>2026-06-24T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/06/24/threat-modelling-bfstore-with-stride</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/06/24/threat-modelling-bfstore-with-stride/"><![CDATA[<p>A customer adds a pink gopher mug to their basket and checks out.</p>

<p>The order service confirms the basket, reserves inventory, creates an order and asks the payment service to begin payment.</p>

<p>Events move through Kafka.</p>

<p>Records are written to MySQL.</p>

<p>A payment provider later sends a webhook.</p>

<p>From the customer’s perspective, this is one action:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Place order
</code></pre></div></div>

<p>From a security perspective, it is a procession of identities, messages, stores and trust decisions.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CUSTOMER
   │
   ▼
PUBLIC EDGE
   │
   ▼
ENVOY GATEWAY
   │
   ▼
ORDER SERVICE
   │
   ├── basket
   ├── inventory
   ├── payment
   ├── MySQL
   └── Kafka
            │
            ▼
     PAYMENT PROVIDER
            │
            ▼
         WEBHOOK
</code></pre></div></div>

<p>Each arrow makes an assumption.</p>

<p>The edge assumes the customer’s session is authentic.</p>

<p>The order service assumes the basket belongs to that customer.</p>

<p>Kafka consumers assume an event came from an approved producer and describes a permitted business transition.</p>

<p>The payment service assumes the provider webhook is authentic, fresh and relevant to the referenced order.</p>

<p>The delivery system assumes the deployed image is the artefact that passed review.</p>

<p>Threat modelling makes those assumptions visible before an attacker, outage or hurried administrator tests them on bfstore’s behalf.</p>

<p>AWS frames threat modelling around four questions:</p>

<ol>
  <li>What are we working on?</li>
  <li>What can go wrong?</li>
  <li>What are we going to do about it?</li>
  <li>Did we do a good job?</li>
</ol>

<p>The exercise should therefore produce more than a diagram. It should produce prioritised decisions, owners, tests and evidence. <a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_threat_model.html">AWS Well-Architected threat-modelling guidance</a></p>

<blockquote>
  <p><strong>A threat model is an architecture diagram whose assumptions have been asked difficult questions.</strong></p>
</blockquote>

<p>STRIDE supplies six families of those questions.</p>

<p>It does not answer them automatically.</p>

<h2 id="start-with-the-system-not-the-acronym">Start with the system, not the acronym</h2>

<p>A useful threat model moves through these stages:</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Question</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Scope</td>
      <td>What journey or change are we analysing?</td>
      <td>Model boundary</td>
    </tr>
    <tr>
      <td>Model</td>
      <td>Which assets, actors, flows and stores participate?</td>
      <td>Data-flow diagram</td>
    </tr>
    <tr>
      <td>Boundaries</td>
      <td>Where does trust, control, identity or ownership change?</td>
      <td>Explicit assumptions</td>
    </tr>
    <tr>
      <td>STRIDE</td>
      <td>What can go wrong?</td>
      <td>Threat statements</td>
    </tr>
    <tr>
      <td>Risk</td>
      <td>What matters first?</td>
      <td>Prioritised register</td>
    </tr>
    <tr>
      <td>Controls</td>
      <td>How will risk be reduced?</td>
      <td>Prevent, detect and respond controls</td>
    </tr>
    <tr>
      <td>Verification</td>
      <td>How will we prove it?</td>
      <td>Tests and evidence</td>
    </tr>
    <tr>
      <td>Maintenance</td>
      <td>What change reopens the model?</td>
      <td>Owner and update triggers</td>
    </tr>
  </tbody>
</table>

<p>STRIDE belongs mainly inside “What can go wrong?”</p>

<p>Beginning with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What spoofing threats exist
in cloud applications?
</code></pre></div></div>

<p>usually produces generic advice.</p>

<p>The system must provide the detail.</p>

<h2 id="stride-is-a-set-of-prompts">STRIDE is a set of prompts</h2>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Property under pressure</th>
      <th>Core question</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Spoofing</strong></td>
      <td>Authentication</td>
      <td>Can something pretend to be someone or something else?</td>
    </tr>
    <tr>
      <td><strong>Tampering</strong></td>
      <td>Integrity</td>
      <td>Can data, code or configuration be changed without authorisation?</td>
    </tr>
    <tr>
      <td><strong>Repudiation</strong></td>
      <td>Accountability</td>
      <td>Can an actor deny an action without enough trustworthy evidence to investigate it?</td>
    </tr>
    <tr>
      <td><strong>Information disclosure</strong></td>
      <td>Confidentiality</td>
      <td>Can information reach someone who should not receive it?</td>
    </tr>
    <tr>
      <td><strong>Denial of service</strong></td>
      <td>Availability</td>
      <td>Can legitimate users or components be prevented from making progress?</td>
    </tr>
    <tr>
      <td><strong>Elevation of privilege</strong></td>
      <td>Authorisation</td>
      <td>Can an actor gain capabilities beyond those it was granted?</td>
    </tr>
  </tbody>
</table>

<p>Microsoft’s threat-modelling guidance uses data-flow diagrams, trust boundaries and STRIDE to identify design risks while they remain cheaper to change. <a href="https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool">Microsoft Threat Modeling Tool</a> <a href="https://learn.microsoft.com/en-us/training/modules/tm-create-a-threat-model-using-foundational-data-flow-diagram-elements/">Microsoft data-flow diagrams</a></p>

<p>The categories overlap.</p>

<p>A forged webhook may involve spoofing and tampering.</p>

<p>An unauthorised service publishing <code class="language-plaintext highlighter-rouge">OrderPaid</code> may involve spoofing, tampering or elevation of privilege.</p>

<p>Deleting audit evidence may involve tampering and repudiation.</p>

<blockquote>
  <p><strong>The category keeps the discussion moving. The actual system provides the threats.</strong></p>
</blockquote>

<h2 id="scope-the-checkout-journey">Scope the checkout journey</h2>

<p>This first model covers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Customer places and pays for an order.
</code></pre></div></div>

<p>Included:</p>

<ul>
  <li>customer session use and public ingress</li>
  <li>basket, catalog, inventory, order and payment services</li>
  <li>gRPC communication, Kafka and MySQL</li>
  <li>payment-provider calls and webhooks</li>
  <li>workload identity</li>
  <li>build and deployment paths</li>
  <li>relevant audit and telemetry paths</li>
</ul>

<p>Not analysed in detail:</p>

<ul>
  <li>the customer’s device</li>
  <li>the payment provider’s internal infrastructure</li>
  <li>AWS physical facilities</li>
  <li>unrelated administration journeys</li>
</ul>

<p>The provider’s internal systems remain outside bfstore’s control.</p>

<p>The integration boundary remains inside the model.</p>

<p>The identity provider and session-management components are abstracted from the first diagram. They need a related model covering credential issuance, account recovery, token validation, revocation, session storage and signing-key lifecycle.</p>

<h2 id="identify-the-assets">Identify the assets</h2>

<p>The checkout journey protects more than secrets.</p>

<table>
  <thead>
    <tr>
      <th>Asset group</th>
      <th>Examples</th>
      <th>Important properties</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Customer identity</td>
      <td>account, session, recovery path</td>
      <td>authenticity, confidentiality</td>
    </tr>
    <tr>
      <td>Personal data</td>
      <td>name, email, delivery address</td>
      <td>confidentiality, justified use</td>
    </tr>
    <tr>
      <td>Commercial state</td>
      <td>price, discount, basket, inventory, order total</td>
      <td>integrity</td>
    </tr>
    <tr>
      <td>Payment authority</td>
      <td>provider tokens, payment references, refund capability</td>
      <td>integrity, restricted access</td>
    </tr>
    <tr>
      <td>Service identity</td>
      <td>service accounts, IAM roles, mTLS identities, Kafka credentials</td>
      <td>authenticity, authorisation</td>
    </tr>
    <tr>
      <td>Supply chain</td>
      <td>source, dependencies, images, deployment plans</td>
      <td>integrity, provenance</td>
    </tr>
    <tr>
      <td>Availability</td>
      <td>checkout, reservation, payment and fulfilment</td>
      <td>timely completion</td>
    </tr>
    <tr>
      <td>Evidence</td>
      <td>audit events, logs, traces, CloudTrail and deployment records</td>
      <td>integrity, retention</td>
    </tr>
  </tbody>
</table>

<p>The product price is intentionally public.</p>

<p>Changing it from £84 to 84 pence without permission is still a security event.</p>

<h2 id="draw-flows-and-explain-each-trust-boundary">Draw flows and explain each trust boundary</h2>

<p>A trust boundary is not simply another hop. It marks a meaningful change in control, identity, ownership or privilege. <a href="https://learn.microsoft.com/en-us/training/modules/tm-create-a-threat-model-using-foundational-data-flow-diagram-elements/">Microsoft data-flow diagrams</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         INTERNET
                            │
                   TRUST BOUNDARY A
                            │
                            ▼
                    ALB / PUBLIC EDGE
                            │
                   TRUST BOUNDARY B
                            │
                            ▼
                       ENVOY GATEWAY
                            │
                   TRUST BOUNDARY C
                            │
          ┌─────────────────┼─────────────────┐
          │                 │                 │
          ▼                 ▼                 ▼
       BASKET             ORDER            CATALOG
                           │
                    ┌──────┼──────┐
                    ▼      ▼      ▼
                INVENTORY MYSQL  KAFKA
                           │
                           ▼
                        PAYMENT
                           │
                  TRUST BOUNDARY D
                           │
                           ▼
                  PAYMENT PROVIDER
                           │
                           ▼
                    SIGNED WEBHOOK
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Boundary</th>
      <th>What changes</th>
      <th>Required decision</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>A: internet to edge</strong></td>
      <td>Uncontrolled callers enter bfstore infrastructure</td>
      <td>Is the connection acceptable, bounded and associated with a valid journey?</td>
    </tr>
    <tr>
      <td><strong>B: edge to gateway</strong></td>
      <td>AWS-managed edge processing hands traffic to bfstore workloads</td>
      <td>Which forwarded identity and network metadata are trusted?</td>
    </tr>
    <tr>
      <td><strong>C: gateway to service</strong></td>
      <td>An external identity becomes an internal operation</td>
      <td>Does the receiving service authorise this action on this resource?</td>
    </tr>
    <tr>
      <td><strong>Service and store boundaries</strong></td>
      <td>Workload identity, ownership and business authority change</td>
      <td>May this caller perform this operation or data access?</td>
    </tr>
    <tr>
      <td><strong>Kafka boundary</strong></td>
      <td>A message becomes durable and asynchronously reusable</td>
      <td>Which broker identity may publish, and which transitions may consumers accept?</td>
    </tr>
    <tr>
      <td><strong>D: provider integration</strong></td>
      <td>Data leaves bfstore and externally initiated events return</td>
      <td>Is the event authentic, fresh, relevant and idempotent?</td>
    </tr>
  </tbody>
</table>

<p>The services should not trust one another merely because they run in the same cluster.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NETWORK POLICY

May traffic travel along this path?


WORKLOAD IDENTITY

Which workload is calling?


SERVICE AUTHORISATION

May that identity perform
this operation on this resource?
</code></pre></div></div>

<p>Kubernetes <code class="language-plaintext highlighter-rouge">NetworkPolicy</code> controls supported Layer 3 and Layer 4 paths and requires an enforcing network implementation. It is not workload identity or operation-level authorisation. <a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">Kubernetes NetworkPolicy</a></p>

<p>A second diagram covers delivery:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ENGINEER
   │
   ▼
GITHUB
   │
   ▼
GITHUB ACTIONS
   │
   ├── build and test
   ├── produce image
   ├── attest artefact
   └── request deployment
            │
            ▼
       AWS IAM ROLE
            │
            ▼
   TERRAKUBE / KUBERNETES
            │
            ▼
       BFSTORE RUNTIME
</code></pre></div></div>

<p>A model that covers only customer traffic may defend an application that the pipeline can replace with arbitrary code.</p>

<h2 id="spoofing-who-are-you-really">Spoofing: who are you really?</h2>

<h3 id="customer-session-spoofing">Customer session spoofing</h3>

<p>An attacker steals or reuses a customer session and places an order as that customer.</p>

<p>Conditions may include:</p>

<ul>
  <li>tokens in logs</li>
  <li>weak account recovery</li>
  <li>long-lived access</li>
  <li>missing issuer or audience validation</li>
  <li>no revocation after credential changes</li>
</ul>

<p>Controls may include short-lived access, secure session storage, issuer and audience checks, reauthentication for sensitive operations, revocation and anomaly detection.</p>

<h3 id="workload-spoofing">Workload spoofing</h3>

<p>A compromised Pod calls the order service while claiming to be the payment service.</p>

<p>A private source IP is insufficient identity.</p>

<p>Controls include distinct Kubernetes service accounts, short-lived workload credentials, explicit service authorisation, network segmentation and producer-specific Kafka ACLs.</p>

<p>EKS can provide scoped temporary AWS credentials through IAM roles for service accounts or EKS Pod Identity. The resulting role still needs narrow permissions, and the node credential path still needs protection. <a href="https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html">EKS IRSA</a> <a href="https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html">EKS Pod Identity</a></p>

<h3 id="payment-webhook-spoofing-and-replay">Payment webhook spoofing and replay</h3>

<p>Plausible JSON is not proof that the payment provider sent an event.</p>

<p>The payment service should verify:</p>

<ul>
  <li>the signature over the exact received payload, using the provider’s supported library or canonicalisation rules</li>
  <li>signed timestamp or equivalent freshness evidence</li>
  <li>unique event identifier and replay window</li>
  <li>event schema and type</li>
  <li>relationship between provider payment reference and bfstore order</li>
  <li>whether the current state permits the transition</li>
</ul>

<p>A valid signature proves less than many implementations assume. It does not prove that the event is fresh, previously unprocessed, relevant to the named order or permitted to change its state.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PENDING_PAYMENT
       │
       ▼
PAYMENT_AUTHORISED
       │
       ▼
READY_FOR_FULFILMENT
</code></pre></div></div>

<p>Only permitted, idempotent transitions should succeed.</p>

<h2 id="tampering-what-changed">Tampering: what changed?</h2>

<h3 id="request-tampering">Request tampering</h3>

<p>A customer changes:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"product_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"product-42"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"quantity"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"unit_price_pence"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The order service must retrieve or validate authoritative product, discount and pricing data.</p>

<p>The customer may request:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Buy one product-42.
</code></pre></div></div>

<p>They may not declare:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Product-42 costs one penny.
</code></pre></div></div>

<h3 id="event-tampering">Event tampering</h3>

<p>A malicious or compromised service publishes <code class="language-plaintext highlighter-rouge">OrderPaid</code> for an order whose payment failed.</p>

<p>Controls include broker-authenticated producer identities, topic authorisation, schema validation, immutable event IDs, authoritative state checks, idempotent consumers and provider reconciliation.</p>

<p>An envelope field such as:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"producer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"payment-service"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>is only a claim unless tied to broker-authenticated identity or another trusted provenance mechanism.</p>

<p>TLS protects transit.</p>

<p>It does not prove that an authorised producer generated a truthful business event.</p>

<h3 id="database-tampering">Database tampering</h3>

<p>Prepared statements protect SQL structure from injection.</p>

<p>They do not stop an over-privileged runtime identity from performing operations it was granted.</p>

<p>Controls include service-owned schemas, separate migrator and runtime accounts, least-privilege grants, database constraints and reconciliation among order, payment and inventory state.</p>

<h3 id="supply-chain-tampering">Supply-chain tampering</h3>

<p>The delivery path must answer two questions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKFLOW IDENTITY

Which repository, environment
and workflow may request deployment?


ARTEFACT IDENTITY

Which exact digest was reviewed,
built, attested and approved?
</code></pre></div></div>

<p>GitHub Actions can use OIDC to obtain short-lived AWS credentials. The AWS trust policy should constrain audience and subject claims, including the expected repository and protected environment or branch. GitHub recommends environment protection rules where environments are used. <a href="https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws">GitHub OIDC for AWS</a></p>

<p>OIDC trust does not prove that the correct artefact is deployed.</p>

<p>Use immutable image digests and verify build provenance or attestations before deployment. GitHub artefact attestations can bind a build to its repository, workflow, commit and digest, but only verification gives the attestation operational value. <a href="https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds">GitHub artefact attestations</a></p>

<blockquote>
  <p>The dangerous request may not enter through the ALB. It may arrive as a valid deployment issued by an unexpectedly powerful workflow.</p>
</blockquote>

<h2 id="repudiation-prove-enough-to-investigate">Repudiation: prove enough to investigate</h2>

<p>Repudiation appears when an actor denies an action and the system lacks enough trustworthy evidence to reconstruct it.</p>

<p>A useful application audit event records:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">event</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">order.refund_requested</span>
  <span class="na">event_id</span><span class="pi">:</span> <span class="s">audit-91c4</span>
  <span class="na">occurred_at</span><span class="pi">:</span> <span class="s">2026-06-24T11:14:32Z</span>

<span class="na">actor</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">customer</span>
  <span class="na">subject_id</span><span class="pi">:</span> <span class="s">customer-421</span>
  <span class="na">session_reference</span><span class="pi">:</span> <span class="s">session-72a1</span>

<span class="na">execution</span><span class="pi">:</span>
  <span class="na">workload_identity</span><span class="pi">:</span> <span class="s">order-service</span>
  <span class="na">policy_decision</span><span class="pi">:</span> <span class="s">refund-owner-check</span>

<span class="na">resource</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">order</span>
  <span class="na">id</span><span class="pi">:</span> <span class="s">order-8042</span>

<span class="na">outcome</span><span class="pi">:</span>
  <span class="na">decision</span><span class="pi">:</span> <span class="s">accepted</span>
  <span class="na">amount_pence</span><span class="pi">:</span> <span class="m">4200</span>

<span class="na">correlation</span><span class="pi">:</span>
  <span class="na">trace_id</span><span class="pi">:</span> <span class="s">6d2c4e1f</span>
</code></pre></div></div>

<p>An audit event may contain an opaque session reference.</p>

<p>It must not contain a bearer token, refresh token or another reusable credential.</p>

<p>CloudTrail can provide AWS control-plane and configured data-event evidence. Its usefulness depends on enabled event sources, destinations, retention and protection against alteration. Management events are generally enabled for trails, while data events require explicit configuration. <a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-user-guide.html">CloudTrail overview</a> <a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-management-events-with-cloudtrail.html">CloudTrail management events</a> <a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html">CloudTrail data events</a></p>

<p>bfstore must still create application-level evidence for business actions.</p>

<p>Logs should be centralised with narrow write paths, restricted deletion, access auditing, retention and integrity controls.</p>

<p>They provide evidence whose strength depends on the complete logging path.</p>

<h2 id="information-disclosure-where-did-the-data-travel">Information disclosure: where did the data travel?</h2>

<p>Sensitive data can escape through error responses, telemetry, Kafka, support exports, environment variables, broad queries or compromised workload identities.</p>

<h3 id="telemetry">Telemetry</h3>

<p>A payment failure should not log:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>customer=mariam@example.com
address=42 Example Road
token=pm_secret_value
</code></pre></div></div>

<p>Use structured logging, field allow-lists, token redaction, no raw request bodies by default, purpose-based retention and tests for sensitive-field leakage.</p>

<p>Customer IDs, emails and order IDs should not become metric labels merely because labels are convenient.</p>

<h3 id="events">Events</h3>

<p>An <code class="language-plaintext highlighter-rouge">OrderCreated</code> event should contain what its consumers need, not a travelling copy of the customer profile.</p>

<div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">message</span> <span class="nc">OrderCreated</span> <span class="p">{</span>
  <span class="kt">string</span> <span class="na">event_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
  <span class="kt">string</span> <span class="na">order_id</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
  <span class="kt">string</span> <span class="na">customer_id</span> <span class="o">=</span> <span class="mi">3</span><span class="p">;</span>
  <span class="kt">int64</span> <span class="na">total_pence</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A shipping workflow may need a delivery address through a narrow path.</p>

<p>A recommendation consumer probably does not.</p>

<p>Event schemas are disclosure boundaries.</p>

<h3 id="secrets-and-encryption">Secrets and encryption</h3>

<p>Kubernetes recommends encryption at rest, least-privilege access and limiting Secret visibility to the containers that need it. Base64 is not protection. <a href="https://kubernetes.io/docs/concepts/configuration/secret/">Kubernetes Secrets</a></p>

<p>AWS KMS encryption context can bind non-secret context to ciphertext and support policy conditions and auditability. It appears in plaintext, so it must not contain customer or secret values. <a href="https://docs.aws.amazon.com/kms/latest/developerguide/encrypt_context.html">AWS KMS encryption context</a></p>

<p>Encryption reduces the value of unauthorised storage access.</p>

<p>It does not excuse over-collection or broad decryption permission.</p>

<h2 id="denial-of-service-can-legitimate-work-finish">Denial of service: can legitimate work finish?</h2>

<p>STRIDE draws attention to loss of availability.</p>

<p>The cause may be hostile, accidental or external. The threat record should state which cause it analyses because security and resilience controls may differ.</p>

<h3 id="abuse-and-cost-amplification">Abuse and cost amplification</h3>

<p>Threats include request floods, expensive searches, oversized bodies, baskets with thousands of items and repeated checkout calls.</p>

<p>Controls include rate and connection limits, request-size limits, bounded input collections, caching and inexpensive rejection before expensive work.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VALID TYPE

does not automatically mean

SAFE AMOUNT OF WORK
</code></pre></div></div>

<h3 id="retry-storms">Retry storms</h3>

<p>A slow provider can be multiplied by retries from callers, consumers and redelivery.</p>

<p>Use deadlines, bounded retries, backoff and jitter, concurrency limits, circuit breakers, idempotency, dead-letter handling and load shedding.</p>

<h3 id="platform-exhaustion">Platform exhaustion</h3>

<p>Kafka may suffer oversized messages, hot partitions, lag or poison-message loops.</p>

<p>A compromised workload may consume shared CPU, memory or Pod capacity.</p>

<p>Use topic limits, ACLs, lag monitoring, resource requests and limits, <code class="language-plaintext highlighter-rouge">LimitRange</code>, <code class="language-plaintext highlighter-rouge">ResourceQuota</code> and controlled failure testing.</p>

<h3 id="external-dependency-failure">External dependency failure</h3>

<p>The storefront may be healthy while the payment provider is unavailable.</p>

<p>That may result from ordinary failure, overload, network partition or attack.</p>

<p>The model should name the assumed cause and consider bounded queueing, explicit delayed states, reconciliation, alerting and provider failover where realistic.</p>

<p>Availability is end to end.</p>

<p>A green Pod is not proof that a customer can place an order.</p>

<h2 id="elevation-of-privilege-what-can-this-identity-become">Elevation of privilege: what can this identity become?</h2>

<h3 id="customer-to-administrator">Customer to administrator</h3>

<p>An ordinary customer manipulates an endpoint or claim and invokes an administration operation.</p>

<p>The control must be server-side authorisation tied to trusted identity.</p>

<p>Hiding the button is interface design, not access control.</p>

<h3 id="service-to-broader-authority">Service to broader authority</h3>

<p>The notification service may send messages.</p>

<p>It does not need to refund payments, modify inventory, decrypt customer profiles or publish <code class="language-plaintext highlighter-rouge">OrderPaid</code>.</p>

<p>Workload roles, database accounts and Kafka ACLs should match narrow service functions.</p>

<h3 id="pod-to-node-or-cluster-authority">Pod to node or cluster authority</h3>

<p>A compromised Pod may exploit an over-privileged service account, mounted credentials, host access, Linux capabilities or node credentials.</p>

<p>Kubernetes defines <code class="language-plaintext highlighter-rouge">Privileged</code>, <code class="language-plaintext highlighter-rouge">Baseline</code> and <code class="language-plaintext highlighter-rouge">Restricted</code> Pod Security Standards. Pod Security Admission can enforce, audit or warn against a version-pinned profile at namespace level. <a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">Pod Security Standards</a> <a href="https://kubernetes.io/docs/concepts/security/pod-security-admission/">Pod Security Admission</a></p>

<p>bfstore application namespaces should target a version-pinned restricted posture unless a reviewed exception is required.</p>

<p>Additional bfstore hardening, such as read-only filesystems, narrow token use and workload-specific egress, should be distinguished from the controls defined by the standard itself.</p>

<p>Kubernetes RBAC must also account for indirect escalation through access to Secrets, workload creation, role bindings and certificate-signing operations. <a href="https://kubernetes.io/docs/concepts/security/rbac-good-practices/">Kubernetes RBAC good practices</a></p>

<p>A deployment controller may need to update Deployments and read rollout status.</p>

<p>It probably does not need to read every Secret or create <code class="language-plaintext highlighter-rouge">ClusterRoleBinding</code> objects.</p>

<h3 id="cicd-to-production-authority">CI/CD to production authority</h3>

<p>The GitHub OIDC trust relationship should restrict which workflow context can assume the production role.</p>

<p>The role itself should then permit the smallest useful deployment operation.</p>

<p>Deployment automation does not need to become an unrestricted production administrator.</p>

<h2 id="write-precise-threat-statements">Write precise threat statements</h2>

<p>A useful threat statement follows this structure:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>An actor

can perform an action

through a particular path

because a condition exists

causing a business or technical impact.
</code></pre></div></div>

<p>For example:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>An attacker can replay a previously valid
payment webhook through the public endpoint
because bfstore validates the signature but
not the event identifier or timestamp, causing
duplicate state transitions and incorrect fulfilment.
</code></pre></div></div>

<p>This is more reviewable than:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Threat: webhook spoofing
Mitigation: encryption
</code></pre></div></div>

<h2 id="score-inherent-and-residual-risk">Score inherent and residual risk</h2>

<p>Priority labels need defined meaning.</p>

<table>
  <thead>
    <tr>
      <th>Dimension</th>
      <th>Questions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Likelihood</strong></td>
      <td>How exposed is the path? Which precondition is required? How difficult is repeatable exploitation?</td>
    </tr>
    <tr>
      <td><strong>Impact</strong></td>
      <td>Could this cause financial loss, unauthorised fulfilment, customer harm, material disclosure or prolonged outage?</td>
    </tr>
    <tr>
      <td><strong>Current controls</strong></td>
      <td>Which implemented and verified controls already reduce likelihood or impact?</td>
    </tr>
    <tr>
      <td><strong>Residual risk</strong></td>
      <td>What remains after those controls are considered?</td>
    </tr>
  </tbody>
</table>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CRITICAL

Mitigate, redesign or formally
accept before release.


HIGH

Assign an owner, delivery date
and tracked verification.


MEDIUM

Track and review against
planned architecture changes.


LOW

Document and reconsider when
assumptions or exposure change.
</code></pre></div></div>

<p>A threat record should separate untreated risk from what remains after current controls.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">threat</span><span class="pi">:</span>
  <span class="na">id</span><span class="pi">:</span> <span class="s">BF-THREAT-014</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Replay of payment-success webhook</span>
  <span class="na">stride</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">spoofing</span>
    <span class="pi">-</span> <span class="s">tampering</span>

<span class="na">scenario</span><span class="pi">:</span>
  <span class="na">actor</span><span class="pi">:</span> <span class="s">external-attacker</span>
  <span class="na">entry_point</span><span class="pi">:</span> <span class="s">payment-webhook</span>
  <span class="na">asset</span><span class="pi">:</span> <span class="s">order-payment-state</span>
  <span class="na">precondition</span><span class="pi">:</span> <span class="s">valid signed webhook captured</span>

<span class="na">risk</span><span class="pi">:</span>
  <span class="na">inherent</span><span class="pi">:</span>
    <span class="na">likelihood</span><span class="pi">:</span> <span class="s">high</span>
    <span class="na">impact</span><span class="pi">:</span> <span class="s">high</span>
    <span class="na">priority</span><span class="pi">:</span> <span class="s">critical</span>

<span class="na">current_controls</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">provider signature verification</span>

<span class="na">planned_controls</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">verify signed timestamp</span>
  <span class="pi">-</span> <span class="s">reject events outside replay window</span>
  <span class="pi">-</span> <span class="s">enforce unique provider event ID</span>
  <span class="pi">-</span> <span class="s">match provider payment reference</span>
  <span class="pi">-</span> <span class="s">enforce idempotent state transition</span>

<span class="na">residual_risk</span><span class="pi">:</span>
  <span class="na">likelihood</span><span class="pi">:</span> <span class="s">low</span>
  <span class="na">impact</span><span class="pi">:</span> <span class="s">high</span>
  <span class="na">priority</span><span class="pi">:</span> <span class="s">medium</span>

<span class="na">verification</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">replay the same signed event</span>
  <span class="pi">-</span> <span class="s">replay an expired event</span>
  <span class="pi">-</span> <span class="s">submit a valid event for the wrong order</span>
  <span class="pi">-</span> <span class="s">attempt an invalid state transition</span>
</code></pre></div></div>

<p>A planned control should not lower residual risk until it exists and has evidence.</p>

<h2 id="a-first-threat-register">A first threat register</h2>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>STRIDE</th>
      <th>Threat</th>
      <th>Inherent priority</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>BF-01</td>
      <td>Spoofing</td>
      <td>Stolen customer session used to place an order</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-02</td>
      <td>Spoofing / Tampering</td>
      <td>Forged or replayed webhook changes order state</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-03</td>
      <td>Tampering</td>
      <td>Client-supplied price accepted at checkout</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-04</td>
      <td>Spoofing / Tampering</td>
      <td>Unauthorised producer publishes <code class="language-plaintext highlighter-rouge">OrderPaid</code></td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-05</td>
      <td>Repudiation</td>
      <td>Refund or price change lacks reliable actor evidence</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-06</td>
      <td>Disclosure</td>
      <td>Personal or payment data enters telemetry</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-07</td>
      <td>Disclosure / Elevation</td>
      <td>Workload reads another service’s secrets or data</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-08</td>
      <td>Denial of service</td>
      <td>Retry storm exhausts checkout capacity</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-09</td>
      <td>Denial of service</td>
      <td>Oversized input creates disproportionate work</td>
      <td>Medium</td>
    </tr>
    <tr>
      <td>BF-10</td>
      <td>Elevation</td>
      <td>Compromised Pod obtains node or broad AWS credentials</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-11</td>
      <td>Elevation</td>
      <td>Unapproved workflow assumes production role</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-12</td>
      <td>Tampering</td>
      <td>Unverified container digest reaches production</td>
      <td>Critical</td>
    </tr>
    <tr>
      <td>BF-13</td>
      <td>Disclosure</td>
      <td>Kafka event contains unnecessary customer data</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-14</td>
      <td>Denial of service</td>
      <td>Kafka exhaustion prevents order progression</td>
      <td>High</td>
    </tr>
    <tr>
      <td>BF-15</td>
      <td>Tampering / Repudiation</td>
      <td>Workload account can alter audit evidence</td>
      <td>High</td>
    </tr>
  </tbody>
</table>

<p>Each record still needs an owner, assumptions, current controls, residual risk, verification evidence and review trigger.</p>

<p>A <code class="language-plaintext highlighter-rouge">Critical</code> row should cause one of four outcomes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MITIGATE

REDESIGN

TRANSFER

FORMALLY ACCEPT
</code></pre></div></div>

<p>“Discussed during threat modelling” is not a fifth treatment.</p>

<h2 id="prevent-detect-respond-and-verify">Prevent, detect, respond and verify</h2>

<p>One threat usually needs several controls.</p>

<p>For replayed payment events:</p>

<table>
  <thead>
    <tr>
      <th>Control class</th>
      <th>Examples</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Prevent</strong></td>
      <td>exact-payload signature verification, freshness checks, unique IDs, payment-reference matching, idempotent transitions</td>
    </tr>
    <tr>
      <td><strong>Detect</strong></td>
      <td>duplicate-event metrics, reconciliation, impossible-transition alerts, complete audit events</td>
    </tr>
    <tr>
      <td><strong>Respond</strong></td>
      <td>suspend fulfilment, quarantine affected orders, query provider state, preserve evidence</td>
    </tr>
  </tbody>
</table>

<p>AWS recommends adapting controls to prevent, detect and respond rather than relying on one layer. <a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_threat_model.html">AWS Well-Architected threat-modelling guidance</a></p>

<p>Every mitigation should produce a test or evidence requirement.</p>

<p>Examples include:</p>

<ul>
  <li>use expired and wrong-audience customer tokens</li>
  <li>call a service using another service’s identity</li>
  <li>replay a correctly signed webhook</li>
  <li>alter a client-supplied price</li>
  <li>publish from an unauthorised Kafka principal</li>
  <li>deploy a mutable tag or unattested digest</li>
  <li>reconstruct a refund from application and infrastructure evidence</li>
  <li>scan telemetry for seeded personal data</li>
  <li>attempt a privileged Pod deployment</li>
  <li>introduce dependency latency and observe retries</li>
  <li>verify an unapproved branch cannot assume the production role</li>
</ul>

<p>A model without verification can become a thoughtful list of controls that everybody assumes somebody else implemented.</p>

<h2 id="keep-the-model-attached-to-delivery">Keep the model attached to delivery</h2>

<p>A pull request introducing a new flow should answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What new asset exists?

Which actor can reach it?

Which boundary changed?

Which data crosses it?

What can go wrong?

Which controls and tests accompany it?
</code></pre></div></div>

<p>Reopen the model for changes such as:</p>

<ul>
  <li>new public endpoints or Kafka topics</li>
  <li>new providers or customer-data fields</li>
  <li>new account connections or workload identities</li>
  <li>new controllers or CI/CD permissions</li>
  <li>changes to authentication, payments, refunds, exports or backups</li>
  <li>incidents that expose a false assumption</li>
</ul>

<p>A stored model might contain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docs/security/
├── checkout-threat-model.md
├── checkout-data-flow.txt
├── threat-register.yaml
├── assumptions.md
└── verification/
    ├── webhook-replay.md
    ├── workload-identity.md
    └── audit-evidence.md
</code></pre></div></div>

<p>The goal is to keep the reasoning attached to the system as it changes.</p>

<h2 id="the-mental-model">The mental model</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         BFSTORE JOURNEY
                                │
                                ▼
                      SCOPE, ASSETS, ACTORS
                                │
                                ▼
                   FLOWS, STORES AND BOUNDARIES
                                │
                                ▼
                              STRIDE
                                │
                                ▼
                         THREAT STATEMENTS
                                │
                                ▼
                 INHERENT RISK AND CURRENT CONTROLS
                                │
                                ▼
                 PREVENT, DETECT, RESPOND, VERIFY
                                │
                                ▼
                          RESIDUAL RISK
                                │
                                ▼
                     OWNER, EVIDENCE, REVIEW
                                │
                                └──────────► MODEL UPDATE
</code></pre></div></div>

<p>The recurring questions are:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What journey or change are we modelling?

Which assets and actors participate?

Where does trust, control,
identity or ownership change?

What can go wrong at each boundary?

Which controls prevent,
detect and contain it?

What evidence proves
those controls work?
</code></pre></div></div>

<p>A trust boundary is where bfstore stops accepting one promise on appearance and requires evidence before making the next decision.</p>

<p>That evidence may be a validated token, workload identity, authorised network path, fresh signed webhook, broker-authenticated principal, database constraint, immutable image digest, verified build provenance, narrow IAM role, protected audit record or tested rate limit.</p>

<p>Threat modelling does not make the system invulnerable.</p>

<p>It makes the security reasoning visible enough to challenge, implement, test and improve.</p>

<p>The attacker should not be the first person to notice where bfstore trusted the wrong thing.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<p>Reviewed against official documentation on 31 July 2026.</p>

<ul>
  <li><a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_threat_model.html">AWS Well-Architected: Identify threats and prioritise mitigations using a threat model</a></li>
  <li><a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/welcome.html">AWS Well-Architected Security Pillar</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool">Microsoft Threat Modeling Tool overview</a></li>
  <li><a href="https://learn.microsoft.com/en-us/training/modules/tm-create-a-threat-model-using-foundational-data-flow-diagram-elements/">Microsoft data-flow diagram guidance</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">Kubernetes NetworkPolicy</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">Kubernetes Pod Security Standards</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/security/pod-security-admission/">Kubernetes Pod Security Admission</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/security/rbac-good-practices/">Kubernetes RBAC good practices</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/configuration/secret/">Kubernetes Secrets</a></li>
  <li><a href="https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html">Amazon EKS IAM roles for service accounts</a></li>
  <li><a href="https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html">Amazon EKS Pod Identity</a></li>
  <li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-user-guide.html">AWS CloudTrail overview</a></li>
  <li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-management-events-with-cloudtrail.html">AWS CloudTrail management events</a></li>
  <li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html">AWS CloudTrail data events</a></li>
  <li><a href="https://docs.aws.amazon.com/kms/latest/developerguide/encrypt_context.html">AWS KMS encryption context</a></li>
  <li><a href="https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws">GitHub Actions: Configuring OIDC in AWS</a></li>
  <li><a href="https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds">GitHub Actions artefact attestations</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[A practical STRIDE threat model for bfstore’s checkout and delivery paths. The exercise begins with scope, assets, actors, data flows and explicit trust boundaries before turning threats into prioritised, testable security work.]]></summary></entry><entry><title type="html">Sizing Go services on Kubernetes: requests, limits and quotas</title><link href="https://cloud.doublewords.net/writing/2026/06/23/kubernetes-pod-resource-quotas-for-go-services/" rel="alternate" type="text/html" title="Sizing Go services on Kubernetes: requests, limits and quotas" /><published>2026-06-23T00:00:00+01:00</published><updated>2026-06-23T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/06/23/kubernetes-pod-resource-quotas-for-go-services</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/06/23/kubernetes-pod-resource-quotas-for-go-services/"><![CDATA[<p>The basket service starts quickly, responds correctly and appears small on my laptop.</p>

<p>I place it in Kubernetes with no resource configuration:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">basket-service</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">basket-service</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">basket-service</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">basket-service</span>
          <span class="na">image</span><span class="pi">:</span> <span class="s">registry.example/basket-service:v1</span>
</code></pre></div></div>

<p>The manifest is valid.</p>

<p>It is also silent about several important questions:</p>

<ul>
  <li>How much CPU and memory should scheduling plan for?</li>
  <li>How far may the process burst?</li>
  <li>What happens during throttling or a memory spike?</li>
  <li>How many replicas can the namespace afford?</li>
  <li>What should the autoscaler interpret as high utilisation?</li>
  <li>Do the Go runtime’s CPU and memory controls match the container?</li>
</ul>

<p>Kubernetes will run the container.</p>

<p>It cannot infer the service’s operating contract from the language used to build the binary.</p>

<blockquote>
  <p><strong>Resource configuration is the point where application behaviour becomes a scheduling, reliability and cost agreement with the platform.</strong></p>
</blockquote>

<p>Throughout this article, <strong>bfstore</strong> is a hypothetical ecommerce platform. The examples use ordinary container-level resources and assume Go 1.25 or later. They are design examples, not universal production values.</p>

<h2 id="the-control-map">The control map</h2>

<p>Kubernetes resource controls answer different questions.</p>

<table>
  <thead>
    <tr>
      <th>Control</th>
      <th>Primary question</th>
      <th>Common failure</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CPU request</td>
      <td>How much CPU should scheduling plan for?</td>
      <td>Dense packing, contention or wasted capacity</td>
    </tr>
    <tr>
      <td>Memory request</td>
      <td>How much memory should placement account for?</td>
      <td>Node pressure and poor eviction behaviour</td>
    </tr>
    <tr>
      <td>CPU limit</td>
      <td>How much CPU throughput may the container consume?</td>
      <td>Throttling and latency</td>
    </tr>
    <tr>
      <td>Memory limit</td>
      <td>Where is the hard memory boundary?</td>
      <td>OOM termination</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GOMAXPROCS</code></td>
      <td>How much parallel Go execution should run?</td>
      <td>Excess parallelism or underuse</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code></td>
      <td>When should Go increase garbage-collection effort?</td>
      <td>GC pressure or cgroup OOM</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LimitRange</code></td>
      <td>Which individual workload shapes are admitted?</td>
      <td>Rejection or misleading defaults</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ResourceQuota</code></td>
      <td>How much may the namespace claim?</td>
      <td>Blocked scaling or rollouts</td>
    </tr>
  </tbody>
</table>

<p>These controls cooperate.</p>

<p>They are not interchangeable.</p>

<h2 id="requests-limits-limitranges-and-resourcequotas">Requests, limits, LimitRanges and ResourceQuotas</h2>

<h3 id="resource-requests">Resource requests</h3>

<p>A request is a capacity claim used for scheduling:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resources</span><span class="pi">:</span>
  <span class="na">requests</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
</code></pre></div></div>

<p>For ordinary container-level resources, the scheduler accounts for container requests plus the documented init-container and Pod-overhead rules. A Pod is placed only when those requests fit within a node’s allocatable capacity. <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/">Kubernetes resource management</a></p>

<h3 id="resource-limits">Resource limits</h3>

<p>A limit is a runtime boundary enforced through operating-system resource controls:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resources</span><span class="pi">:</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
</code></pre></div></div>

<p>CPU and memory limits have different failure semantics.</p>

<h3 id="limitrange">LimitRange</h3>

<p>A <code class="language-plaintext highlighter-rouge">LimitRange</code> applies admission policy to individual Pods or containers in one namespace. It can define defaults, minimums, maximums and request-to-limit ratios. Changes do not rewrite existing Pods. <a href="https://kubernetes.io/docs/concepts/policy/limit-range/">Kubernetes LimitRanges</a></p>

<h3 id="resourcequota">ResourceQuota</h3>

<p>A <code class="language-plaintext highlighter-rouge">ResourceQuota</code> limits aggregate namespace claims such as total CPU and memory requests or limits, Pod counts and persistent storage. It is admission control, not a namespace CPU throttle, shared memory cgroup or capacity reservation. Namespace quotas can exceed cluster capacity, leaving accepted workloads to contend for placement. <a href="https://kubernetes.io/docs/concepts/policy/resource-quotas/">Kubernetes ResourceQuotas</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTAINER
request and limit
       │
       ▼
LIMIT RANGE
is this shape allowed?
       │
       ▼
RESOURCE QUOTA
is namespace budget available?
       │
       ▼
SCHEDULER
can an eligible node place it?
</code></pre></div></div>

<p>These controls are complementary. A container limit does not define namespace scale, a quota does not rightsize one container, and a default does not prove suitability.</p>

<h2 id="requests-are-capacity-claims-not-private-hardware">Requests are capacity claims, not private hardware</h2>

<p>Suppose a node has:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ALLOCATABLE CPU

4 CPU
</code></pre></div></div>

<p>It already hosts workloads requesting:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3.6 CPU
</code></pre></div></div>

<p>A new Pod requests:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>500m CPU
</code></pre></div></div>

<p>The scheduler sees:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3.6 + 0.5 = 4.1 CPU
</code></pre></div></div>

<p>The Pod does not fit.</p>

<p>This remains true when existing services are idle. The scheduler is protecting the capacity claims represented by their requests.</p>

<p>Requests have different runtime implications for CPU and memory:</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Scheduling role</th>
      <th>Runtime effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CPU request</td>
      <td>Accounts for planned CPU capacity</td>
      <td>Influences relative CPU weight under contention</td>
    </tr>
    <tr>
      <td>Memory request</td>
      <td>Accounts for planned memory capacity</td>
      <td>Influences eviction assessment; it is not a private runtime reservation</td>
    </tr>
    <tr>
      <td>CPU limit</td>
      <td>Not the normal-demand estimate</td>
      <td>Enforces a throughput ceiling through throttling</td>
    </tr>
    <tr>
      <td>Memory limit</td>
      <td>Not the normal-demand estimate</td>
      <td>Defines a cgroup boundary that can lead to OOM termination</td>
    </tr>
  </tbody>
</table>

<p>If requests are consistently too high:</p>

<ul>
  <li>nodes appear full while resources remain idle;</li>
  <li>node autoscaling may add unnecessary capacity;</li>
  <li>deployments remain unplaced;</li>
  <li>namespace quotas are consumed artificially;</li>
  <li>cost rises.</li>
</ul>

<p>If requests are consistently too low:</p>

<ul>
  <li>too many Pods can be packed onto a node;</li>
  <li>CPU contention and memory pressure become more likely;</li>
  <li>eviction exposure increases for workloads using far more than requested;</li>
  <li>percentage-based autoscaling signals become misleading.</li>
</ul>

<p>Requests should describe meaningful ordinary demand, not the smallest values that permit admission.</p>

<h2 id="cpu-and-memory-fail-differently">CPU and memory fail differently</h2>

<h3 id="cpu-is-compressible">CPU is compressible</h3>

<p>A process can receive less CPU time and keep running more slowly.</p>

<p>When a container tries to consume more CPU than its limit permits, the kernel throttles its cgroup. The process is not normally killed merely for demanding additional CPU. <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/">Kubernetes resource management</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CPU DEMAND ABOVE LIMIT
         │
         ▼
KERNEL THROTTLING
         │
         ▼
WORK CONTINUES MORE SLOWLY
         │
         ▼
LATENCY MAY INCREASE
</code></pre></div></div>

<p>For a Go HTTP or gRPC service, throttling can delay:</p>

<ul>
  <li>request handling;</li>
  <li>garbage collection;</li>
  <li>goroutine scheduling;</li>
  <li>TLS and serialisation work;</li>
  <li>health checks;</li>
  <li>graceful shutdown;</li>
  <li>background consumers.</li>
</ul>

<p>The service may remain alive while failing its latency objective.</p>

<h3 id="memory-is-incompressible">Memory is incompressible</h3>

<p>Memory cannot always be deferred.</p>

<p>Memory-limit enforcement is reactive. A container can briefly cross the configured value, but when the cgroup cannot satisfy an allocation, the kernel may OOM-kill a process in that cgroup. Kubernetes then observes the container termination. Whether it restarts depends on the Pod’s restart policy and its managing workload. <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/">Kubernetes resource management</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MEMORY DEMAND
REACHES THE HARD BOUNDARY
         │
         ▼
CGROUP OOM CONDITION
         │
         ▼
PROCESS TERMINATION
         │
         ▼
POSSIBLE CONTAINER RESTART
</code></pre></div></div>

<blockquote>
  <p><strong>CPU limits usually produce degraded execution. Memory limits may produce lost execution.</strong></p>
</blockquote>

<p>Both can damage availability, but they leave different evidence.</p>

<h2 id="the-units-deserve-respect">The units deserve respect</h2>

<p>Kubernetes CPU is measured in CPU units:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1 CPU = 1000m CPU
</code></pre></div></div>

<p>Therefore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>250m  = 0.25 CPU
500m  = 0.5 CPU
1500m = 1.5 CPU
</code></pre></div></div>

<p>Memory is measured in bytes. I normally use binary units:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>256Mi
512Mi
1Gi
</code></pre></div></div>

<p>The case matters:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">400Mi"</span>
</code></pre></div></div>

<p>means 400 mebibytes.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">400m"</span>
</code></pre></div></div>

<p>means 0.4 bytes. Kubernetes calls out this exact class of typo in its resource documentation. <a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/">Kubernetes resource management</a></p>

<p>Quoting quantities is not required, but it keeps their intended units visually explicit.</p>

<h2 id="a-go-service-has-two-memory-boundaries">A Go service has two memory boundaries</h2>

<p>The Kubernetes memory limit is an external cgroup boundary.</p>

<p>The Go runtime also supports a soft memory limit through:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GOMEMLIMIT
</code></pre></div></div>

<p>or:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">debug</span><span class="o">.</span><span class="n">SetMemoryLimit</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> includes the Go heap and other memory managed by the Go runtime, including goroutine stacks. It excludes external sources such as the binary’s mappings, memory managed by other languages and memory held by the operating system on the program’s behalf. It is not an RSS or cgroup-memory ceiling. <a href="https://go.dev/src/runtime/extern.go">Go runtime environment variables</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KUBERNETES MEMORY LIMIT

hard cgroup boundary
a process may be killed
        │
        ▼
HEADROOM

cgo and native allocations
external memory mappings
shared libraries
kernel accounting differences
temporary overshoot and variance
        │
        ▼
GOMEMLIMIT

soft Go runtime target
GC responds more aggressively
</code></pre></div></div>

<p>Suppose the application container has:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">limits</span><span class="pi">:</span>
  <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
</code></pre></div></div>

<p>An initial hypothesis might be:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">GOMEMLIMIT</span>
    <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">420MiB"</span> <span class="c1"># Illustrative; validate with measurements.</span>
</code></pre></div></div>

<p>The correct margin depends on the application’s measured memory shape, including:</p>

<ul>
  <li>Go runtime memory;</li>
  <li>cgo and native libraries;</li>
  <li>memory-mapped files;</li>
  <li>telemetry libraries;</li>
  <li>temporary allocation spikes;</li>
  <li>the difference between runtime and kernel accounting.</li>
</ul>

<p>A sidecar normally has its own container memory cgroup and does not consume the application container’s 512Mi limit. It still contributes to the Pod’s scheduling total, namespace quota, node pressure and any Pod-level autoscaling metric.</p>

<p>Setting <code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> equal to the container limit leaves little protection against memory outside the runtime’s accounting.</p>

<p>Setting it far too low can drive near-continuous garbage collection. The Go garbage-collector guide describes this as a soft limit: the runtime attempts to respect it while preserving enough CPU for the program to make progress. <a href="https://go.dev/doc/gc-guide">Go garbage-collector guide</a></p>

<p><code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> does not replace the Kubernetes limit.</p>

<p>The Kubernetes limit protects the node and neighbouring workloads.</p>

<p><code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> gives the Go runtime an earlier signal.</p>

<h2 id="go-125-can-use-cpu-limits-for-gomaxprocs-but-not-requests">Go 1.25 can use CPU limits for GOMAXPROCS, but not requests</h2>

<p>Before Go 1.25, the runtime usually selected <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> from the logical CPUs visible to the process.</p>

<p>A small container on a large node could therefore attempt much more parallel Go execution than its cgroup CPU limit could sustain.</p>

<p>Go 1.25 changed the Linux default. The runtime considers a cgroup CPU bandwidth limit when selecting <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code>, periodically checks for changes and rounds fractional CPU limits up to a valid integer. Kubernetes CPU limits normally provide that cgroup input. Kubernetes CPU requests do not. Manually setting <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> disables the automatic default and updates. <a href="https://go.dev/doc/go1.25">Go 1.25 release notes</a> <a href="https://go.dev/blog/container-aware-gomaxprocs">Container-aware GOMAXPROCS</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KUBERNETES CPU REQUEST

scheduler capacity claim
CPU weight under contention
HPA utilisation denominator


KUBERNETES CPU LIMIT

cgroup throughput ceiling
Go 1.25 runtime input


GOMAXPROCS

parallel Go execution limit
</code></pre></div></div>

<p>A CPU limit and <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> are not identical.</p>

<p>A CPU limit controls throughput over time.</p>

<p><code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> controls how many threads may execute Go code simultaneously.</p>

<p>The trade-off is therefore explicit:</p>

<table>
  <thead>
    <tr>
      <th>Operating model</th>
      <th>Benefit</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CPU request with no CPU limit</td>
      <td>Uses otherwise idle node CPU and avoids hard quota throttling</td>
      <td>Go cannot derive parallelism from the request; parallelism may reflect the node’s visible CPUs</td>
    </tr>
    <tr>
      <td>CPU request plus CPU limit</td>
      <td>Provides a cgroup ceiling and a Go 1.25 runtime input</td>
      <td>Hard throttling can increase latency</td>
    </tr>
    <tr>
      <td>CPU request with an explicit <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code></td>
      <td>Allows burst throughput while constraining Go parallelism</td>
      <td>Manual tuning disables automatic updates and must be owned deliberately</td>
    </tr>
  </tbody>
</table>

<p>For bfstore, I would not set <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> manually in every Deployment by habit.</p>

<p>I would choose the CPU operating model at platform level, measure throttling and tail latency, and override the runtime only when evidence supports it.</p>

<h2 id="a-baseline-bfstore-deployment">A baseline bfstore Deployment</h2>

<p>A baseline should be an explicit, measurable hypothesis.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">catalog-service</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">bfstore-prod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app.kubernetes.io/name</span><span class="pi">:</span> <span class="s">catalog-service</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app.kubernetes.io/name</span><span class="pi">:</span> <span class="s">catalog-service</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">catalog-service</span>
          <span class="na">image</span><span class="pi">:</span> <span class="s">registry.example/bfstore/catalog-service:v1.0.0</span>

          <span class="na">env</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">GOMEMLIMIT</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">420MiB"</span> <span class="c1"># Initial hypothesis, not a universal ratio.</span>

          <span class="na">ports</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">grpc</span>
              <span class="na">containerPort</span><span class="pi">:</span> <span class="m">8080</span>

          <span class="na">resources</span><span class="pi">:</span>
            <span class="na">requests</span><span class="pi">:</span>
              <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
              <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
            <span class="na">limits</span><span class="pi">:</span>
              <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>

          <span class="na">readinessProbe</span><span class="pi">:</span>
            <span class="na">grpc</span><span class="pi">:</span>
              <span class="na">port</span><span class="pi">:</span> <span class="m">8080</span>
            <span class="na">periodSeconds</span><span class="pi">:</span> <span class="m">5</span>

          <span class="na">livenessProbe</span><span class="pi">:</span>
            <span class="na">grpc</span><span class="pi">:</span>
              <span class="na">port</span><span class="pi">:</span> <span class="m">8080</span>
            <span class="na">periodSeconds</span><span class="pi">:</span> <span class="m">10</span>
</code></pre></div></div>

<p>This example deliberately omits a CPU limit.</p>

<p>That permits the service to use idle node CPU beyond its request and avoids hard cgroup throttling. It also means Go cannot derive <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> from the 250m request. On a large node, the runtime may select much greater parallelism unless CPU affinity, an explicit runtime value or another effective cgroup boundary narrows it.</p>

<p>A stricter environment might use:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resources</span><span class="pi">:</span>
  <span class="na">requests</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
</code></pre></div></div>

<p>That gives the runtime a cgroup CPU limit to consider and constrains prolonged consumption.</p>

<p>It can also create throttling.</p>

<p>Neither design is universally correct.</p>

<h2 id="sidecars-are-part-of-the-resource-shape">Sidecars are part of the resource shape</h2>

<p>A Pod may contain:</p>

<ul>
  <li>a service-mesh proxy;</li>
  <li>a telemetry collector;</li>
  <li>a certificate agent;</li>
  <li>a configuration reloader;</li>
  <li>a security agent.</li>
</ul>

<p>Each container needs its own resource contract:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">containers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">order-service</span>
      <span class="na">resources</span><span class="pi">:</span>
        <span class="na">requests</span><span class="pi">:</span>
          <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">300m"</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">320Mi"</span>
        <span class="na">limits</span><span class="pi">:</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">640Mi"</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">telemetry-sidecar</span>
      <span class="na">resources</span><span class="pi">:</span>
        <span class="na">requests</span><span class="pi">:</span>
          <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">50m"</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">64Mi"</span>
        <span class="na">limits</span><span class="pi">:</span>
          <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">200m"</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">128Mi"</span>
</code></pre></div></div>

<p>For ordinary container-level resources, the Pod total includes both containers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CPU REQUEST

300m + 50m = 350m


MEMORY REQUEST

320Mi + 64Mi = 384Mi


MEMORY LIMIT

640Mi + 128Mi = 768Mi
</code></pre></div></div>

<p>The sidecar also affects autoscaling when the HPA uses aggregate Pod resource utilisation.</p>

<p>That leads to a design decision:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Should scaling follow
the complete Pod cost?

or

Should scaling follow
the application container?
</code></pre></div></div>

<p>When a stable application container should drive scaling and sidecar usage is incidental, <code class="language-plaintext highlighter-rouge">autoscaling/v2</code> supports a <code class="language-plaintext highlighter-rouge">ContainerResource</code> metric:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">metrics</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">ContainerResource</span>
    <span class="na">containerResource</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">cpu</span>
      <span class="na">container</span><span class="pi">:</span> <span class="s">catalog-service</span>
      <span class="na">target</span><span class="pi">:</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">Utilization</span>
        <span class="na">averageUtilization</span><span class="pi">:</span> <span class="m">65</span>
</code></pre></div></div>

<p>Container names then become part of the autoscaling contract. A rename must be coordinated with the HPA. <a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/">Kubernetes HPA</a></p>

<h2 id="requests-and-limits-create-a-qos-class">Requests and limits create a QoS class</h2>

<p>Kubernetes assigns each Pod a Quality of Service class.</p>

<h3 id="guaranteed">Guaranteed</h3>

<p>Every relevant container has:</p>

<ul>
  <li>a CPU request;</li>
  <li>an equal CPU limit;</li>
  <li>a memory request;</li>
  <li>an equal memory limit.</li>
</ul>

<h3 id="burstable">Burstable</h3>

<p>At least one request or limit exists, but the Pod does not meet all <code class="language-plaintext highlighter-rouge">Guaranteed</code> conditions.</p>

<h3 id="besteffort">BestEffort</h3>

<p>No relevant container has a CPU or memory request or limit.</p>

<p>QoS influences node-pressure behaviour, but the common shorthand of “<code class="language-plaintext highlighter-rouge">BestEffort</code>, then <code class="language-plaintext highlighter-rouge">Burstable</code>, then <code class="language-plaintext highlighter-rouge">Guaranteed</code>” is not the complete eviction algorithm.</p>

<p>For node-pressure eviction, Kubernetes considers:</p>

<ol>
  <li>whether the Pod’s use of the starved resource exceeds its request;</li>
  <li>Pod priority;</li>
  <li>usage relative to the request.</li>
</ol>

<p>A <code class="language-plaintext highlighter-rouge">BestEffort</code> Pod is especially exposed because any positive usage exceeds its zero request. A <code class="language-plaintext highlighter-rouge">Guaranteed</code> Pod has stronger protection, but it is not immune to eviction or to a container-level OOM. <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/">Kubernetes QoS classes</a> <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/">Kubernetes node-pressure eviction</a> <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/">Pod priority and eviction ranking</a></p>

<p>The baseline example is <code class="language-plaintext highlighter-rouge">Burstable</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">requests</span><span class="pi">:</span>
  <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
  <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
<span class="na">limits</span><span class="pi">:</span>
  <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
</code></pre></div></div>

<p>The gap says:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plan for this much.

Allow this much memory.

Treat the difference
as deliberate burst space.
</code></pre></div></div>

<p>Using equal requests and limits to obtain <code class="language-plaintext highlighter-rouge">Guaranteed</code> QoS may suit workloads that need predictable placement and stronger pressure protection.</p>

<p>It also accounts the full limit during scheduling.</p>

<p>QoS should follow the workload’s operating requirements, not act as a badge.</p>

<h2 id="the-cpu-request-becomes-the-hpa-denominator">The CPU request becomes the HPA denominator</h2>

<p>For a percentage-based CPU target, the HorizontalPodAutoscaler compares observed CPU use with the relevant CPU request.</p>

<p>Suppose one Pod consumes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>200m CPU
</code></pre></div></div>

<p>With a request of:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>250m
</code></pre></div></div>

<p>utilisation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>200 / 250 = 80%
</code></pre></div></div>

<p>With a request of:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>500m
</code></pre></div></div>

<p>the same process usage becomes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>200 / 500 = 40%
</code></pre></div></div>

<p>The application did not change.</p>

<p>The autoscaler’s interpretation did.</p>

<blockquote>
  <p><strong>If the request is fictional, the target is fictional too.</strong></p>
</blockquote>

<p>A Pod-level CPU utilisation metric also depends on the relevant containers having requests. Missing requests can prevent the HPA from calculating utilisation normally. <a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/">Kubernetes HPA</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">autoscaling/v2</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">HorizontalPodAutoscaler</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">catalog-service</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">bfstore-prod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">scaleTargetRef</span><span class="pi">:</span>
    <span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">catalog-service</span>

  <span class="na">minReplicas</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">maxReplicas</span><span class="pi">:</span> <span class="m">12</span>

  <span class="na">metrics</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">Resource</span>
      <span class="na">resource</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">cpu</span>
        <span class="na">target</span><span class="pi">:</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">Utilization</span>
          <span class="na">averageUtilization</span><span class="pi">:</span> <span class="m">65</span>

  <span class="na">behavior</span><span class="pi">:</span>
    <span class="na">scaleDown</span><span class="pi">:</span>
      <span class="na">stabilizationWindowSeconds</span><span class="pi">:</span> <span class="m">300</span>
</code></pre></div></div>

<p>CPU may not be the best demand signal.</p>

<p>Alternatives include:</p>

<ul>
  <li>request rate per Pod;</li>
  <li>active requests;</li>
  <li>queue depth;</li>
  <li>event-processing lag;</li>
  <li>concurrency;</li>
  <li>latency;</li>
  <li>several metrics together.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">autoscaling/v2</code> can evaluate several metrics and use the highest proposed replica count. <a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/">Kubernetes HPA</a></p>

<h2 id="resourcequota-creates-a-namespace-ceiling">ResourceQuota creates a namespace ceiling</h2>

<p>A production namespace may contain:</p>

<ul>
  <li>application services;</li>
  <li>workers;</li>
  <li>migration Jobs;</li>
  <li>gateways;</li>
  <li>telemetry components;</li>
  <li>old and new replicas during rollouts.</li>
</ul>

<p>A <code class="language-plaintext highlighter-rouge">ResourceQuota</code> can express the namespace’s aggregate ceiling:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ResourceQuota</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">bfstore-production-budget</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">bfstore-prod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">hard</span><span class="pi">:</span>
    <span class="na">requests.cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">20"</span>
    <span class="na">requests.memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">40Gi"</span>

    <span class="na">limits.cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">40"</span>
    <span class="na">limits.memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">80Gi"</span>

    <span class="na">pods</span><span class="pi">:</span> <span class="s2">"</span><span class="s">150"</span>
    <span class="na">services</span><span class="pi">:</span> <span class="s2">"</span><span class="s">40"</span>
    <span class="na">persistentvolumeclaims</span><span class="pi">:</span> <span class="s2">"</span><span class="s">30"</span>
</code></pre></div></div>

<p>Kubernetes rejects a new or resized object when accepting it would exceed the applicable quota. A Deployment object itself may still be accepted while the controller fails to create all its Pods; the controller status and events expose the admission failure. <a href="https://kubernetes.io/docs/concepts/policy/resource-quotas/">Kubernetes ResourceQuotas</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RESOURCE QUOTA

admission-time ceiling


NOT

namespace CPU throttling
shared memory enforcement
reserved cluster capacity
automatic rightsizing
</code></pre></div></div>

<p>The budget needs headroom for:</p>

<ul>
  <li>rollout surge;</li>
  <li>autoscaling;</li>
  <li>Jobs;</li>
  <li>incident tooling;</li>
  <li>operational agents;</li>
  <li>controlled failover.</li>
</ul>

<p>A quota calculated only from steady-state replicas can block the rollout intended to improve the service.</p>

<p>Quota is capacity governance, not capacity reservation.</p>

<h2 id="limitrange-provides-rails-not-rightsizing">LimitRange provides rails, not rightsizing</h2>

<p>A <code class="language-plaintext highlighter-rouge">LimitRange</code> can reject clearly unsupported shapes and supply defaults:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">LimitRange</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">bfstore-service-boundaries</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">bfstore-prod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">Container</span>

      <span class="na">min</span><span class="pi">:</span>
        <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">25m"</span>
        <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">32Mi"</span>

      <span class="na">max</span><span class="pi">:</span>
        <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4"</span>
        <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4Gi"</span>

      <span class="na">defaultRequest</span><span class="pi">:</span>
        <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">100m"</span>
        <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">128Mi"</span>

      <span class="na">default</span><span class="pi">:</span>
        <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
        <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
</code></pre></div></div>

<p>This prevents an ordinary container from declaring a shape the namespace or node classes cannot reasonably support.</p>

<p>Defaults still require care.</p>

<p>If ten services silently inherit the same values, the platform has achieved consistency without proving accuracy.</p>

<p>For bfstore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GOLDEN PATH

explicit reviewed values


LIMIT RANGE

supported individual boundaries


RESOURCE QUOTA

namespace-wide declaration ceiling
</code></pre></div></div>

<p>The golden path should render an explicit resource block.</p>

<p>The <code class="language-plaintext highlighter-rouge">LimitRange</code> should remain a final guardrail for third-party charts, Jobs and incomplete workloads.</p>

<h2 id="choosing-the-first-numbers">Choosing the first numbers</h2>

<p>No production service begins with perfect resource data. The first values are hypotheses.</p>

<p>Measure startup, steady-state and post-GC memory; credible memory peaks; background and loaded CPU; goroutine count; cgo or mapping behaviour; and telemetry overhead.</p>

<p>Then test realistic conditions:</p>

<ul>
  <li>expected and peak traffic;</li>
  <li>large valid payloads and batches;</li>
  <li>slow downstream dependencies, retries and timeouts;</li>
  <li>database contention or Kafka bursts;</li>
  <li>startup and graceful shutdown.</li>
</ul>

<p>For CPU, observe upper-percentile usage, throttling, GC CPU and latency at saturation. For memory, observe Go runtime memory, working set, RSS, post-GC floor, peaks and OOM events.</p>

<p>Choose requests from the capacity one replica needs during ordinary operation under its SLO, not from its lowest idle measurement.</p>

<p>For memory limits, keep measured headroom above credible peaks while retaining a useful node-protection boundary. For CPU, choose explicitly between a hard ceiling and request-based access to idle capacity.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MEASURE
   │
   ▼
SET AN INITIAL CONTRACT
   │
   ▼
DEPLOY AND OBSERVE
   │
   ▼
ADJUST
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">kubectl top</code> provides a useful recent snapshot when Metrics Server is available, but durable evidence should come from the observability platform:</p>

<ul>
  <li>CPU use and throttling;</li>
  <li>working set, RSS, OOM kills and restarts;</li>
  <li>Go runtime memory, GC CPU and goroutines;</li>
  <li>latency, request rate, queue depth or consumer lag;</li>
  <li>desired and admitted replica counts.</li>
</ul>

<p>A service using little CPU because it is deadlocked is not efficient.</p>

<h2 id="reading-common-failures">Reading common failures</h2>

<h3 id="the-workload-controller-cannot-create-a-pod">The workload controller cannot create a Pod</h3>

<p>Check <code class="language-plaintext highlighter-rouge">ResourceQuota</code>, <code class="language-plaintext highlighter-rouge">LimitRange</code>, other admission policies and the Deployment’s <code class="language-plaintext highlighter-rouge">ReplicaFailure</code> events. A Deployment can exist while its requested Pods do not.</p>

<h3 id="a-pod-exists-but-remains-pending">A Pod exists but remains Pending</h3>

<p>Check whether any eligible node can satisfy the requests, then inspect affinity, taints, tolerations, topology constraints and node-class availability. This is a scheduling failure after admission, not the same as a quota rejection.</p>

<h3 id="a-container-is-oomkilled">A container is OOMKilled</h3>

<p>Inspect which process was killed, Go runtime memory, cgo or mapping growth, transient allocations and the hard limit. Raising the limit may be correct, but it can also turn a leak into a slower incident.</p>

<h3 id="latency-rises-while-cpu-is-throttled">Latency rises while CPU is throttled</h3>

<p>Check the CPU limit, GC bursts, <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code>, replica count and downstream retries. The service may be alive while users are already waiting.</p>

<h3 id="hpa-scales-unexpectedly">HPA scales unexpectedly</h3>

<p>Check the credibility of CPU requests, sidecar usage, missing metrics, quota headroom and whether CPU actually represents demand. Consider a <code class="language-plaintext highlighter-rouge">ContainerResource</code> metric when the application container should drive scaling.</p>

<h3 id="a-node-experiences-memory-pressure">A node experiences memory pressure</h3>

<p>Check whether the Pod exceeds its request, its priority, relative overuse and the node’s reservations and eviction thresholds. QoS is useful context, not the complete eviction algorithm.</p>

<h2 id="workload-profiles-are-starting-ranges">Workload profiles are starting ranges</h2>

<p>A golden path should reduce blank-page work without pretending to know every service.</p>

<p>Example starting ranges might be:</p>

<table>
  <thead>
    <tr>
      <th>Workload shape</th>
      <th style="text-align: right">Initial CPU request range</th>
      <th style="text-align: right">Initial memory request range</th>
      <th>Primary validation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Small I/O-bound API</td>
      <td style="text-align: right">50–150m</td>
      <td style="text-align: right">96–192Mi</td>
      <td>latency and concurrency</td>
    </tr>
    <tr>
      <td>Standard request-response API</td>
      <td style="text-align: right">150–500m</td>
      <td style="text-align: right">192–512Mi</td>
      <td>latency, GC and peak memory</td>
    </tr>
    <tr>
      <td>CPU-governed service</td>
      <td style="text-align: right">500m–2 CPU</td>
      <td style="text-align: right">256–1024Mi</td>
      <td>throttling and tail latency</td>
    </tr>
    <tr>
      <td>Event worker</td>
      <td style="text-align: right">evidence-dependent</td>
      <td style="text-align: right">evidence-dependent</td>
      <td>lag, batch size and memory peaks</td>
    </tr>
  </tbody>
</table>

<p>The platform should attach governance to the profile:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">workload</span><span class="pi">:</span>
  <span class="na">profile</span><span class="pi">:</span> <span class="s">standard-go-service</span>
  <span class="na">profile_version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2"</span>
  <span class="na">review_after</span><span class="pi">:</span> <span class="s2">"</span><span class="s">30d"</span>

<span class="na">autoscaling</span><span class="pi">:</span>
  <span class="na">metric</span><span class="pi">:</span> <span class="s">cpu</span>
  <span class="na">target_utilisation</span><span class="pi">:</span> <span class="m">65</span>

<span class="na">resource_overrides</span><span class="pi">:</span>
  <span class="na">requests</span><span class="pi">:</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">384Mi"</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">768Mi"</span>

<span class="na">evidence</span><span class="pi">:</span>
  <span class="na">load_test</span><span class="pi">:</span> <span class="s">required</span>
  <span class="na">owner</span><span class="pi">:</span> <span class="s">catalog-team</span>
</code></pre></div></div>

<p>The platform can then:</p>

<ul>
  <li>render explicit Kubernetes values;</li>
  <li>validate them against <code class="language-plaintext highlighter-rouge">LimitRange</code>;</li>
  <li>calculate quota and rollout headroom;</li>
  <li>add standard dashboards;</li>
  <li>require evidence for unusually large overrides;</li>
  <li>surface throttling and OOM behaviour after deployment.</li>
</ul>

<p>A profile is a starting point.</p>

<p>It is not production truth.</p>

<h2 id="the-decision-checklist">The decision checklist</h2>

<p>Before approving a resource contract, I now ask:</p>

<ol>
  <li>What resource shape does the workload exhibit under realistic load?</li>
  <li>Which CPU and memory values must scheduling plan for?</li>
  <li>Which hard limits protect the platform without breaking the SLO?</li>
  <li>How do <code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> and <code class="language-plaintext highlighter-rouge">GOMAXPROCS</code> relate to those boundaries?</li>
  <li>Can rollout surge and maximum autoscaling fit beneath quota?</li>
  <li>Which production evidence will trigger rightsizing?</li>
</ol>

<h2 id="the-mental-model">The mental model</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         GO SERVICE
                              │
                              ▼
                    OBSERVED RESOURCE SHAPE
                              │
                 ┌────────────┴────────────┐
                 ▼                         ▼
            CPU REQUEST              MEMORY REQUEST
       scheduling, weight, HPA    scheduling, eviction
                 │                         │
                 └────────────┬────────────┘
                              ▼
                 ┌────────────┴────────────┐
                 ▼                         ▼
             CPU LIMIT                MEMORY LIMIT
             throttling                 OOM wall
                 │                         │
                 ▼                         ▼
            GOMAXPROCS                 GOMEMLIMIT
                 │                         │
                 └────────────┬────────────┘
                              ▼
                 ┌────────────┴────────────┐
                 ▼                         ▼
             LIMITRANGE              RESOURCEQUOTA
        individual guardrail        namespace ceiling
                              │
                              ▼
                      CLUSTER CAPACITY
</code></pre></div></div>

<p>Requests make placement and autoscaling more honest. Limits define selected runtime boundaries. <code class="language-plaintext highlighter-rouge">GOMEMLIMIT</code> gives the garbage collector an earlier signal, while Go 1.25 can align default parallelism with a CPU limit. <code class="language-plaintext highlighter-rouge">LimitRange</code> constrains individual shapes, and <code class="language-plaintext highlighter-rouge">ResourceQuota</code> constrains the namespace total.</p>

<p>Observability decides whether any of those assumptions were true.</p>

<p>The numbers will change. That is expected.</p>

<p>The failure would be choosing them once, copying them into every service and calling the resulting uniformity a platform.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li><a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/">Kubernetes resource management for Pods and containers</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/policy/resource-quotas/">Kubernetes ResourceQuotas</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/policy/limit-range/">Kubernetes LimitRanges</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/">Kubernetes Pod Quality of Service classes</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/">Kubernetes node-pressure eviction</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/">Kubernetes Pod priority and eviction ranking</a></li>
  <li><a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/">Kubernetes Horizontal Pod Autoscaling</a></li>
  <li><a href="https://go.dev/doc/go1.25">Go 1.25 release notes</a></li>
  <li><a href="https://go.dev/blog/container-aware-gomaxprocs">Container-aware GOMAXPROCS</a></li>
  <li><a href="https://go.dev/src/runtime/extern.go">Go runtime environment variables</a></li>
  <li><a href="https://go.dev/doc/gc-guide">Go garbage-collector guide</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[Kubernetes resource requests, limits, LimitRanges and ResourceQuotas solve different capacity problems. For Go services, those controls must also account for garbage collection, GOMEMLIMIT, container-aware GOMAXPROCS, sidecars, autoscaling and the difference between CPU throttling and memory termination.]]></summary></entry><entry><title type="html">KMS, cryptographic erasure, and ecommerce data</title><link href="https://cloud.doublewords.net/writing/2026/06/22/kms-cryptographic-erasure-and-ecommerce-data/" rel="alternate" type="text/html" title="KMS, cryptographic erasure, and ecommerce data" /><published>2026-06-22T00:00:00+01:00</published><updated>2026-06-22T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/06/22/kms-cryptographic-erasure-and-ecommerce-data</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/06/22/kms-cryptographic-erasure-and-ecommerce-data/"><![CDATA[<p>A customer asks bfstore to remove their account.</p>

<p>The customer profile is deleted from MySQL.</p>

<p>The job reports:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>customer_deleted = true
</code></pre></div></div>

<p>The application has removed one row.</p>

<p>The customer may still exist in:</p>

<ul>
  <li>order records</li>
  <li>shipping events</li>
  <li>payment-provider references</li>
  <li>Kafka topics</li>
  <li>search indexes</li>
  <li>application logs and traces</li>
  <li>support exports</li>
  <li>database snapshots</li>
  <li>cross-account backups</li>
  <li>disaster-recovery copies</li>
  <li>a CSV downloaded during an incident</li>
</ul>

<p>Deleting the live row is not the same as eliminating every recoverable representation of the data.</p>

<p>Encryption adds another possible control. If the remaining copies are ciphertext and every key path required to decrypt them is permanently removed, the ciphertext may remain physically present while the protected plaintext becomes infeasible to recover.</p>

<p>That is the idea behind <strong>cryptographic erase</strong>.</p>

<p>NIST SP 800-88 Revision 2 describes cryptographic erase as a purge technique based on sanitising the keys that encrypt data, or keys that prevent access to those encryption keys. It also places strict conditions on the claim: sensitive data must not previously have been stored in plaintext on the affected storage, all relevant key copies must be sanitised, and backed-up, escrowed or unwrapped keys must be handled separately.<sup id="fnref:nist-ce"><a href="#fn:nist-ce" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<p>That is much stricter than:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>We deleted the KMS alias.
</code></pre></div></div>

<p>or:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>We turned encryption on.
</code></pre></div></div>

<blockquote>
  <p><strong>Cryptographic erasure is not a button. It is an evidence-backed claim that no usable route from the remaining ciphertext to its plaintext still exists.</strong></p>
</blockquote>

<p>AWS KMS can control an important part of that route. The architecture must still know where the data, derived copies and relevant keys live.</p>

<p>Throughout this article, <strong>bfstore</strong> is a hypothetical AWS-hosted ecommerce platform used to make the design choices concrete. The intended audience is application, platform and security engineers designing access, recovery and deletion boundaries. This is not legal advice or a complete KMS operations manual.</p>

<p>AWS service behaviour in this article was checked against official documentation on 31 July 2026. Service state transitions and permission requirements should be confirmed again when a production runbook is used.</p>

<h2 id="the-decision-in-one-table">The decision in one table</h2>

<table>
  <thead>
    <tr>
      <th>Desired result</th>
      <th>Required design</th>
      <th>What is not enough</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Remove one customer</td>
      <td>Data mapping, field deletion, projection cleanup and, where justified, destruction of a dedicated data-key envelope</td>
      <td>Deleting a shared database KMS key</td>
    </tr>
    <tr>
      <td>Retire an entire dataset</td>
      <td>Inventory every copy and remove every required key path</td>
      <td>Deleting an alias</td>
    </tr>
    <tr>
      <td>Preserve disaster recovery</td>
      <td>Independent backup copies and intentionally separated keys</td>
      <td>Sharing one key across production and recovery</td>
    </tr>
    <tr>
      <td>Prevent erased data returning after restore</td>
      <td>Replay an erasure ledger before reopening service</td>
      <td>Successfully starting the restored database</td>
    </tr>
    <tr>
      <td>Reduce payment risk</td>
      <td>Minimise collection and use provider tokenisation</td>
      <td>Encrypting unnecessary card data</td>
    </tr>
    <tr>
      <td>Rotate protection</td>
      <td>Use new key material and migrate data where required</td>
      <td>Assuming rotation destroys old material</td>
    </tr>
  </tbody>
</table>

<p>The recurring question is not simply:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is this encrypted?
</code></pre></div></div>

<p>It is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which data must be accessible,
recoverable and destroyed together?
</code></pre></div></div>

<h2 id="define-the-erasure-claim-before-choosing-the-mechanism">Define the erasure claim before choosing the mechanism</h2>

<p>“No usable route remains” needs a scope.</p>

<p>For bfstore, the technical claim should cover:</p>

<ul>
  <li>the identified ciphertext;</li>
  <li>every retained plaintext or derived representation;</li>
  <li>KMS keys and application data keys controlled by bfstore;</li>
  <li>wrapped-key copies in databases, events, exports and backups;</li>
  <li>grants, replicas and external key stores that can restore access;</li>
  <li>plaintext keys still held in application or service memory.</li>
</ul>

<p>It does not prove that plaintext was never copied or exfiltrated before the erasure operation. It does not protect against a future failure of the cryptographic algorithm. It does not automatically establish compliance with every legal or contractual deletion requirement.</p>

<p>The evidence should therefore separate three claims:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TECHNICAL CLAIM

The identified ciphertext cannot be
decrypted through any retained key path.


DATA-LIFECYCLE CLAIM

Live, replicated, exported and backup
representations were handled under
the approved retention policy.


COMPLIANCE CLAIM

The organisation reviewed whether
that process satisfies the applicable
legal and contractual obligations.
</code></pre></div></div>

<p>Keeping those claims separate makes each one more credible.</p>

<h2 id="encryption-at-rest-is-only-one-stage-of-the-lifecycle">Encryption at rest is only one stage of the lifecycle</h2>

<p>AWS services provide broad encryption-at-rest support. For example, Amazon S3 encrypts new objects by default, RDS encryption covers database storage, logs, automated backups and snapshots, and DynamoDB encrypts table data, indexes, streams and backups.<sup id="fnref:s3-default"><a href="#fn:s3-default" class="footnote" rel="footnote" role="doc-noteref">2</a></sup><sup id="fnref:rds-encryption"><a href="#fn:rds-encryption" class="footnote" rel="footnote" role="doc-noteref">3</a></sup><sup id="fnref:dynamodb-encryption"><a href="#fn:dynamodb-encryption" class="footnote" rel="footnote" role="doc-noteref">4</a></sup></p>

<p>That protection matters. Someone who obtains encrypted storage should not automatically obtain readable data.</p>

<p>But <strong>encrypted</strong> does not answer:</p>

<ul>
  <li>Who may request decryption?</li>
  <li>Which workloads share the key?</li>
  <li>Which backup has another key?</li>
  <li>What happens during recovery?</li>
  <li>Which records become unreadable if the key is deleted?</li>
  <li>Does the same plaintext exist elsewhere?</li>
  <li>Is the data being retained longer than necessary?</li>
</ul>

<p>The UK Information Commissioner’s Office says organisations should establish retention periods, erase or anonymise data that is no longer needed, and address backup data rather than treating deletion from the live system as the whole process.<sup id="fnref:ico-storage"><a href="#fn:ico-storage" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></p>

<p>A useful sequence is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DATA CLASSIFICATION
        │
        ▼
RETENTION RULE
        │
        ▼
ENCRYPTION BOUNDARY
        │
        ▼
RECOVERY DESIGN
        │
        ▼
ERASURE DESIGN
</code></pre></div></div>

<p>Encryption should be designed with the final stage already visible.</p>

<h2 id="kms-usually-protects-the-keys-that-encrypt-the-data">KMS usually protects the keys that encrypt the data</h2>

<p>Large application payloads are generally protected through <strong>envelope encryption</strong>.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PLAINTEXT DATA
      │
      ▼
DATA KEY
      │
      ▼
CIPHERTEXT DATA


DATA KEY
      │
      ▼
KMS KEY
      │
      ▼
ENCRYPTED DATA KEY
</code></pre></div></div>

<p>AWS KMS can generate a plaintext data key for immediate use and an encrypted copy that can be stored with the encrypted data.<sup id="fnref:data-keys"><a href="#fn:data-keys" class="footnote" rel="footnote" role="doc-noteref">6</a></sup> The application or AWS service then stores:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ciphertext

+

encrypted data key
</code></pre></div></div>

<p>When the data is read:</p>

<ol>
  <li>the encrypted data key is sent to KMS;</li>
  <li>KMS authorises the operation;</li>
  <li>KMS returns the plaintext data key;</li>
  <li>the data key decrypts the application data;</li>
  <li>the plaintext key should be removed from memory as soon as possible.</li>
</ol>

<p>KMS does not retain an inventory of every data key, row, object, export or backup protected through that process. It also does not store the application ciphertext.<sup id="fnref:kms-delete"><a href="#fn:kms-delete" class="footnote" rel="footnote" role="doc-noteref">7</a></sup></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KMS CAN RECORD

the KMS key
the cryptographic request
the caller
the policy and encryption context


KMS DOES NOT INVENTORY

every database row
every S3 object version
every backup
every exported encrypted data key
every plaintext copy
</code></pre></div></div>

<p>Deleting a KMS key can make the data keys wrapped by it unusable. It cannot prove that another data-key copy or plaintext representation does not exist.</p>

<h2 id="key-topology-becomes-deletion-topology">Key topology becomes deletion topology</h2>

<p>Suppose every production resource uses:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alias/bfstore-prod
</code></pre></div></div>

<p>It protects:</p>

<ul>
  <li>customer and order databases;</li>
  <li>application logs;</li>
  <li>Kafka storage;</li>
  <li>snapshots;</li>
  <li>backup vaults;</li>
  <li>sensitive exports.</li>
</ul>

<p>The policy inventory is small. The destruction boundary is enormous.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DELETE ONE KEY
      │
      ├── customer records unreadable
      ├── orders unreadable
      ├── logs unreadable
      ├── backups unreadable
      └── operational recovery lost
</code></pre></div></div>

<p>The key cannot selectively erase one customer. It also cannot retire one service without affecting unrelated workloads.</p>

<p>At the other extreme, one KMS key per row would create a large key inventory, difficult policies, recovery complexity and avoidable cost.</p>

<p>The useful boundary usually lies between those extremes.</p>

<blockquote>
  <p><strong>A KMS key should protect data that shares an access policy, recovery policy and destruction lifecycle.</strong></p>
</blockquote>

<p>For bfstore, a first layout might be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alias/bfstore-prod-customer-data
alias/bfstore-prod-order-data
alias/bfstore-prod-payment-tokens
alias/bfstore-prod-application-logs
alias/bfstore-prod-sensitive-exports
alias/bfstore-prod-backups
</code></pre></div></div>

<p>This is not a rule that every resource needs a unique key. It is a warning against placing unrelated destruction and recovery requirements under one cryptographic roof.</p>

<h2 id="ecommerce-data-does-not-have-one-lifecycle">Ecommerce data does not have one lifecycle</h2>

<table>
  <thead>
    <tr>
      <th>Data</th>
      <th>Main concern</th>
      <th>Likely lifecycle decision</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Product names and images</td>
      <td>Integrity and availability</td>
      <td>Broad service encryption may be sufficient</td>
    </tr>
    <tr>
      <td>Customer email</td>
      <td>Personal data and account lifecycle</td>
      <td>Remove when no longer justified</td>
    </tr>
    <tr>
      <td>Password verifier</td>
      <td>Authentication security</td>
      <td>Delete with account and manage credential rotation</td>
    </tr>
    <tr>
      <td>Shipping address</td>
      <td>Personal and operational</td>
      <td>Retain only through fulfilment, returns and justified claims</td>
    </tr>
    <tr>
      <td>Order line items</td>
      <td>Commercial record</td>
      <td>May need longer retention than the account profile</td>
    </tr>
    <tr>
      <td>Provider payment token</td>
      <td>Financially sensitive capability</td>
      <td>Narrow access and provider-aligned retention</td>
    </tr>
    <tr>
      <td>Full primary account number (PAN)</td>
      <td>Cardholder data</td>
      <td>Avoid storing unless genuinely required</td>
    </tr>
    <tr>
      <td>Card verification code or value</td>
      <td>Sensitive authentication data</td>
      <td>Do not retain after authorisation</td>
    </tr>
    <tr>
      <td>Fraud signals</td>
      <td>Sensitive profiling</td>
      <td>Restricted access and documented retention</td>
    </tr>
    <tr>
      <td>Logs and traces</td>
      <td>Operational data that may contain personal data</td>
      <td>Redact at source and retain briefly</td>
    </tr>
    <tr>
      <td>Analytics events</td>
      <td>Behavioural data</td>
      <td>Minimise, aggregate and expire</td>
    </tr>
    <tr>
      <td>Backups</td>
      <td>Same sensitivity as their source</td>
      <td>Recovery period plus controlled delayed erasure</td>
    </tr>
  </tbody>
</table>

<p>The data model should let these lifecycles diverge.</p>

<p>An order may need to survive account closure, but it may not need to keep the customer’s email and full shipping address for the same period. Public catalogue data may still be encrypted, but key deletion is unlikely to be its main end-of-life mechanism.</p>

<h2 id="record-level-crypto-shredding-needs-a-dedicated-envelope">Record-level crypto-shredding needs a dedicated envelope</h2>

<p>Deleting a database-level KMS key affects the encrypted resource, not one customer.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CUSTOMER DATABASE
        │
        ▼
alias/bfstore-prod-customer-data
</code></pre></div></div>

<p>For narrower control, bfstore can selectively apply application-level envelope encryption to high-risk fields or records.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">customer_id</span><span class="pi">:</span> <span class="s">customer-8042</span>

<span class="na">encrypted_profile</span><span class="pi">:</span>
  <span class="na">ciphertext</span><span class="pi">:</span> <span class="s">AQICAH...</span>
  <span class="na">encrypted_data_key</span><span class="pi">:</span> <span class="s">AQIDAH...</span>
  <span class="na">algorithm</span><span class="pi">:</span> <span class="s">AES-256-GCM</span>
  <span class="na">schema_version</span><span class="pi">:</span> <span class="m">2</span>
</code></pre></div></div>

<p>The sensitive profile is encrypted with a data-encryption key. The wrapped copy of that data key is stored with the ciphertext.</p>

<p>At customer deletion, bfstore may remove every recoverable copy of:</p>

<ul>
  <li>the wrapped data key;</li>
  <li>plaintext projections and indexes;</li>
  <li>duplicate envelopes in events or exports;</li>
  <li>cached plaintext keys;</li>
  <li>the ciphertext itself, where the retention policy requires deletion.</li>
</ul>

<p>For shared KMS keys, this narrower pattern is often called <strong>record-level crypto-shredding</strong>. NIST’s guidance supports sanitising wrapped versions of data-encryption keys as a way to prevent decryption, but the assurance conditions still apply: every wrapped or unwrapped copy must be addressed.<sup id="fnref:nist-ce:1"><a href="#fn:nist-ce" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<p>Deleting one wrapped-key row from the live database is not enough when the same envelope survives in a snapshot, Kafka event, support export or replicated store.</p>

<p>Also keep two actions distinct:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DATA DELETION

remove the ciphertext or plaintext record


CRYPTOGRAPHIC ERASURE

eliminate every usable decryption-key path
</code></pre></div></div>

<p>A robust customer-erasure workflow may use both. They are not the same operation.</p>

<p>Application envelope encryption should be selective. Good candidates include saved addresses, high-risk profile attributes, sensitive support attachments and confidential exports. Encrypting every ordinary column independently without an access or deletion reason creates complexity rather than useful control.</p>

<h2 id="payment-data-minimise-before-encrypting">Payment data: minimise before encrypting</h2>

<p>Encryption is not permission to collect every payment field.</p>

<p>PCI DSS requires retention to be limited to legal, regulatory and business needs. Card verification codes or values cannot be retained after authorisation, even if encrypted. Encryption of stored cardholder data also does not, by itself, remove the environment from PCI DSS scope.<sup id="fnref:pci-retention"><a href="#fn:pci-retention" class="footnote" rel="footnote" role="doc-noteref">8</a></sup><sup id="fnref:pci-cvv"><a href="#fn:pci-cvv" class="footnote" rel="footnote" role="doc-noteref">9</a></sup><sup id="fnref:pci-scope"><a href="#fn:pci-scope" class="footnote" rel="footnote" role="doc-noteref">10</a></sup></p>

<p>A preferred application record looks like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">payment</span><span class="pi">:</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">example-processor</span>
  <span class="na">provider_customer_id</span><span class="pi">:</span> <span class="s">cus_7f42</span>
  <span class="na">provider_payment_method_id</span><span class="pi">:</span> <span class="s">pm_91a8</span>
  <span class="na">payment_intent_id</span><span class="pi">:</span> <span class="s">pi_4431</span>
  <span class="na">card_brand</span><span class="pi">:</span> <span class="s">visa</span>
  <span class="na">card_last_four</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4242"</span>
  <span class="na">status</span><span class="pi">:</span> <span class="s">authorised</span>
</code></pre></div></div>

<p>Not:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">payment</span><span class="pi">:</span>
  <span class="na">pan</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4242424242424242"</span>
  <span class="na">expiry</span><span class="pi">:</span> <span class="s2">"</span><span class="s">12/30"</span>
  <span class="na">card_verification_value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">123"</span>
</code></pre></div></div>

<p>The safer sequence is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DO NOT COLLECT UNNECESSARY DATA
            │
            ▼
USE PROVIDER TOKENISATION
            │
            ▼
ENCRYPT RETAINED REFERENCES
</code></pre></div></div>

<p>Provider tokens and transaction identifiers still need protection. They may permit refunds, account actions or valuable operations in the wrong application context.</p>

<h2 id="encryption-context-binds-purpose-not-identity">Encryption context binds purpose, not identity</h2>

<p>AWS KMS supports an <strong>encryption context</strong> for symmetric cryptographic operations. It is non-secret additional authenticated data that is cryptographically bound to the ciphertext. The same exact, case-sensitive context must be supplied for decryption. Context values may also be used in policy conditions and appear in plaintext in CloudTrail.<sup id="fnref:encryption-context"><a href="#fn:encryption-context" class="footnote" rel="footnote" role="doc-noteref">11</a></sup></p>

<p>Suitable:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customer"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"record-type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"shipping-address"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"environment"</span><span class="p">:</span><span class="w"> </span><span class="s2">"production"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Unsuitable:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"customer-email"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mariam@example.com"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"shipping-address"</span><span class="p">:</span><span class="w"> </span><span class="s2">"42 Example Road"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>A policy fragment might require the expected service and environment:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Allow"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Principal"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"AWS"</span><span class="p">:</span><span class="w"> </span><span class="s2">"arn:aws:iam::111122223333:role/bfstore-customer-runtime"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"Action"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"kms:Decrypt"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"kms:GenerateDataKey"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"Resource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Condition"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"StringEquals"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"kms:EncryptionContext:service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"customer"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kms:EncryptionContext:environment"</span><span class="p">:</span><span class="w"> </span><span class="s2">"production"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This is an illustrative <strong>key-policy fragment</strong>, not a complete production policy. A full design may also need account and service constraints, grant controls, organisation policies and protection against confused-deputy paths.</p>

<p>Encryption context improves integrity, audit evidence and purpose restriction. It does not create a new destruction boundary. One KMS key may still wrap many data keys carrying different contexts.</p>

<h2 id="key-policy-is-part-of-the-data-boundary">Key policy is part of the data boundary</h2>

<p>Every KMS key has one key policy, and AWS describes key policies as the primary mechanism for controlling access. IAM policies, grants and VPC endpoint policies may also participate.<sup id="fnref:kms-access"><a href="#fn:kms-access" class="footnote" rel="footnote" role="doc-noteref">12</a></sup></p>

<p>Separate key administration from ordinary data use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KEY ADMINISTRATOR

may:
    update key policy
    manage rotation
    disable key
    schedule deletion

does not automatically need:
    decrypt customer data


APPLICATION ROLE

may:
    generate data keys
    decrypt approved envelopes

must not:
    change key policy
    disable the key
    schedule deletion
</code></pre></div></div>

<p>A backup service may need another constrained path. The exact permissions depend on the service integration.</p>

<p>The ability to decide whether data may ever be decrypted again is more powerful than permission to decrypt it today. <code class="language-plaintext highlighter-rouge">kms:ScheduleKeyDeletion</code> should be rare, reviewed and monitored.</p>

<h2 id="what-does-not-establish-erasure">What does not establish erasure</h2>

<h3 id="deleting-an-alias">Deleting an alias</h3>

<p>Aliases are mutable names for keys. Removing an alias does not delete the underlying KMS key or its material.</p>

<p>Inventory and evidence should use the stable key ARN and key ID where exact identity matters.</p>

<h3 id="rotation">Rotation</h3>

<p>KMS rotation changes the key material used for new encryption operations. KMS retains the older material required to decrypt existing ciphertext.<sup id="fnref:kms-rotation"><a href="#fn:kms-rotation" class="footnote" rel="footnote" role="doc-noteref">13</a></sup></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BEFORE ROTATION

ciphertext A → material version 1


AFTER ROTATION

ciphertext A → still decryptable with version 1
ciphertext B → encrypted with version 2
</code></pre></div></div>

<p>Rotation does not re-encrypt existing application data, rotate stored data keys or erase old ciphertext.</p>

<h3 id="temporary-disablement">Temporary disablement</h3>

<p>Disabling a customer managed key makes KMS cryptographic operations fail, but it is reversible. It is useful for incident containment and dependency testing.</p>

<p>The impact on workloads may be delayed. Services or applications that already hold plaintext data keys can continue until they need KMS again.<sup id="fnref:unusable-keys"><a href="#fn:unusable-keys" class="footnote" rel="footnote" role="doc-noteref">14</a></sup></p>

<p>Disablement is therefore a rehearsal, not destruction.</p>

<h3 id="scheduling-deletion-without-waiting">Scheduling deletion without waiting</h3>

<p>AWS KMS requires a deletion waiting period of 7 to 30 days, with 30 days as the default. During <code class="language-plaintext highlighter-rouge">PendingDeletion</code>, the key cannot perform cryptographic operations, but deletion can still be cancelled. Once the waiting period completes, the KMS key, key material and metadata are irreversibly deleted.<sup id="fnref:kms-delete:1"><a href="#fn:kms-delete" class="footnote" rel="footnote" role="doc-noteref">7</a></sup></p>

<p>A rehearsal must run long enough to exercise actual key reuse, service caches and normal recovery paths.</p>

<h2 id="recovery-deliberately-complicates-erasure">Recovery deliberately complicates erasure</h2>

<p>A backup exists so that data survives loss of the primary copy. Cryptographic erasure exists so that data cannot survive loss of its required key. Those goals can conflict.</p>

<p>If production and backup use the same key:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY ── key A
BACKUP  ── key A
</code></pre></div></div>

<p>Deleting key A may remove both the workload and its recovery path.</p>

<p>If the backup copy uses an independent key:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRIMARY             ── production key
CROSS-ACCOUNT COPY  ── backup-account key
</code></pre></div></div>

<p>Deleting the production key does not erase the backup. That improves recovery isolation, but final erasure must also address the recovery point, destination key, retention schedule and any locked vault policy.</p>

<p>AWS Backup generally encrypts cross-account and cross-Region copies using the target vault’s KMS key, with resource-specific caveats.<sup id="fnref:backup-encryption"><a href="#fn:backup-encryption" class="footnote" rel="footnote" role="doc-noteref">15</a></sup></p>

<p>This is not a flaw. It is the expected result of separating recovery credentials from production credentials.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRODUCTION KEY COMPROMISED

preserve independent recovery


DATASET RETENTION EXPIRES

remove live data and every retained copy


CUSTOMER ERASURE REQUEST

handle that customer's fields under
the approved retention basis
</code></pre></div></div>

<p>One KMS-key deletion cannot safely represent all three events.</p>

<h2 id="service-behaviour-is-not-uniform">Service behaviour is not uniform</h2>

<p>The architectural principle is stable: a restore requires usable data and a usable key. The exact failure states are service-specific.</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Key-lifecycle consequence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Amazon RDS</td>
      <td>Encryption covers storage, logs, automated backups and snapshots. A stoppable encrypted DB instance with backups enabled can enter <code class="language-plaintext highlighter-rouge">inaccessible-encryption-credentials-recoverable</code> after RDS detects loss of key access, remain recoverable for seven days, then become terminally inaccessible if access is not restored.<sup id="fnref:rds-encryption:1"><a href="#fn:rds-encryption" class="footnote" rel="footnote" role="doc-noteref">3</a></sup></td>
    </tr>
    <tr>
      <td>Amazon S3</td>
      <td>New objects are encrypted by default. Changing bucket-default encryption does not automatically re-encrypt existing objects; existing versions may require a separate update or copy process.<sup id="fnref:s3-default:1"><a href="#fn:s3-default" class="footnote" rel="footnote" role="doc-noteref">2</a></sup><sup id="fnref:s3-existing"><a href="#fn:s3-existing" class="footnote" rel="footnote" role="doc-noteref">16</a></sup></td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td>Table data, indexes, streams and backups are encrypted. If a customer managed key remains inaccessible for more than seven days, the table can be archived, and restoration still depends on the relevant key.<sup id="fnref:dynamodb-encryption:1"><a href="#fn:dynamodb-encryption" class="footnote" rel="footnote" role="doc-noteref">4</a></sup><sup id="fnref:dynamodb-keys"><a href="#fn:dynamodb-keys" class="footnote" rel="footnote" role="doc-noteref">17</a></sup></td>
    </tr>
    <tr>
      <td>AWS Backup</td>
      <td>A copied recovery point may use the destination vault’s key. The source resource type determines some details, so the runbook must name the service and copy path, not only the KMS key.<sup id="fnref:backup-encryption:1"><a href="#fn:backup-encryption" class="footnote" rel="footnote" role="doc-noteref">15</a></sup></td>
    </tr>
  </tbody>
</table>

<p>A backup encrypted under a deleted key is not an escape hatch. Recovery needs a key path too.</p>

<h2 id="multi-region-and-imported-keys-widen-the-boundary">Multi-Region and imported keys widen the boundary</h2>

<p>Related KMS multi-Region keys share key material and can decrypt one another’s ciphertext across Regions. Deleting one relative does not establish erasure while another remains usable. AWS KMS keeps a primary key in <code class="language-plaintext highlighter-rouge">PendingReplicaDeletion</code> until all replicas have been deleted, after which the ordinary deletion waiting period begins.<sup id="fnref:multi-region"><a href="#fn:multi-region" class="footnote" rel="footnote" role="doc-noteref">18</a></sup></p>

<p>Inventory:</p>

<ul>
  <li>the primary;</li>
  <li>every replica;</li>
  <li>Region-specific policies and grants;</li>
  <li>disaster-recovery resources;</li>
  <li>replicated and backup data.</li>
</ul>

<p>Imported key material adds an external copy by design. AWS recommends retaining the material outside AWS so it can be reimported if the KMS copy expires or is deleted.<sup id="fnref:imported-material"><a href="#fn:imported-material" class="footnote" rel="footnote" role="doc-noteref">19</a></sup></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KMS COPY

deleted


EXTERNAL HSM COPY

still exists
</code></pre></div></div>

<p>That is temporary withdrawal, not permanent erasure. The erasure process must include external HSM backups, escrow and runbooks capable of reimporting the material.</p>

<p>Deleting imported material from a KMS key can be reversed by reimporting the same material. Deleting the KMS key after its waiting period is different and irreversible.<sup id="fnref:imported-delete"><a href="#fn:imported-delete" class="footnote" rel="footnote" role="doc-noteref">20</a></sup><sup id="fnref:kms-delete:2"><a href="#fn:kms-delete" class="footnote" rel="footnote" role="doc-noteref">7</a></sup></p>

<h2 id="plaintext-keys-and-values-can-outlive-a-kms-request">Plaintext keys and values can outlive a KMS request</h2>

<p>Envelope encryption puts plaintext data keys outside KMS while data is being encrypted or decrypted. They may exist in:</p>

<ul>
  <li>application memory;</li>
  <li>service caches;</li>
  <li>cryptographic-library state;</li>
  <li>hardware registers;</li>
  <li>crash dumps;</li>
  <li>diagnostic captures.</li>
</ul>

<p>NIST specifically requires assurance that unwrapped key copies in volatile memory or encryption engines are eliminated as part of a cryptographic-erase operation.<sup id="fnref:nist-ce:2"><a href="#fn:nist-ce" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<p>Application code should:</p>

<ul>
  <li>use reviewed encryption libraries;</li>
  <li>minimise plaintext-key lifetime;</li>
  <li>never log key material;</li>
  <li>avoid support workflows that collect unrestricted memory dumps;</li>
  <li>clear mutable buffers where the language and library make that meaningful;</li>
  <li>restart or drain workloads when a high-assurance erasure requires cached keys to disappear.</li>
</ul>

<p>Go does not offer a universal guarantee that overwriting one variable removes every compiler-created or runtime copy. That is another reason not to build custom cryptographic protocols in business code.</p>

<h2 id="kms-does-not-know-every-dependency">KMS does not know every dependency</h2>

<p>Before deleting a KMS key, AWS recommends determining its past usage. KMS does not maintain a list of all ciphertexts protected by the key, and local use of a previously generated data key is not visible as a new KMS operation.<sup id="fnref:kms-delete:3"><a href="#fn:kms-delete" class="footnote" rel="footnote" role="doc-noteref">7</a></sup><sup id="fnref:kms-usage"><a href="#fn:kms-usage" class="footnote" rel="footnote" role="doc-noteref">21</a></sup></p>

<p>Evidence must combine:</p>

<ul>
  <li>CloudTrail;</li>
  <li>infrastructure-as-code state;</li>
  <li>AWS Config or resource inventory;</li>
  <li>RDS, S3, DynamoDB and AWS Backup metadata;</li>
  <li>application envelope registries;</li>
  <li>grants and cross-account access;</li>
  <li>disaster-recovery inventories;</li>
  <li>retention and deletion records.</li>
</ul>

<p>A key record could contain:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">key</span><span class="pi">:</span>
  <span class="na">alias</span><span class="pi">:</span> <span class="s">alias/bfstore-prod-customer-data</span>
  <span class="na">arn</span><span class="pi">:</span> <span class="s">arn:aws:kms:eu-west-2:111122223333:key/example</span>
  <span class="na">region</span><span class="pi">:</span> <span class="s">eu-west-2</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">symmetric</span>
  <span class="na">regionality</span><span class="pi">:</span> <span class="s">single-region</span>
  <span class="na">material_origin</span><span class="pi">:</span> <span class="s">AWS_KMS</span>

<span class="na">ownership</span><span class="pi">:</span>
  <span class="na">data_owner</span><span class="pi">:</span> <span class="s">customer-service</span>
  <span class="na">key_administrator</span><span class="pi">:</span> <span class="s">security-platform</span>
  <span class="na">backup_owner</span><span class="pi">:</span> <span class="s">bfstore-backup</span>

<span class="na">data</span><span class="pi">:</span>
  <span class="na">classification</span><span class="pi">:</span> <span class="s">customer-personal-data</span>

<span class="na">resources</span><span class="pi">:</span>
  <span class="na">primary</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">rds-customer-prod</span>
    <span class="pi">-</span> <span class="s">customer-export-bucket</span>
  <span class="na">backups</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">customer-rds-snapshot-policy</span>
    <span class="pi">-</span> <span class="s">bfstore-backup-account-copy</span>

<span class="na">deletion</span><span class="pi">:</span>
  <span class="na">waiting_period_days</span><span class="pi">:</span> <span class="m">30</span>
  <span class="na">disable_rehearsal_required</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">restore_test_required</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">approvers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">data-owner</span>
    <span class="pi">-</span> <span class="s">security</span>
    <span class="pi">-</span> <span class="s">backup-owner</span>
</code></pre></div></div>

<p>No key should reach deletion with its purpose described as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>probably old
</code></pre></div></div>

<h2 id="key-deletion-needs-a-two-person-shaped-process">Key deletion needs a two-person-shaped process</h2>

<p>A safe production process might be:</p>

<ol>
  <li>The data owner confirms that the retention basis has ended.</li>
  <li>The resource owner inventories every dependent resource and application envelope.</li>
  <li>The backup owner confirms every recovery point and destination key.</li>
  <li>Security reviews policy, grant and usage evidence.</li>
  <li>The key is disabled.</li>
  <li>Workloads are observed through a meaningful rehearsal period.</li>
  <li>Restore tests confirm which recovery paths remain.</li>
  <li>A separate privileged role schedules deletion.</li>
  <li>Attempted use during the waiting period produces alarms.</li>
  <li>Unexpected dependencies trigger cancellation.</li>
  <li>Final deletion evidence is retained.</li>
</ol>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RETENTION APPROVAL
        │
        ▼
DEPENDENCY INVENTORY
        │
        ▼
DISABLE AND OBSERVE
        │
        ▼
RESTORE TEST
        │
        ▼
SCHEDULE DELETION
        │
        ▼
ALARMED WAITING PERIOD
        │
        ├── unexpected use → cancel
        └── no dependency  → continue
        │
        ▼
FINAL DELETION
        │
        ▼
ERASURE EVIDENCE
</code></pre></div></div>

<p>AWS records CloudTrail events when deletion is scheduled and when the key is deleted. Policy can also constrain the requested waiting period using <code class="language-plaintext highlighter-rouge">kms:ScheduleKeyDeletionPendingWindowInDays</code>.<sup id="fnref:kms-delete:4"><a href="#fn:kms-delete" class="footnote" rel="footnote" role="doc-noteref">7</a></sup><sup id="fnref:kms-condition"><a href="#fn:kms-condition" class="footnote" rel="footnote" role="doc-noteref">22</a></sup></p>

<p>The waiting period is the last recovery window before an irreversible action.</p>

<h2 id="customer-erasure-starts-with-a-data-map-not-a-kms-command">Customer erasure starts with a data map, not a KMS command</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CUSTOMER ID
    │
    ├── customer service
    ├── order service
    ├── payment references
    ├── shipping service
    ├── notification service
    ├── Kafka and search
    ├── logs and traces
    ├── analytics
    └── backups
</code></pre></div></div>

<p>Each representation then receives one of four decisions.</p>

<h3 id="delete-now">Delete now</h3>

<p>Examples:</p>

<ul>
  <li>live customer profile;</li>
  <li>saved addresses;</li>
  <li>marketing preferences;</li>
  <li>inactive sessions;</li>
  <li>unnecessary search projections.</li>
</ul>

<h3 id="transform">Transform</h3>

<p>An order may initially contain:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">order</span><span class="pi">:</span>
  <span class="na">order_id</span><span class="pi">:</span> <span class="s">order-8042</span>
  <span class="na">customer_id</span><span class="pi">:</span> <span class="s">customer-421</span>
  <span class="na">customer_email</span><span class="pi">:</span> <span class="s">mariam@example.com</span>
  <span class="na">shipping_name</span><span class="pi">:</span> <span class="s">Mariam Example</span>
  <span class="na">shipping_address</span><span class="pi">:</span> <span class="s">42 Example Road</span>
  <span class="na">items</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">product_id</span><span class="pi">:</span> <span class="s">product-11</span>
      <span class="na">quantity</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">total_pence</span><span class="pi">:</span> <span class="m">8900</span>
</code></pre></div></div>

<p>A later retained form might become:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">order</span><span class="pi">:</span>
  <span class="na">order_id</span><span class="pi">:</span> <span class="s">order-8042</span>
  <span class="na">customer_reference</span><span class="pi">:</span> <span class="s">deleted-customer</span>
  <span class="na">items</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">product_id</span><span class="pi">:</span> <span class="s">product-11</span>
      <span class="na">quantity</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">total_pence</span><span class="pi">:</span> <span class="m">8900</span>
  <span class="na">fulfilment_region</span><span class="pi">:</span> <span class="s">GB</span>
  <span class="na">personal_data_removed_at</span><span class="pi">:</span> <span class="s">2026-06-22</span>
</code></pre></div></div>

<p>Whether this is anonymous or merely pseudonymous depends on the remaining data and bfstore’s ability to reconnect it with other records. The architecture should not label data anonymous without assessing re-identification.</p>

<h3 id="retain-under-an-approved-basis">Retain under an approved basis</h3>

<p>Examples may include tax records, chargeback evidence, fraud investigation or legal claims. This is a legal and business decision, not one KMS can make.</p>

<h3 id="put-beyond-ordinary-use-until-backup-expiry">Put beyond ordinary use until backup expiry</h3>

<p>The live system no longer exposes the data. Backup procedures prevent it from being used for another purpose and allow it to expire under an established schedule. The ICO describes this “beyond use” approach for backup data that cannot be overwritten immediately, while stressing that the organisation must be clear about the process.<sup id="fnref:ico-erasure"><a href="#fn:ico-erasure" class="footnote" rel="footnote" role="doc-noteref">23</a></sup></p>

<p>Where a dedicated field or record envelope exists, bfstore can also destroy every wrapped data-key copy and remove the corresponding plaintext projections. That does not normally require deleting the shared service KMS key.</p>

<h2 id="restores-must-replay-erasure-decisions">Restores must replay erasure decisions</h2>

<p>Suppose a customer is erased on 22 June.</p>

<p>A backup from 15 June still contains the profile.</p>

<p>In August, an incident restores that backup.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>15 JUNE BACKUP
      │
      ▼
22 JUNE CUSTOMER ERASED
      │
      ▼
10 AUGUST RESTORE
      │
      ▼
CUSTOMER DATA REAPPEARS
</code></pre></div></div>

<p>The restore process needs an <strong>erasure ledger</strong>, or an equivalent durable reconciliation mechanism, containing deletion and retention changes made after the recovery point.</p>

<p>Before customer traffic resumes:</p>

<ol>
  <li>restore the backup;</li>
  <li>replay ordinary database events;</li>
  <li>replay the erasure ledger;</li>
  <li>rebuild search, cache and analytics projections;</li>
  <li>verify that removed data has not returned;</li>
  <li>record the reconciliation evidence.</li>
</ol>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RESTORE BACKUP
      │
      ▼
REPLAY APPLICATION CHANGES
      │
      ▼
REPLAY ERASURE LEDGER
      │
      ▼
REBUILD PROJECTIONS
      │
      ▼
VERIFY
      │
      ▼
RETURN TO SERVICE
</code></pre></div></div>

<p>A restore that resurrects data the business committed to removing is technically successful and operationally wrong.</p>

<p>Backup tests should therefore verify privacy and retention behaviour, not only whether MySQL starts.</p>

<h2 id="proposed-bfstore-approach">Proposed bfstore approach</h2>

<p>For bfstore, I would use the following model:</p>

<ul>
  <li>Use AWS-service encryption for production storage, logs, event infrastructure and backups.</li>
  <li>Use customer managed KMS keys where bfstore needs control over policy, audit, disablement or deletion.</li>
  <li>Separate keys by environment, service or data owner, classification and recovery boundary.</li>
  <li>Keep payment-account data outside bfstore through hosted collection and provider tokenisation.</li>
  <li>Apply application envelope encryption selectively to data with a clear access or erasure requirement.</li>
  <li>Keep encryption context non-sensitive and purpose-oriented.</li>
  <li>Give production backups independent keys where recovery isolation requires it, while documenting their separate erasure lifecycle.</li>
  <li>Treat key deletion as a production change requiring inventory, rehearsal, restore testing, multiple approvals and retained evidence.</li>
  <li>Make erasure-ledger replay a mandatory restore gate.</li>
</ul>

<p>The final mental model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DATA MINIMISATION
        │
        ▼
RETENTION DECISION
        │
        ▼
ACCESS AND ENCRYPTION BOUNDARY
        │
        ▼
RECOVERY COPIES AND KEYS
        │
        ▼
END-OF-LIFE ACTION
        │
        ├── delete
        ├── transform
        ├── retain under an approved basis
        └── cryptographically erase
        │
        ▼
VERIFIABLE EVIDENCE
</code></pre></div></div>

<p>A KMS key is not a padlock attached to one database row.</p>

<p>It is an authority, recovery and blast-radius boundary with cryptography inside it.</p>

<blockquote>
  <p><strong>The key hierarchy should reflect not only who may read data, but which data must live, recover and die together.</strong></p>
</blockquote>

<p>The ciphertext may remain.</p>

<p>The route home should not.</p>

<h2 id="references">References</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:nist-ce">
      <p>NIST, <a href="https://doi.org/10.6028/NIST.SP.800-88r2"><em>SP 800-88 Revision 2: Guidelines for Media Sanitization</em></a>, sections 3.2.2 to 3.2.5, September 2025. <a href="#fnref:nist-ce" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:nist-ce:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:nist-ce:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:s3-default">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html">Default encryption FAQ for Amazon S3</a>. <a href="#fnref:s3-default" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:s3-default:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:rds-encryption">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html">Encrypting Amazon RDS resources</a>. <a href="#fnref:rds-encryption" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:rds-encryption:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:dynamodb-encryption">
      <p>AWS, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/EncryptionAtRest.html">DynamoDB encryption at rest</a>. <a href="#fnref:dynamodb-encryption" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:dynamodb-encryption:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:ico-storage">
      <p>Information Commissioner’s Office, <a href="https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/storage-limitation/">Principle (e): Storage limitation</a>. <a href="#fnref:ico-storage" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:data-keys">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/data-keys.html">Generate data keys</a> and <a href="https://docs.aws.amazon.com/kms/latest/developerguide/kms-cryptography.html">AWS KMS cryptography essentials</a>. <a href="#fnref:data-keys" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kms-delete">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html">Delete an AWS KMS key</a>. <a href="#fnref:kms-delete" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:kms-delete:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:kms-delete:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:kms-delete:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:kms-delete:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:pci-retention">
      <p>PCI Security Standards Council, <a href="https://www.pcisecuritystandards.org/faq/articles/Frequently_Asked_Question/What-is-the-maximum-period-of-time-that-cardholder-data-can-be-stored/">What is the maximum period of time that cardholder data can be stored?</a>. <a href="#fnref:pci-retention" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pci-cvv">
      <p>PCI Security Standards Council, <a href="https://www.pcisecuritystandards.org/faqs/1574/">FAQ 1574: Card verification code retention</a>. <a href="#fnref:pci-cvv" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pci-scope">
      <p>PCI Security Standards Council, <a href="https://www.pcisecuritystandards.org/faqs/1086/">FAQ 1086: How does encrypted cardholder data impact PCI DSS scope?</a>, updated March 2026. <a href="#fnref:pci-scope" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:encryption-context">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/encrypt_context.html">Encryption context</a>. <a href="#fnref:encryption-context" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kms-access">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/security-iam.html">Identity and access management for AWS KMS</a> and <a href="https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html">Key policies in AWS KMS</a>. <a href="#fnref:kms-access" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kms-rotation">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html">Rotate AWS KMS keys</a>. <a href="#fnref:kms-rotation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:unusable-keys">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/unusable-kms-keys.html">How unusable KMS keys affect data keys</a>. <a href="#fnref:unusable-keys" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:backup-encryption">
      <p>AWS, <a href="https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html">Encryption for backups in AWS Backup</a>. <a href="#fnref:backup-encryption" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:backup-encryption:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:s3-existing">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-kms-encryption.html">Specifying server-side encryption with AWS KMS for existing S3 objects</a>. <a href="#fnref:s3-existing" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:dynamodb-keys">
      <p>AWS, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/encryption.howitworks.html">DynamoDB encryption at rest: how it works</a>. <a href="#fnref:dynamodb-keys" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:multi-region">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/multi-region-keys-overview.html">Multi-Region keys in AWS KMS</a>, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html">Delete an AWS KMS key</a> and <a href="https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html">KMS key states</a>. <a href="#fnref:multi-region" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:imported-material">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/importing-keys-considerations.html">Special considerations for imported key material</a>. <a href="#fnref:imported-material" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:imported-delete">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/importing-keys-delete-key-material.html">Delete imported key material</a>. <a href="#fnref:imported-delete" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kms-usage">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/monitoring-keys-determining-usage.html">Determine past usage of a KMS key</a>. <a href="#fnref:kms-usage" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kms-condition">
      <p>AWS, <a href="https://docs.aws.amazon.com/kms/latest/developerguide/conditions-kms.html">AWS KMS condition keys</a>. <a href="#fnref:kms-condition" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ico-erasure">
      <p>Information Commissioner’s Office, <a href="https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/individual-rights/right-to-erasure/">Right to erasure</a>. <a href="#fnref:ico-erasure" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[AWS KMS protects encryption keys, but erasure depends on key boundaries, data copies, recovery design and evidence that no usable route from ciphertext to plaintext remains.]]></summary></entry><entry><title type="html">Prepared statements and boring SQL safety</title><link href="https://cloud.doublewords.net/writing/2026/06/21/prepared-statements-and-boring-sql-safety/" rel="alternate" type="text/html" title="Prepared statements and boring SQL safety" /><published>2026-06-21T00:00:00+01:00</published><updated>2026-06-21T00:00:00+01:00</updated><id>https://cloud.doublewords.net/writing/2026/06/21/prepared-statements-and-boring-sql-safety</id><content type="html" xml:base="https://cloud.doublewords.net/writing/2026/06/21/prepared-statements-and-boring-sql-safety/"><![CDATA[<p>Suppose bfstore, the fictional organisation used throughout this series, is implementing its catalog repository in Go and MySQL.</p>

<p>The catalog service needs to search for products by name.</p>

<p>The first implementation looks pleasantly direct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FindProducts</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">search</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">query</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span>
		<span class="s">"SELECT id, name, price_pence FROM products WHERE name = '%s'"</span><span class="p">,</span>
		<span class="n">search</span><span class="p">,</span>
	<span class="p">)</span>

	<span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">query</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"query products: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">rows</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="k">return</span> <span class="n">scanProducts</span><span class="p">(</span><span class="n">rows</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For ordinary input:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gopher mug
</code></pre></div></div>

<p>the generated SQL becomes:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">price_pence</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'gopher mug'</span>
</code></pre></div></div>

<p>The code appears to work.</p>

<p>The danger is that <code class="language-plaintext highlighter-rouge">search</code> is not being treated as data. It is inserted into the SQL program before that program reaches the database.</p>

<p>A malicious or simply unexpected value can close the string literal and introduce new syntax:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gopher mug' OR '1' = '1
</code></pre></div></div>

<p>The database may receive:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">price_pence</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'gopher mug'</span> <span class="k">OR</span> <span class="s1">'1'</span> <span class="o">=</span> <span class="s1">'1'</span>
</code></pre></div></div>

<p>The input has changed the meaning of the query.</p>

<p>It did not merely supply a product name.</p>

<p>It became part of the program.</p>

<p>The safe version keeps the SQL structure fixed:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FindProducts</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">search</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">const</span> <span class="n">query</span> <span class="o">=</span> <span class="s">`
		SELECT id, name, price_pence
		FROM products
		WHERE name = ?
	`</span>

	<span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="n">search</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"query products: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">rows</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="k">return</span> <span class="n">scanProducts</span><span class="p">(</span><span class="n">rows</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now the application supplies two distinct things:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SQL STRUCTURE

SELECT id, name, price_pence
FROM products
WHERE name = ?


PARAMETER VALUE

gopher mug' OR '1' = '1
</code></pre></div></div>

<p>The database access layer treats the complete parameter as one value. The apostrophes and SQL keywords inside it do not gain authority to rewrite the statement.</p>

<p>The examples in this article use Go’s <code class="language-plaintext highlighter-rouge">database/sql</code> package with <code class="language-plaintext highlighter-rouge">go-sql-driver/mysql</code> and MySQL-style <code class="language-plaintext highlighter-rouge">?</code> placeholders. Placeholder syntax and some execution details differ between database drivers.</p>

<blockquote>
  <p><strong>Safe SQL begins by deciding the program before untrusted data enters the room.</strong></p>
</blockquote>

<p>That is a wonderfully boring property.</p>

<p>Database code benefits from as much of it as possible.</p>

<h2 id="sql-injection-is-a-boundary-failure">SQL injection is a boundary failure</h2>

<p>SQL is a programming language interpreted by the database.</p>

<p>The application sends an instruction such as:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>The product ID belongs to the data handled by that instruction.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SQL STRUCTURE

SELECT
FROM
WHERE
=
?


DATA

product ID
</code></pre></div></div>

<p>Injection becomes possible when the application combines those two categories into one undifferentiated string.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UNTRUSTED VALUE
      │
      ▼
STRING CONCATENATION
      │
      ▼
NEW SQL PROGRAM
      │
      ▼
DATABASE INTERPRETER
</code></pre></div></div>

<p>An untrusted value can arrive from:</p>

<ul>
  <li>an HTTP query parameter</li>
  <li>a gRPC request</li>
  <li>a message-broker event</li>
  <li>a CSV import</li>
  <li>a command-line tool</li>
  <li>another internal service</li>
  <li>an administration interface</li>
  <li>previously stored database content</li>
  <li>a compromised integration</li>
</ul>

<p>The source may be another bfstore service on a private network.</p>

<p>That does not make its contents safe to interpret as executable SQL.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INTERNAL SOURCE

does not equal

SAFE TO INTERPRET AS CODE
</code></pre></div></div>

<p>OWASP’s primary recommendation is to stop constructing dynamic SQL through string concatenation and to use parameter binding. The database API then retains a boundary between statement structure and data values.</p>

<p>The application should not try to recreate that guarantee through careful punctuation.</p>

<h2 id="parameterised-execution-is-not-one-implementation-mechanism">Parameterised execution is not one implementation mechanism</h2>

<p>The phrases <strong>parameterised query</strong> and <strong>prepared statement</strong> are often used as though they mean exactly the same thing.</p>

<p>They are related, but there are three distinct layers.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PARAMETERISED API

SQL text and values are supplied
through separate Go arguments


DRIVER EXECUTION STRATEGY

the driver may use the server's
prepared-statement protocol

or safely encode parameters
into one statement


REUSABLE sql.Stmt

the application explicitly prepares
a statement for repeated use
</code></pre></div></div>

<p>This call uses the parameter API:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">row</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryRowContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`SELECT id, status FROM orders WHERE id = ?`</span><span class="p">,</span>
	<span class="n">orderID</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The SQL text and <code class="language-plaintext highlighter-rouge">orderID</code> are supplied separately. That is the application-level safety property.</p>

<p>With the default behaviour of <code class="language-plaintext highlighter-rouge">go-sql-driver/mysql</code>, an argument-bearing query normally uses prepare, execute and close operations. If the connection string enables <code class="language-plaintext highlighter-rouge">interpolateParams=true</code>, the driver instead safely encodes the values into one SQL statement before sending it, reducing network round trips.</p>

<p>The application should not depend on one wire-level strategy for its injection defence.</p>

<p>The durable rule is:</p>

<blockquote>
  <p><strong>Every variable value must travel through the driver’s parameter API.</strong></p>
</blockquote>

<p>Whether the driver preserves that boundary through MySQL’s prepared-statement protocol or safe driver-side encoding is an execution detail that must be understood separately.</p>

<h3 id="reusable-statements-are-a-lifecycle-decision">Reusable statements are a lifecycle decision</h3>

<p>An explicit reusable statement looks like:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">stmt</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">PrepareContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`SELECT id, status FROM orders WHERE id = ?`</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
	<span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"prepare order lookup: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">defer</span> <span class="n">stmt</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>
</code></pre></div></div>

<p>Then later:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">row</span> <span class="o">:=</span> <span class="n">stmt</span><span class="o">.</span><span class="n">QueryRowContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">orderID</span><span class="p">)</span>
</code></pre></div></div>

<p>A statement prepared on <code class="language-plaintext highlighter-rouge">sql.DB</code> is safe for concurrent use. <code class="language-plaintext highlighter-rouge">database/sql</code> manages it across the connection pool and prepares it on additional underlying connections when necessary.</p>

<p>A statement prepared on <code class="language-plaintext highlighter-rouge">sql.Tx</code> or <code class="language-plaintext highlighter-rouge">sql.Conn</code> is bound to that transaction or reserved connection.</p>

<table>
  <thead>
    <tr>
      <th>API</th>
      <th>Behaviour</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DB.QueryContext</code> with arguments</td>
      <td>Safe parameter API; the driver chooses the execution strategy</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DB.PrepareContext</code></td>
      <td>Reusable through the connection pool</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Tx.PrepareContext</code></td>
      <td>Bound to the transaction’s connection</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Conn.PrepareContext</code></td>
      <td>Bound to the reserved connection</td>
    </tr>
  </tbody>
</table>

<p>A reusable <code class="language-plaintext highlighter-rouge">sql.Stmt</code> is normally prepared during repository or application initialisation and closed during shutdown. Preparing and closing the same statement inside every request may add lifecycle complexity without meaningful reuse.</p>

<p>Explicit preparation can reduce repeated parsing overhead for frequently executed operations.</p>

<p>Performance is a useful passenger.</p>

<p>It is not the reason parameter binding is mandatory.</p>

<h2 id="the-placeholder-must-not-be-quoted">The placeholder must not be quoted</h2>

<p>A placeholder represents a complete value.</p>

<p>For MySQL, the usual marker is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>?
</code></pre></div></div>

<p>This is correct:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>This is not:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'?'</span>
</code></pre></div></div>

<p>In the second statement, the question mark is inside a SQL string literal. It represents the literal character <code class="language-plaintext highlighter-rouge">?</code>, not a parameter position.</p>

<p>The driver is responsible for sending the actual value with the appropriate type and representation:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">query</span> <span class="o">=</span> <span class="s">`
	SELECT id, name
	FROM products
	WHERE name = ?
`</span>

<span class="n">row</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryRowContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="n">productName</span><span class="p">)</span>
</code></pre></div></div>

<p>The same mechanism handles ordinary values of several types:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">ExecContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`
		INSERT INTO products (
			id,
			name,
			price_pence,
			active,
			created_at
		)
		VALUES (?, ?, ?, ?, ?)
	`</span><span class="p">,</span>
	<span class="n">product</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
	<span class="n">product</span><span class="o">.</span><span class="n">Name</span><span class="p">,</span>
	<span class="n">product</span><span class="o">.</span><span class="n">PricePence</span><span class="p">,</span>
	<span class="n">product</span><span class="o">.</span><span class="n">Active</span><span class="p">,</span>
	<span class="n">product</span><span class="o">.</span><span class="n">CreatedAt</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The values remain values whether they contain apostrophes, Unicode, line breaks, punctuation or convincing-looking SQL.</p>

<p>The application should validate whether those values make sense for the domain.</p>

<p>It should not manually convert them into SQL literals.</p>

<h2 id="escaping-is-not-the-everyday-defence">Escaping is not the everyday defence</h2>

<p>An alternative approach tries to make string construction safe by escaping input:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">safeName</span> <span class="o">:=</span> <span class="n">escapeSQL</span><span class="p">(</span><span class="n">productName</span><span class="p">)</span>

<span class="n">query</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span>
	<span class="s">"SELECT id FROM products WHERE name = '%s'"</span><span class="p">,</span>
	<span class="n">safeName</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Escaping remains fragile because correctness depends on the dialect, character set, SQL mode, literal context and driver behaviour. The application also has to choose the right escaping rule at every call site.</p>

<p>Parameter binding removes that responsibility for values.</p>

<p>A useful hierarchy is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. FIXED SQL STRUCTURE

2. PARAMETER BINDING FOR VALUES

3. CONTROLLED APPLICATION CHOICES
   WHERE STRUCTURE MUST VARY

4. CONTEXTUAL ESCAPING ONLY
   WHEN A SAFE PARAMETER API
   TRULY CANNOT BE USED
</code></pre></div></div>

<p>The first two should handle nearly all bfstore runtime queries.</p>

<p>Escaping belongs in specialised compatibility code and carefully reviewed infrastructure libraries. It should not be the normal application programming model.</p>

<h2 id="parameters-represent-values-not-sql-grammar">Parameters represent values, not SQL grammar</h2>

<p>A parameter can stand where SQL expects a data value.</p>

<p>It cannot normally stand in for:</p>

<ul>
  <li>table names</li>
  <li>column names</li>
  <li>SQL keywords</li>
  <li>operators</li>
  <li>sort direction</li>
</ul>

<p>This does not mean what its author hopes:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">price_pence</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="o">?</span> <span class="o">?</span>
</code></pre></div></div>

<p>with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>price_pence

DESC
</code></pre></div></div>

<p>MySQL parameter markers are used where values belong, not where identifiers or keywords belong.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VALUE

'pink gopher mug'
8042
true
2026-06-21


STRUCTURE

products
price_pence
DESC
&gt;=
JOIN
</code></pre></div></div>

<p>Parameter binding protects values.</p>

<p>Structural variation must be designed in application code.</p>

<p>This is where an otherwise parameterised query can quietly reopen the injection boundary.</p>

<h2 id="controlled-structural-variation">Controlled structural variation</h2>

<p>Suppose the catalog API supports:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sort=name

sort=price

sort=newest
</code></pre></div></div>

<p>The unsafe version inserts the supplied value directly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">query</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">`
	SELECT id, name, price_pence
	FROM products
	ORDER BY %s
`</span><span class="p">,</span> <span class="n">requestedSort</span><span class="p">)</span>
</code></pre></div></div>

<p>A safer design maps public choices to fixed SQL fragments.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ProductSort</span> <span class="kt">string</span>

<span class="k">const</span> <span class="p">(</span>
	<span class="n">ProductSortName</span>       <span class="n">ProductSort</span> <span class="o">=</span> <span class="s">"name"</span>
	<span class="n">ProductSortPrice</span>      <span class="n">ProductSort</span> <span class="o">=</span> <span class="s">"price"</span>
	<span class="n">ProductSortNewest</span>     <span class="n">ProductSort</span> <span class="o">=</span> <span class="s">"newest"</span>
	<span class="n">ProductSortPopularity</span> <span class="n">ProductSort</span> <span class="o">=</span> <span class="s">"popularity"</span>
<span class="p">)</span>

<span class="k">func</span> <span class="n">productOrderBy</span><span class="p">(</span><span class="n">sort</span> <span class="n">ProductSort</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">switch</span> <span class="n">sort</span> <span class="p">{</span>
	<span class="k">case</span> <span class="n">ProductSortName</span><span class="o">:</span>
		<span class="k">return</span> <span class="s">"name ASC, id ASC"</span><span class="p">,</span> <span class="no">nil</span>
	<span class="k">case</span> <span class="n">ProductSortPrice</span><span class="o">:</span>
		<span class="k">return</span> <span class="s">"price_pence ASC, id ASC"</span><span class="p">,</span> <span class="no">nil</span>
	<span class="k">case</span> <span class="n">ProductSortNewest</span><span class="o">:</span>
		<span class="k">return</span> <span class="s">"created_at DESC, id DESC"</span><span class="p">,</span> <span class="no">nil</span>
	<span class="k">case</span> <span class="n">ProductSortPopularity</span><span class="o">:</span>
		<span class="k">return</span> <span class="s">"purchase_count DESC, id ASC"</span><span class="p">,</span> <span class="no">nil</span>
	<span class="k">default</span><span class="o">:</span>
		<span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"unsupported product sort %q"</span><span class="p">,</span> <span class="n">sort</span><span class="p">)</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The query then combines only the fixed fragment returned by controlled code:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">orderBy</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">productOrderBy</span><span class="p">(</span><span class="n">requestedSort</span><span class="p">)</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
	<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
<span class="p">}</span>

<span class="n">query</span> <span class="o">:=</span> <span class="s">`
	SELECT id, name, price_pence
	FROM products
	WHERE active = ?
	ORDER BY `</span> <span class="o">+</span> <span class="n">orderBy</span> <span class="o">+</span> <span class="s">`
	LIMIT ?
`</span>

<span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="no">true</span><span class="p">,</span> <span class="n">limit</span><span class="p">)</span>
</code></pre></div></div>

<p>A string is still being combined into the SQL.</p>

<p>The difference is its origin.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UNSAFE

request value
    → SQL fragment


CONTROLLED

request value
    → enum decision
    → fixed code-owned fragment
</code></pre></div></div>

<p>The same rule applies to sort direction. Map the public choice to the fixed fragments <code class="language-plaintext highlighter-rouge">ASC</code> or <code class="language-plaintext highlighter-rouge">DESC</code>; never append the request value itself.</p>

<p>The caller may choose between behaviours the application already owns. It may not supply arbitrary SQL after <code class="language-plaintext highlighter-rouge">ORDER BY</code>.</p>

<h3 id="dynamic-identifiers-deserve-suspicion">Dynamic identifiers deserve suspicion</h3>

<p>Suppose an administration report accepts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>environment=production
</code></pre></div></div>

<p>and builds:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">query</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span>
	<span class="s">"SELECT count(*) FROM %s_orders"</span><span class="p">,</span>
	<span class="n">environment</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This is unsafe if the request controls the identifier.</p>

<p>It may also indicate that the schema has encoded a business dimension through table names.</p>

<p>A more durable model may be:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">orders</span>
<span class="k">WHERE</span> <span class="n">environment</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>The variable has moved from SQL structure into data, where it can be parameterised naturally.</p>

<p>When dynamic identifiers are genuinely required, map a closed application enum to fixed, code-owned identifier fragments. Then review why the schema needs that variation.</p>

<p>A placeholder’s refusal to become a table name is often useful design feedback.</p>

<h2 id="variable-shape-queries-still-bind-every-value">Variable-shape queries still bind every value</h2>

<p>A basket request may need several product records:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bf-product-101
bf-product-204
bf-product-811
</code></pre></div></div>

<p>A single placeholder does not become a list.</p>

<p>This is incorrect:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="k">IN</span> <span class="p">(</span><span class="o">?</span><span class="p">)</span>
</code></pre></div></div>

<p>with one comma-separated string argument. The database sees one value.</p>

<p>The safe approach creates one placeholder for each value:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">placeholders</span><span class="p">(</span><span class="n">count</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">count</span> <span class="o">&lt;=</span> <span class="m">0</span> <span class="p">{</span>
		<span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"placeholder count must be positive"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimSuffix</span><span class="p">(</span>
		<span class="n">strings</span><span class="o">.</span><span class="n">Repeat</span><span class="p">(</span><span class="s">"?,"</span><span class="p">,</span> <span class="n">count</span><span class="p">),</span>
		<span class="s">","</span><span class="p">,</span>
	<span class="p">),</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The repository builds only punctuation and placeholder positions:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">maxProductIDsPerQuery</span> <span class="o">=</span> <span class="m">500</span>

<span class="k">func</span> <span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">ProductRepository</span><span class="p">)</span> <span class="n">FindByIDs</span><span class="p">(</span>
	<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
	<span class="n">ids</span> <span class="p">[]</span><span class="n">ProductID</span><span class="p">,</span>
<span class="p">)</span> <span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
		<span class="k">return</span> <span class="p">[]</span><span class="n">Product</span><span class="p">{},</span> <span class="no">nil</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maxProductIDsPerQuery</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span>
			<span class="s">"too many product IDs: maximum is %d"</span><span class="p">,</span>
			<span class="n">maxProductIDsPerQuery</span><span class="p">,</span>
		<span class="p">)</span>
	<span class="p">}</span>

	<span class="n">markers</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">placeholders</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">))</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="n">query</span> <span class="o">:=</span> <span class="s">`
		SELECT id, name, price_pence
		FROM products
		WHERE id IN (`</span> <span class="o">+</span> <span class="n">markers</span> <span class="o">+</span> <span class="s">`)
	`</span>

	<span class="n">args</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">any</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">))</span>
	<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">ids</span> <span class="p">{</span>
		<span class="n">args</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">id</span><span class="o">.</span><span class="n">String</span><span class="p">())</span>
	<span class="p">}</span>

	<span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="n">args</span><span class="o">...</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"query products by IDs: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">rows</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="k">return</span> <span class="n">scanProducts</span><span class="p">(</span><span class="n">rows</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The generated structure may contain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>?,?,?
</code></pre></div></div>

<p>The actual IDs remain argument values.</p>

<p>The empty-list case is handled explicitly rather than generating invalid SQL such as:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WHERE</span> <span class="n">id</span> <span class="k">IN</span> <span class="p">()</span>
</code></pre></div></div>

<h3 id="bulk-inserts-use-the-same-principle">Bulk inserts use the same principle</h3>

<p>A bulk insert varies the number of value groups:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">buildProductInsert</span><span class="p">(</span><span class="n">products</span> <span class="p">[]</span><span class="n">Product</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="p">[]</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">products</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
		<span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="no">nil</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"no products supplied"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">const</span> <span class="n">maxProductsPerInsert</span> <span class="o">=</span> <span class="m">500</span>
	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">products</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maxProductsPerInsert</span> <span class="p">{</span>
		<span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span>
			<span class="s">"too many products: maximum is %d"</span><span class="p">,</span>
			<span class="n">maxProductsPerInsert</span><span class="p">,</span>
		<span class="p">)</span>
	<span class="p">}</span>

	<span class="k">var</span> <span class="n">query</span> <span class="n">strings</span><span class="o">.</span><span class="n">Builder</span>

	<span class="n">query</span><span class="o">.</span><span class="n">WriteString</span><span class="p">(</span><span class="s">`
		INSERT INTO products (
			id,
			name,
			price_pence
		)
		VALUES
	`</span><span class="p">)</span>

	<span class="n">args</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">any</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">products</span><span class="p">)</span><span class="o">*</span><span class="m">3</span><span class="p">)</span>

	<span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">product</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">products</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
			<span class="n">query</span><span class="o">.</span><span class="n">WriteString</span><span class="p">(</span><span class="s">","</span><span class="p">)</span>
		<span class="p">}</span>

		<span class="n">query</span><span class="o">.</span><span class="n">WriteString</span><span class="p">(</span><span class="s">"(?, ?, ?)"</span><span class="p">)</span>

		<span class="n">args</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span>
			<span class="n">args</span><span class="p">,</span>
			<span class="n">product</span><span class="o">.</span><span class="n">ID</span><span class="o">.</span><span class="n">String</span><span class="p">(),</span>
			<span class="n">product</span><span class="o">.</span><span class="n">Name</span><span class="p">,</span>
			<span class="n">product</span><span class="o">.</span><span class="n">PricePence</span><span class="p">,</span>
		<span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">query</span><span class="o">.</span><span class="n">String</span><span class="p">(),</span> <span class="n">args</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The program generates commas, parentheses and placeholder positions.</p>

<p>It does not embed product data into SQL text.</p>

<p>Batch limits solve a separate problem:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PLACEHOLDER GENERATION

prevents values becoming syntax


BATCH LIMITS

prevent safe syntax becoming
an unsafe amount of work
</code></pre></div></div>

<p>Large legitimate workloads should be split into controlled batches or use a purpose-designed ingestion mechanism. This protects statement size, memory allocation, parse time and packet limits.</p>

<h2 id="like-patterns-are-data-with-operator-semantics"><code class="language-plaintext highlighter-rouge">LIKE</code> patterns are data with operator semantics</h2>

<p>Suppose the catalog performs substring search:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pattern</span> <span class="o">:=</span> <span class="s">"%"</span> <span class="o">+</span> <span class="n">search</span> <span class="o">+</span> <span class="s">"%"</span>

<span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`
		SELECT id, name
		FROM products
		WHERE name LIKE ?
	`</span><span class="p">,</span>
	<span class="n">pattern</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This is injection-safe.</p>

<p>A search value containing an apostrophe cannot rewrite the query.</p>

<p>However, <code class="language-plaintext highlighter-rouge">LIKE</code> assigns special meaning to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>%

_
</code></pre></div></div>

<p>A user searching for:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>100%
</code></pre></div></div>

<p>may unintentionally request every string beginning with <code class="language-plaintext highlighter-rouge">100</code>.</p>

<p>That is not SQL injection. The value has remained data.</p>

<p>It is a search-semantics problem.</p>

<p>If <code class="language-plaintext highlighter-rouge">%</code> and <code class="language-plaintext highlighter-rouge">_</code> should be literal, define an escape character that does not depend on MySQL’s backslash string rules:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">escapeLike</span><span class="p">(</span><span class="n">value</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
	<span class="n">replacer</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">NewReplacer</span><span class="p">(</span>
		<span class="s">"!"</span><span class="p">,</span> <span class="s">"!!"</span><span class="p">,</span>
		<span class="s">"%"</span><span class="p">,</span> <span class="s">"!%"</span><span class="p">,</span>
		<span class="s">"_"</span><span class="p">,</span> <span class="s">"!_"</span><span class="p">,</span>
	<span class="p">)</span>

	<span class="k">return</span> <span class="n">replacer</span><span class="o">.</span><span class="n">Replace</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The query declares that escape character:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="k">LIKE</span> <span class="o">?</span> <span class="k">ESCAPE</span> <span class="s1">'!'</span>
</code></pre></div></div>

<p>The final pattern remains a parameter:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pattern</span> <span class="o">:=</span> <span class="s">"%"</span> <span class="o">+</span> <span class="n">escapeLike</span><span class="p">(</span><span class="n">search</span><span class="p">)</span> <span class="o">+</span> <span class="s">"%"</span>
</code></pre></div></div>

<p>Keep the distinction clear:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SQL INJECTION SAFETY

Can the value alter SQL structure?


SEARCH SEMANTICS

What does the value mean
inside this operator?
</code></pre></div></div>

<p>Parameterisation solves the first.</p>

<p>The application still defines the second.</p>

<h2 id="validation-and-authorisation-answer-different-questions">Validation and authorisation answer different questions</h2>

<p>Suppose an API accepts a page size:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">rows</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`
		SELECT id, name
		FROM products
		ORDER BY id
		LIMIT ?
	`</span><span class="p">,</span>
	<span class="n">limit</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>MySQL supports parameter markers in <code class="language-plaintext highlighter-rouge">LIMIT</code> positions.</p>

<p>The value still needs validation:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">validatePageSize</span><span class="p">(</span><span class="n">size</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">size</span> <span class="o">&lt;</span> <span class="m">1</span> <span class="o">||</span> <span class="n">size</span> <span class="o">&gt;</span> <span class="m">100</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"page size must be between 1 and 100"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Parameterisation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Can this value become SQL syntax?
</code></pre></div></div>

<p>Validation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is this value permitted
by the application contract?
</code></pre></div></div>

<p>A parameterised limit of <code class="language-plaintext highlighter-rouge">100000000</code> is not injection. It may still create an expensive query or denial-of-service opportunity.</p>

<p>Authorisation is another boundary.</p>

<p>This query is safely parameterised:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">,</span> <span class="n">status</span><span class="p">,</span> <span class="n">total_pence</span>
<span class="k">FROM</span> <span class="n">orders</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>A caller can still supply another customer’s valid order ID.</p>

<p>The ownership rule should appear in the operation:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">,</span> <span class="n">status</span><span class="p">,</span> <span class="n">total_pence</span>
<span class="k">FROM</span> <span class="n">orders</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
  <span class="k">AND</span> <span class="n">customer_id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">row</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryRowContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="n">query</span><span class="p">,</span>
	<span class="n">orderID</span><span class="p">,</span>
	<span class="n">authenticatedCustomerID</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The two questions remain separate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INJECTION

Can orderID rewrite the SQL?


AUTHORISATION

May this customer read
the selected order?
</code></pre></div></div>

<p>A perfectly parameterised query can still implement an insecure direct object reference.</p>

<p>For bfstore, repository signatures should make the business scope visible:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">FindCustomerOrder</span><span class="p">(</span>
	<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
	<span class="n">customerID</span> <span class="n">CustomerID</span><span class="p">,</span>
	<span class="n">orderID</span> <span class="n">OrderID</span><span class="p">,</span>
<span class="p">)</span> <span class="p">(</span><span class="n">Order</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p>rather than asking every caller to remember a separate ownership check.</p>

<h2 id="the-repository-owns-the-sql-contract">The repository owns the SQL contract</h2>

<p>A repository should expose domain operations rather than arbitrary SQL construction:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ProductRepository</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">FindByID</span><span class="p">(</span>
		<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
		<span class="n">id</span> <span class="n">ProductID</span><span class="p">,</span>
	<span class="p">)</span> <span class="p">(</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>

	<span class="n">FindActive</span><span class="p">(</span>
		<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
		<span class="n">filter</span> <span class="n">ProductFilter</span><span class="p">,</span>
	<span class="p">)</span> <span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>

	<span class="n">Create</span><span class="p">(</span>
		<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
		<span class="n">product</span> <span class="n">Product</span><span class="p">,</span>
	<span class="p">)</span> <span class="kt">error</span>

	<span class="n">UpdatePrice</span><span class="p">(</span>
		<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
		<span class="n">id</span> <span class="n">ProductID</span><span class="p">,</span>
		<span class="n">price</span> <span class="n">Money</span><span class="p">,</span>
	<span class="p">)</span> <span class="kt">error</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The filter uses controlled application types:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">ProductFilter</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Query</span>     <span class="kt">string</span>
	<span class="n">Category</span>  <span class="o">*</span><span class="n">CategoryID</span>
	<span class="n">Sort</span>      <span class="n">ProductSort</span>
	<span class="n">PageSize</span>  <span class="kt">int</span>
	<span class="n">PageAfter</span> <span class="o">*</span><span class="n">ProductID</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The boundary is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CALLER OWNS

product ID
search value
category
requested sort choice
page size


REPOSITORY OWNS

table names
column names
SQL operators
join structure
sort expressions
parameter order
projections
transaction use
</code></pre></div></div>

<p>That gives identifier control, validation, pagination, authorisation and query shape one well-lit home.</p>

<p>It also provides a practical route towards database least privilege.</p>

<h2 id="least-privilege-should-match-the-repository">Least privilege should match the repository</h2>

<p>Assume an injection vulnerability survives somewhere.</p>

<p>Its impact depends partly on what the runtime database identity can do.</p>

<p>A catalog read path should not connect as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root
</code></pre></div></div>

<p>The runtime account should not ordinarily be able to:</p>

<ul>
  <li>create users</li>
  <li>grant privileges</li>
  <li>drop schemas</li>
  <li>modify migration history</li>
  <li>access another service’s database</li>
  <li>read administrative tables</li>
  <li>write server-side files</li>
</ul>

<p>A bfstore service should use distinct identities for distinct jobs:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BOOTSTRAP ACCOUNT

creates schemas and initial accounts
short-lived operational use


MIGRATOR ACCOUNT

applies approved schema migrations
DDL permissions where required


RUNTIME ACCOUNT

only the DML operations
required by the service


READ-ONLY ACCOUNT

SELECT on approved reporting objects
</code></pre></div></div>

<p>A complete illustrative MySQL role flow is:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">ROLE</span> <span class="s1">'catalog_runtime'</span><span class="p">;</span>

<span class="k">GRANT</span>
	<span class="k">SELECT</span><span class="p">,</span>
	<span class="k">INSERT</span><span class="p">,</span>
	<span class="k">UPDATE</span><span class="p">,</span>
	<span class="k">DELETE</span>
<span class="k">ON</span> <span class="n">bfstore_catalog</span><span class="p">.</span><span class="o">*</span>
<span class="k">TO</span> <span class="s1">'catalog_runtime'</span><span class="p">;</span>

<span class="k">GRANT</span> <span class="s1">'catalog_runtime'</span>
<span class="k">TO</span> <span class="s1">'catalog_app'</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>

<span class="k">SET</span> <span class="k">DEFAULT</span> <span class="k">ROLE</span> <span class="s1">'catalog_runtime'</span>
<span class="k">TO</span> <span class="s1">'catalog_app'</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
</code></pre></div></div>

<p>A real deployment should narrow the allowed host and privileges further.</p>

<p>Creating a role does not automatically make it active for an application account. The role must be granted and made active through a default role, session activation or another deliberate configuration.</p>

<p>The repository and database role should tell the same story:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>REPOSITORY METHODS
        │
        ▼
REQUIRED SQL OPERATIONS
        │
        ▼
DATABASE ROLE
</code></pre></div></div>

<p>A mismatch is useful evidence.</p>

<p>If the runtime role requires schema-creation privileges, migration work may be using the wrong identity.</p>

<p>If the catalog role requires order-table access, a service boundary may be leaking.</p>

<h3 id="one-service-should-not-query-another-services-tables">One service should not query another service’s tables</h3>

<p>The catalog service owns catalog data.</p>

<p>The order service owns orders.</p>

<p>The payment service owns payment state.</p>

<p>A convenient cross-schema join can introduce:</p>

<ul>
  <li>broader credentials</li>
  <li>coupled migrations</li>
  <li>unclear ownership</li>
  <li>hidden runtime dependencies</li>
  <li>greater injection impact</li>
</ul>

<p>A safer architecture may use:</p>

<ul>
  <li>a service API</li>
  <li>a domain event</li>
  <li>a reporting projection</li>
  <li>an analytics pipeline</li>
  <li>an explicitly owned read model</li>
</ul>

<p>Parameter binding protects the syntax boundary.</p>

<p>Service-owned databases protect the ownership boundary.</p>

<h2 id="transactions-preserve-integrity-not-query-safety">Transactions preserve integrity, not query safety</h2>

<p>Creating an order may require:</p>

<ol>
  <li>inserting the order</li>
  <li>inserting its order lines</li>
  <li>recording an outbox event</li>
</ol>

<p>Those changes should succeed or fail together:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">OrderRepository</span><span class="p">)</span> <span class="n">Create</span><span class="p">(</span>
	<span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span>
	<span class="n">order</span> <span class="n">Order</span><span class="p">,</span>
	<span class="n">event</span> <span class="n">OutboxEvent</span><span class="p">,</span>
<span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">tx</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">r</span><span class="o">.</span><span class="n">db</span><span class="o">.</span><span class="n">BeginTx</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="no">nil</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"begin order transaction: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">tx</span><span class="o">.</span><span class="n">Rollback</span><span class="p">()</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">insertOrder</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">tx</span><span class="p">,</span> <span class="n">order</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">insertOrderLines</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">tx</span><span class="p">,</span> <span class="n">order</span><span class="o">.</span><span class="n">Lines</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">insertOutboxEvent</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">tx</span><span class="p">,</span> <span class="n">event</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">tx</span><span class="o">.</span><span class="n">Commit</span><span class="p">();</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"commit order transaction: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each statement must still bind its values:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">tx</span><span class="o">.</span><span class="n">ExecContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="s">`
		INSERT INTO orders (
			id,
			customer_id,
			status,
			total_pence
		)
		VALUES (?, ?, ?, ?)
	`</span><span class="p">,</span>
	<span class="n">order</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
	<span class="n">order</span><span class="o">.</span><span class="n">CustomerID</span><span class="p">,</span>
	<span class="n">order</span><span class="o">.</span><span class="n">Status</span><span class="p">,</span>
	<span class="n">order</span><span class="o">.</span><span class="n">TotalPence</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>A transaction answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Should these changes become visible
as one atomic unit?
</code></pre></div></div>

<p>Parameterisation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Can these values alter
the SQL program?
</code></pre></div></div>

<p>The context supplied to <code class="language-plaintext highlighter-rouge">BeginTx</code> remains associated with the transaction until commit or rollback. If that context is cancelled, <code class="language-plaintext highlighter-rouge">database/sql</code> rolls the transaction back and <code class="language-plaintext highlighter-rouge">Commit</code> returns an error.</p>

<p>Keep all transactional operations on <code class="language-plaintext highlighter-rouge">sql.Tx</code>. A call made directly through <code class="language-plaintext highlighter-rouge">sql.DB</code> may use another connection and execute outside the transaction.</p>

<h2 id="context-limits-work-but-cancellation-has-a-cost">Context limits work, but cancellation has a cost</h2>

<p>A parameterised query can still wait on:</p>

<ul>
  <li>a lock</li>
  <li>an overloaded database</li>
  <li>a poor query plan</li>
  <li>a large result set</li>
  <li>a failed network path</li>
</ul>

<p>Repository operations should accept <code class="language-plaintext highlighter-rouge">context.Context</code> and use context-aware methods:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QueryContext
QueryRowContext
ExecContext
PrepareContext
BeginTx
</code></pre></div></div>

<p>A caller can establish an operation-specific budget:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">findProductTimeout</span> <span class="o">=</span> <span class="m">750</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span>

<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">findProductTimeout</span><span class="p">)</span>
<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

<span class="n">product</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">repository</span><span class="o">.</span><span class="n">FindByID</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">productID</span><span class="p">)</span>
</code></pre></div></div>

<p>The duration is illustrative. A real deadline should reflect the service budget, expected database latency, retry policy and connection-pool behaviour.</p>

<p>With <code class="language-plaintext highlighter-rouge">go-sql-driver/mysql</code>, cancellation or timeout before the result is received causes the underlying connection to be closed. The pool may then need to create a replacement.</p>

<p>Cancellation is therefore not free bookkeeping.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PARAMETERS

protect query structure


CONTEXT

limits how long work
may occupy resources


QUERY DESIGN

limits how much work
the database must perform


POOL SETTINGS

limit how many database
connections may be occupied
</code></pre></div></div>

<p>An arbitrarily tiny timeout copied into every repository method can turn latency into connection churn.</p>

<h2 id="result-handling-and-projections-should-be-explicit">Result handling and projections should be explicit</h2>

<p>A multi-row query should:</p>

<ul>
  <li>check the query error</li>
  <li>close the returned rows</li>
  <li>scan every expected column</li>
  <li>check <code class="language-plaintext highlighter-rouge">rows.Err()</code> after iteration</li>
</ul>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">scanProducts</span><span class="p">(</span><span class="n">rows</span> <span class="o">*</span><span class="n">sql</span><span class="o">.</span><span class="n">Rows</span><span class="p">)</span> <span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">products</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">Product</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span>

	<span class="k">for</span> <span class="n">rows</span><span class="o">.</span><span class="n">Next</span><span class="p">()</span> <span class="p">{</span>
		<span class="k">var</span> <span class="n">product</span> <span class="n">Product</span>

		<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">rows</span><span class="o">.</span><span class="n">Scan</span><span class="p">(</span>
			<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
			<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">Name</span><span class="p">,</span>
			<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">PricePence</span><span class="p">,</span>
		<span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"scan product: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
		<span class="p">}</span>

		<span class="n">products</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">products</span><span class="p">,</span> <span class="n">product</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">rows</span><span class="o">.</span><span class="n">Err</span><span class="p">();</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"iterate products: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">products</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A single-row lookup reports its query and scan errors through <code class="language-plaintext highlighter-rouge">Scan</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">err</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryRowContext</span><span class="p">(</span>
	<span class="n">ctx</span><span class="p">,</span>
	<span class="n">query</span><span class="p">,</span>
	<span class="n">productID</span><span class="p">,</span>
<span class="p">)</span><span class="o">.</span><span class="n">Scan</span><span class="p">(</span>
	<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
	<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">Name</span><span class="p">,</span>
	<span class="o">&amp;</span><span class="n">product</span><span class="o">.</span><span class="n">PricePence</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Translate <code class="language-plaintext highlighter-rouge">sql.ErrNoRows</code> into a domain result rather than exposing a storage detail:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">Is</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="n">sql</span><span class="o">.</span><span class="n">ErrNoRows</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">return</span> <span class="n">Product</span><span class="p">{},</span> <span class="n">ErrProductNotFound</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Explicit projections also matter.</p>

<p>This is injection-safe:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>It is still weaker than:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
	<span class="n">id</span><span class="p">,</span>
	<span class="n">name</span><span class="p">,</span>
	<span class="n">description</span><span class="p">,</span>
	<span class="n">price_pence</span><span class="p">,</span>
	<span class="n">active</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p>The explicit list shows which data the operation needs and prevents a future sensitive column from entering the result merely because it was added to the table.</p>

<p>Parameterisation protects the parser.</p>

<p>Explicit projections protect the data contract.</p>

<h2 id="stored-procedures-orms-and-builders-keep-the-same-boundary">Stored procedures, ORMs and builders keep the same boundary</h2>

<p>A stored procedure can expose a parameterised interface:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CALL</span> <span class="n">get_customer_order</span><span class="p">(</span><span class="o">?</span><span class="p">,</span> <span class="o">?</span><span class="p">)</span>
</code></pre></div></div>

<p>That is safe only if the procedure itself uses fixed or safely parameterised SQL.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>APPLICATION

passes value safely
      │
      ▼
STORED PROCEDURE

concatenates it into SQL
      │
      ▼
INJECTION RETURNS
</code></pre></div></div>

<p>The same principle applies to:</p>

<ul>
  <li>ORM query languages</li>
  <li>query builders</li>
  <li>database functions</li>
  <li>reporting tools</li>
  <li>migration frameworks</li>
</ul>

<p>An ORM may provide a safe expression API:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">query</span><span class="o">.</span><span class="n">Where</span><span class="p">(</span><span class="s">"customer_id = ?"</span><span class="p">,</span> <span class="n">customerID</span><span class="p">)</span>
</code></pre></div></div>

<p>and an unsafe structural escape hatch:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">query</span><span class="o">.</span><span class="n">Raw</span><span class="p">(</span><span class="n">userSuppliedSQL</span><span class="p">)</span>
</code></pre></div></div>

<p>A builder may bind values automatically while accepting an uncontrolled identifier:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="o">.</span><span class="n">OrderBy</span><span class="p">(</span><span class="n">requestedSort</span><span class="p">)</span>
</code></pre></div></div>

<p>The review questions remain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where did this SQL fragment originate?

Does this API bind a value
or insert a string?

Can the caller choose identifiers?

Does a raw-query path exist?

Does the generated SQL preserve
the same authorisation scope?
</code></pre></div></div>

<p>The rule should not be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ORM equals safe.
</code></pre></div></div>

<p>It should be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Every value is bound.

Every structural choice
comes from controlled code.
</code></pre></div></div>

<h2 id="query-logging-should-not-expose-parameter-values">Query logging should not expose parameter values</h2>

<p>Operational logs should identify the database operation without recording:</p>

<ul>
  <li>passwords</li>
  <li>access tokens</li>
  <li>payment data</li>
  <li>personal information</li>
  <li>complete order contents</li>
  <li>private customer attributes</li>
</ul>

<p>This is risky:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span>
	<span class="s">"executing database operation"</span><span class="p">,</span>
	<span class="s">"query"</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span>
	<span class="s">"args"</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The SQL may be harmless to record in some environments, but <code class="language-plaintext highlighter-rouge">args</code> can contain the sensitive material.</p>

<p>A custom debug helper that reconstructs the statement is worse:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span>
	<span class="s">"executing SQL"</span><span class="p">,</span>
	<span class="s">"rendered_query"</span><span class="p">,</span> <span class="n">interpolateForDebug</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">args</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>It recreates a giant string containing both program and data, and may misrepresent the actual driver encoding.</p>

<p>Prefer stable operation evidence:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span>
	<span class="s">"database operation"</span><span class="p">,</span>
	<span class="s">"operation"</span><span class="p">,</span> <span class="s">"catalog.find_products"</span><span class="p">,</span>
	<span class="s">"product_count"</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">),</span>
	<span class="s">"duration_ms"</span><span class="p">,</span> <span class="n">elapsed</span><span class="o">.</span><span class="n">Milliseconds</span><span class="p">(),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>For failures:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logger</span><span class="o">.</span><span class="n">Error</span><span class="p">(</span>
	<span class="s">"database operation failed"</span><span class="p">,</span>
	<span class="s">"operation"</span><span class="p">,</span> <span class="s">"orders.create"</span><span class="p">,</span>
	<span class="s">"error"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Useful evidence can include:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>operation name
duration
row count
error class
trace ID
database instance
transaction outcome
</code></pre></div></div>

<p>Complete parameter values should not enter ordinary logs by default.</p>

<p>Security sometimes means preventing the database from interpreting data as code.</p>

<p>It also means preventing the logging system from keeping that data as a souvenir.</p>

<h2 id="tests-should-prove-that-values-remain-values">Tests should prove that values remain values</h2>

<p>A repository test should not attempt to catalogue every historical payload.</p>

<p>It should prove the architectural property:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Values travel through parameters
and remain literal values.
</code></pre></div></div>

<p>Useful fixtures include:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>O'Reilly gopher mug

"quoted" product

100% cotton

underscore_product

'; DROP TABLE products; --

gopher' OR '1'='1
</code></pre></div></div>

<p>A complete integration test should arrange its own data:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestRepositoryFindByNameTreatsInputAsValue</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">t</span><span class="o">.</span><span class="n">Parallel</span><span class="p">()</span>

	<span class="n">ctx</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">()</span>
	<span class="n">hostileName</span> <span class="o">:=</span> <span class="s">"gopher' OR '1'='1"</span>
	<span class="n">controlName</span> <span class="o">:=</span> <span class="s">"ordinary gopher"</span>

	<span class="n">hostile</span> <span class="o">:=</span> <span class="n">Product</span><span class="p">{</span>
		<span class="n">ID</span><span class="o">:</span>   <span class="n">NewProductID</span><span class="p">(),</span>
		<span class="n">Name</span><span class="o">:</span> <span class="n">hostileName</span><span class="p">,</span>
	<span class="p">}</span>

	<span class="n">control</span> <span class="o">:=</span> <span class="n">Product</span><span class="p">{</span>
		<span class="n">ID</span><span class="o">:</span>   <span class="n">NewProductID</span><span class="p">(),</span>
		<span class="n">Name</span><span class="o">:</span> <span class="n">controlName</span><span class="p">,</span>
	<span class="p">}</span>

	<span class="n">require</span><span class="o">.</span><span class="n">NoError</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">repository</span><span class="o">.</span><span class="n">Create</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">hostile</span><span class="p">))</span>
	<span class="n">require</span><span class="o">.</span><span class="n">NoError</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">repository</span><span class="o">.</span><span class="n">Create</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">control</span><span class="p">))</span>

	<span class="n">t</span><span class="o">.</span><span class="n">Cleanup</span><span class="p">(</span><span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="n">_</span> <span class="o">=</span> <span class="n">repository</span><span class="o">.</span><span class="n">Delete</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">hostile</span><span class="o">.</span><span class="n">ID</span><span class="p">)</span>
		<span class="n">_</span> <span class="o">=</span> <span class="n">repository</span><span class="o">.</span><span class="n">Delete</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">control</span><span class="o">.</span><span class="n">ID</span><span class="p">)</span>
	<span class="p">})</span>

	<span class="n">products</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">repository</span><span class="o">.</span><span class="n">FindByName</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">hostileName</span><span class="p">)</span>
	<span class="n">require</span><span class="o">.</span><span class="n">NoError</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>

	<span class="n">require</span><span class="o">.</span><span class="n">Len</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">products</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
	<span class="n">assert</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">hostile</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span> <span class="n">products</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">ID</span><span class="p">)</span>
	<span class="n">assert</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">hostileName</span><span class="p">,</span> <span class="n">products</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Name</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test should prove that the exact value survives, unrelated rows are not returned and authorisation remains intact.</p>

<p>Multi-statement execution is only one possible injection outcome. The common Go MySQL driver disables multiple statements by default, but unsafe input can still alter one statement’s predicate, projection, join or operation.</p>

<p>A mock can verify that SQL and arguments were supplied separately. An integration test proves how the driver and MySQL behave together.</p>

<p>Static review searches also help:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fmt.Sprintf("SELECT

fmt.Sprintf("INSERT

fmt.Sprintf("UPDATE

fmt.Sprintf("DELETE

+ request.Sort

+ userInput

.Raw(
</code></pre></div></div>

<p>A text search is not a complete security scanner.</p>

<p>It is an excellent detector of database code beginning to develop theatrical punctuation.</p>

<h2 id="the-review-table-i-use">The review table I use</h2>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Question</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Structure</td>
      <td>Is SQL grammar controlled by application code?</td>
    </tr>
    <tr>
      <td>Values</td>
      <td>Does every variable value travel through the driver’s parameter API?</td>
    </tr>
    <tr>
      <td>Identifiers</td>
      <td>Do table, column and sort choices come from a closed mapping?</td>
    </tr>
    <tr>
      <td>Variable shape</td>
      <td>Are placeholder lists generated without embedding values and with a maximum size?</td>
    </tr>
    <tr>
      <td>Semantics</td>
      <td>Are <code class="language-plaintext highlighter-rouge">LIKE</code>, ranges and pagination rules defined separately from injection safety?</td>
    </tr>
    <tr>
      <td>Validation</td>
      <td>Are domain and workload limits enforced?</td>
    </tr>
    <tr>
      <td>Authorisation</td>
      <td>Does the operation constrain data by actor, tenant or ownership?</td>
    </tr>
    <tr>
      <td>Privilege</td>
      <td>Can the runtime identity perform only the required operations?</td>
    </tr>
    <tr>
      <td>Ownership</td>
      <td>Does the service access only its own schema and approved projections?</td>
    </tr>
    <tr>
      <td>Transactions</td>
      <td>Do related changes use one <code class="language-plaintext highlighter-rouge">sql.Tx</code> and one context?</td>
    </tr>
    <tr>
      <td>Cancellation</td>
      <td>Do deadlines reflect real service budgets and driver behaviour?</td>
    </tr>
    <tr>
      <td>Results</td>
      <td>Are rows closed, scans checked and iteration errors handled?</td>
    </tr>
    <tr>
      <td>Logging</td>
      <td>Are parameter values excluded from ordinary operational logs?</td>
    </tr>
    <tr>
      <td>Testing</td>
      <td>Do integration tests round-trip hostile-looking literal values?</td>
    </tr>
    <tr>
      <td>Abstractions</td>
      <td>Do procedures, ORMs and builders preserve the same boundary?</td>
    </tr>
  </tbody>
</table>

<p>This turns SQL safety into a repeatable engineering habit rather than a code-review mood.</p>

<h2 id="the-mental-model-i-am-keeping">The mental model I am keeping</h2>

<p>My earlier model was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SQL SAFETY

escape apostrophes

validate the form

hope the query remains intact
</code></pre></div></div>

<p>The stronger model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                       DATABASE OPERATION
                                │
                ┌───────────────┴───────────────┐
                │                               │
                ▼                               ▼
           SQL STRUCTURE                    DATA VALUES
                │                               │
                ▼                               ▼
        CONTROLLED APPLICATION             PARAMETERS
               CODE                             │
                │                               │
        ┌───────┼────────┐                      │
        │       │        │                      │
        ▼       ▼        ▼                      ▼
      TABLE   COLUMN   OPERATOR          DRIVER PARAMETER API
      NAMES   NAMES    AND ORDER                │
        │       │        │                      │
        └───────┼────────┘                      │
                │                               │
                └───────────────┬───────────────┘
                                ▼
                         DATABASE ENGINE
                                │
          ┌─────────────────────┼─────────────────────┐
          │                     │                     │
          ▼                     ▼                     ▼
    AUTHORISATION        LEAST PRIVILEGE        TRANSACTION
          │                     │                     │
          └─────────────────────┼─────────────────────┘
                                ▼
                     BORING DATABASE SAFETY
</code></pre></div></div>

<p>The parameter API answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Can this value alter
the SQL structure?
</code></pre></div></div>

<p>Controlled identifiers answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which application-owned
query structures may vary?
</code></pre></div></div>

<p>Validation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is this value acceptable
to the domain and workload budget?
</code></pre></div></div>

<p>Authorisation answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>May this actor access
the selected data?
</code></pre></div></div>

<p>Least privilege answers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What is the greatest damage
this database identity can cause?
</code></pre></div></div>

<p>Transactions answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which changes must succeed
or fail together?
</code></pre></div></div>

<p>Contexts answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How long may the operation
occupy resources?
</code></pre></div></div>

<p>Explicit projections answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which data did this operation
intend to retrieve?
</code></pre></div></div>

<p>And tests answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Do hostile-looking values
remain ordinary values?
</code></pre></div></div>

<p>Prepared statements are not a complete database security programme. They implement one essential boundary:</p>

<blockquote>
  <p><strong>The caller may supply data. The caller may not edit the program that interprets it.</strong></p>
</blockquote>

<p>A product name may contain an apostrophe.</p>

<p>A search may contain a percentage sign.</p>

<p>A customer may paste something that resembles a <code class="language-plaintext highlighter-rouge">DROP TABLE</code> statement.</p>

<p>The database should receive all of it with the emotional response of a filing cabinet.</p>

<p>Dynamic identifiers come from controlled code. Authorisation remains visible, runtime accounts stay narrow, transactions preserve integrity, deadlines limit abandoned work and tests prove that strange values remain harmlessly strange.</p>

<p>This is not glamorous security.</p>

<p>Nobody unveils a conference keynote called <em>We Passed the String as an Argument</em>.</p>

<p>That is precisely why I like it.</p>

<p>The safest query is rarely the cleverest one.</p>

<p>It is the one whose structure was decided before untrusted data entered the room.</p>

<h2 id="references-and-further-reading">References and further reading</h2>

<ul>
  <li><a href="https://go.dev/doc/database/sql-injection">Go: Avoiding SQL injection risk</a></li>
  <li><a href="https://go.dev/doc/database/prepared-statements">Go: Using prepared statements</a></li>
  <li><a href="https://pkg.go.dev/database/sql">Go <code class="language-plaintext highlighter-rouge">database/sql</code> package</a></li>
  <li><a href="https://go.dev/doc/database/querying">Go: Querying for data</a></li>
  <li><a href="https://go.dev/doc/database/change-data">Go: Executing database changes</a></li>
  <li><a href="https://go.dev/doc/database/execute-transactions">Go: Executing transactions</a></li>
  <li><a href="https://go.dev/doc/database/cancel-operations">Go: Cancelling database operations</a></li>
  <li><a href="https://github.com/go-sql-driver/mysql"><code class="language-plaintext highlighter-rouge">go-sql-driver/mysql</code> documentation</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/sql-prepared-statements.html">MySQL 8.4: Prepared statements</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/prepare.html">MySQL 8.4: <code class="language-plaintext highlighter-rouge">PREPARE</code> statement</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/string-comparison-functions.html">MySQL 8.4: String comparison functions</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/roles.html">MySQL 8.4: Using roles</a></li>
  <li><a href="https://dev.mysql.com/doc/refman/8.4/en/access-control.html">MySQL 8.4: Access control and account management</a></li>
  <li><a href="https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html">OWASP SQL Injection Prevention Cheat Sheet</a></li>
  <li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Database_Security_Cheat_Sheet.html">OWASP Database Security Cheat Sheet</a></li>
</ul>]]></content><author><name>Miriam Yvonne Antrobus</name><email>mantrobus@doublewords.net</email></author><summary type="html"><![CDATA[Prepared statements prevent SQL values from becoming executable query structure, but safe database access also requires controlled identifiers, explicit authorisation, least-privilege accounts, transactions, timeouts and careful handling of dynamic queries.]]></summary></entry></feed>