0% read
    ← All stories
    Cloud Networking · Airport Thriller

    Clearance Denied: how a request actually gets through Front Door, APIM and the AKS load balancer

    Every HTTPS request to your platform is a passenger with no passport, no luggage tag and a very short connection time. Here is the terminal it walks through — and what kubectl apply quietly builds on the apron behind it.

    14 August 2026 · 16 min read · Nilesh Mohite
    Azure Front Door API Management AKS Kubernetes Private Link .NET

    At 03:12 UTC a request checks in for a flight it will never remember taking. It is a POST /v1/orders from a mobile app in Pune, roughly 900 bytes of JSON, carrying a bearer token it did not read and a User-Agent string it cannot vouch for. It believes it is going straight to your API. It is not. It is about to walk the full length of an international terminal, and at every desk somebody is going to ask it a question it might not survive.

    Most architecture diagrams draw this journey as three friendly boxes and two arrows. That drawing is a lie of omission. Between the client and the pod there are at least five distinct trust boundaries, three different load-balancing algorithms, two TLS terminations, one firewall that runs before routing is even decided, and a Layer 4 appliance that you never explicitly created and probably cannot find in the portal.

    So let's walk it properly. Terminal door to aircraft door. One passenger, one bad afternoon.

    ◆ ◆ ◆
    01 / THE MANIFEST

    The terminal, from above

    Before the walkthrough — the map on the wall by the entrance.

    Here is the shape of the building. Read it once now; the rest of the article is a slow walk through it.

       CLIENT (Pune)
            │  DNS → anycast IP
            ▼
    ┌────────────────────────────────────────────────────────────────────┐
    │  AZURE FRONT DOOR  — global, ~200 PoPs, anycast + split TCP        │
    │                                                                    │
    │   1. TLS terminated at the nearest PoP                             │
    │   2. Host header → which profile is this?                          │
    │   3. WAF evaluated  ← happens BEFORE routing and caching           │
    │   4. Route match  → 5. Rule set  → 6. Cache lookup                 │
    │   7. Origin selection (health probe + latency/priority/weight)     │
    └────────────────────────────────────────────────────────────────────┘
            │  Microsoft backbone (not the public internet)
            │  Private Link — the origin has no public IP at all
            ▼
    ┌────────────────────────────────────────────────────────────────────┐
    │  API MANAGEMENT  — regional, the boarding-pass desk                │
    │                                                                    │
    │   inbound  → check-header X-Azure-FDID                             │
    │              validate-jwt                                          │
    │              rate-limit-by-key / quota-by-key                      │
    │              validate-content (schema)                             │
    │   backend  → forward-request, retry, circuit break                 │
    │   outbound → strip internal headers, shape the response            │
    │   on-error → the only lane that runs when something explodes       │
    └────────────────────────────────────────────────────────────────────┘
            │  VNet — private IP, no internet leg
            ▼
    ┌────────────────────────────────────────────────────────────────────┐
    │  AKS — the apron                                                   │
    │                                                                    │
    │   Azure internal Load Balancer (Standard SKU)                      │
    │     frontend IP   10.240.0.25                                      │
    │     backend pool  = node NICs / node IPs                           │
    │     LB rule       :443 → :30412   +  health probe                  │
    │        │                                                           │
    │        ▼                                                           │
    │   node → kube-proxy (iptables/IPVS/eBPF) → ingress controller      │
    │        → Service ClusterIP → POD                                   │
    └────────────────────────────────────────────────────────────────────┘
    

    Five trust boundaries. Only one of them is drawn in most slide decks.

    DNS PoP / TLS WAF Route Private Link APIM policy Internal LB kube-proxy Pod
    ◆ ◆ ◆
    02 / THE TERMINAL DOOR

    Front Door doesn't route first. It opens first.

    Anycast, split TCP, and why your latency drops before you optimise anything.

    The first thing that happens is not routing. It is geography.

    Your custom domain resolves — through a CNAME chain that ends at Front Door — to an anycast address. Anycast means the same IP is announced from every Front Door point of presence on the planet simultaneously, and internet routing hands the client to whichever PoP is topologically nearest. Our Pune request never crosses an ocean to say hello. It shakes hands with a building in India.

    That handshake matters more than people expect. TLS negotiation is the expensive part of a short HTTPS request — multiple round trips before a single byte of your JSON moves. Front Door terminates TLS at the PoP, then reuses warm, already-established connections over the Microsoft backbone to your origin. This is "split TCP": the slow, lossy, high-RTT leg is made as short as physically possible, and the long leg runs on a network with no congestion drama.

    The single largest latency win in most Azure architectures is not a code change. It is moving the TLS handshake 1,500 km closer to the user.

    Only once the connection exists does Front Door look at the Host header to work out whose Front Door profile this request belongs to. This is why an unregistered custom domain fails in a confusing way: the PoP answered the door, but nobody in the building recognises the name on the envelope.

    The order of operations, which almost everyone gets wrong

    Front Door's processing order is documented and it is not the order most people assume:

    #StepWhy the order matters
    1PoP selection (anycast)Client never chooses; BGP does.
    2TLS terminationCert must be for the custom domain, not the origin.
    3Host header → profile matchDomain must be registered and validated.
    4WAF evaluationRuns before routing and before cache. A blocked request never costs you an origin call or a cache slot.
    5Route matchDomain + path pattern → origin group.
    6Rule set / rules engineCan override the origin group, rewrite, or redirect.
    7Cache lookupA hit returns here. Origin never hears about it.
    8Origin selectionHealth probe status → then priority → then weight → then latency band.
    9Forward to originOver the backbone, or over Private Link.
    The practical consequence Because WAF sits at step 4, a volumetric attack is absorbed at the edge, in the attacker's own region, and is billed as a WAF request rather than as origin compute, APIM capacity units and pod CPU. Every checkpoint you push later in the chain is a checkpoint you pay for in four currencies instead of one.
    ◆ ◆ ◆
    03 / CHECKPOINT ONE

    The WAF: the officer who doesn't care who you are

    Pattern matching, not identity. Fast, dumb, and exactly where it should be.

    The first officer our request meets does not ask for a boarding pass. It does not know what a tenant is. It looks at shape: the URI, the query string, the headers, the body, the source IP's reputation, and the rate at which this client has been arriving.

    On Front Door Premium you get the managed rule sets — the Microsoft Default Rule Set (an OWASP Core-Rule-Set derivative) and the Bot Manager rule set — plus custom rules you write yourself. The mental model that keeps people out of trouble:

    And the rule that saves careers: run in Detection mode first. Every managed rule set will block something legitimate in your estate. You want to discover which one on a Tuesday afternoon in a log query, not at 03:12 UTC in an incident bridge.

    waf.bicep — a policy that starts in Detection and means it
    resource waf 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2024-02-01' = {
      name: 'wafOrdersApi'
      location: 'Global'
      sku: { name: 'Premium_AzureFrontDoor' }
      properties: {
        policySettings: {
          enabledState: 'Enabled'
          mode: 'Detection'          // (1) flip to 'Prevention' only after a clean week
          requestBodyCheck: 'Enabled'
        }
        managedRules: {
          managedRuleSets: [
            { ruleSetType: 'Microsoft_DefaultRuleSet', ruleSetVersion: '2.1' }
            { ruleSetType: 'Microsoft_BotManagerRuleSet', ruleSetVersion: '1.1' }
          ]
        }
        customRules: {
          rules: [
            {
              name: 'ThrottleOrders'
              priority: 100                // (2) lower number = evaluated first
              ruleType: 'RateLimitRule'
              rateLimitDurationInMinutes: 1
              rateLimitThreshold: 300
              action: 'Block'
              matchConditions: [
                {
                  matchVariable: 'RequestUri'
                  operator: 'Contains'
                  matchValue: [ '/v1/orders' ]
                }
              ]
            }
          ]
        }
      }
    }

    Two things worth internalising. First, WAF rate limiting is per PoP — it is a blunt instrument for stopping floods, not a precise instrument for enforcing a customer's contractual quota. That job belongs one checkpoint later, in APIM, where identity actually exists. Second, if you are behind Front Door, the client IP the WAF sees is the real one; the IP your pod sees will not be, and we will come back to that in section 07.

    ◆ ◆ ◆
    04 / CHECKPOINT TWO

    API Management: the desk that asks for your name

    Four sections, one pipeline, and the only place that knows who you are.

    The WAF asked what does this look like. APIM asks who are you, what are you entitled to, and have you had too much already today. It is the first component in the chain with a concept of identity, product, subscription and quota.

    Everything APIM does to a request happens in a four-stage pipeline, and understanding the stages is most of understanding the product:

      request ──▶ inbound ──▶ backend ──▶ [ your API in AKS ]
                     │           │                    │
                     │           └──── response ◀──────┘
                     │                 │
                     │                 ▼
                     │            outbound ──▶ client
                     │
                     └── throw ──▶ on-error ──▶ client
                         (remaining steps in the current
                          section are skipped entirely)
    

    on-error is not a catch-all wrapper. It is a separate lane the request is shunted into.

    Policies are inherited down five scopes — global → workspace → product → API → operation — and the <base /> element is where the parent's policies get spliced in. Put <base /> first in each section unless you have a specific reason not to; the day you forget, an API-scope policy silently deletes your global security posture for that one API and nothing turns red.

    The inbound section, written like you mean it

    policies.xml — API scope, orders API
    <policies>
      <inbound>
        <base />
    
        <!-- (1) Did you come through the front door, or over the fence? -->
        <check-header name="X-Azure-FDID"
                      failed-check-httpcode="403"
                      failed-check-error-message="Invalid request."
                      ignore-case="false">
          <value>{{FrontDoorId}}</value>
        </check-header>
    
        <!-- (2) Identity, verified against the issuer's own keys -->
        <validate-jwt header-name="Authorization"
                      failed-validation-httpcode="401"
                      failed-validation-error-message="Unauthorized">
          <openid-config url="https://login.microsoftonline.com/{{tenantId}}/v2.0/.well-known/openid-configuration" />
          <audiences><audience>api://orders</audience></audiences>
          <required-claims>
            <claim name="roles" match="any"><value>Orders.Write</value></claim>
          </required-claims>
        </validate-jwt>
    
        <!-- (3) Quota per *caller*, not per PoP. This is the real contract. -->
        <rate-limit-by-key calls="120" renewal-period="60"
            counter-key="@(context.Subscription?.Id ?? context.Request.IpAddress)" />
        <quota-by-key calls="100000" renewal-period="86400"
            counter-key="@(context.Subscription.Id)" />
    
        <!-- (4) Reject malformed bodies here, not in your domain layer -->
        <validate-content unspecified-content-type-action="prevent"
                          max-size="102400" size-exceeded-action="prevent"
                          errors-variable-name="bodyErrors">
          <content type="application/json" validate-as="json" action="prevent" />
        </validate-content>
    
        <!-- (5) Give the pod a thread to pull on -->
        <set-header name="X-Correlation-Id" exists-action="skip">
          <value>@(context.RequestId.ToString())</value>
        </set-header>
      </inbound>
    
      <backend><base /></backend>
    
      <outbound>
        <base />
        <!-- (6) The pod's opinions are not the client's business -->
        <set-header name="X-Powered-By" exists-action="delete" />
        <set-header name="Server" exists-action="delete" />
      </outbound>
    
      <on-error>
        <base />
        <set-body>@{ return new JObject(
            new JProperty("error","upstream_failure"),
            new JProperty("correlationId", context.RequestId)).ToString(); }</set-body>
      </on-error>
    </policies>

    Line (1) is the one people skip, and it is the one that matters most. X-Azure-FDID is a GUID unique to your Front Door profile, injected by Front Door itself. Without that check, anyone who discovers your APIM hostname can bypass the WAF entirely by talking to APIM directly — the entire edge tier becomes decorative. Microsoft's own guidance is to do both this and an IP restriction using the AzureFrontDoor.Backend service tag.

    A security control you can walk around is not a security control. It is a suggestion with a monthly bill.
    Tier reality check Networking capability in APIM is tier-shaped, and the shapes changed with the v2 tiers. VNet injection (external or internal mode, covering gateway, portal and management plane) lives in Developer and classic Premium; Premium v2 has injection too, but scoped to the gateway. Standard v2 and Premium v2 offer outbound VNet integration. Inbound private endpoints are available across the tiers including v2. Pick the tier from the network topology you need, not from the request-per-second number on the pricing page.
    ◆ ◆ ◆
    05 / THE JETWAY

    Nobody walks across the tarmac

    Private Link, and deleting the public runway entirely.

    Here is the failure mode that survives most security reviews: the chain is perfect, the WAF is in Prevention, the JWT validation is airtight — and the AKS ingress still has a public IP with a permissive NSG, because that is how it was during the spike three sprints ago.

    The fix is to make the origin unreachable from the internet by construction rather than by policy. Front Door Premium supports Private Link origins, and the supported origin list includes internal load balancers — explicitly naming AKS — as well as App Service, Blob Storage, Application Gateway, Container Apps and API Management.

    The flow is deliberately two-sided, and that is the point:

    1. You enable Private Link on the origin in your Front Door profile.
    2. Front Door creates a private endpoint on your behalf, from its own managed regional network.
    3. A pending connection request appears on your resource. Nothing flows until you approve it — portal, CLI or PowerShell.
    4. A few minutes later the tunnel is live. Traffic now goes client → global PoP → Microsoft backbone → Front Door's regional cluster → private endpoint → your origin.

    Two constraints to plan around. Front Door Private Link is only offered from regions with Availability Zone support; if your origin sits elsewhere, traffic hops in from the nearest supported region and you pay for that in latency. And approval is a human-in-the-loop step, which means it belongs in your runbook, not in your Bicep deployment's happy path.

    When Private Link is not on the table, the belt-and-braces version is a layered lockdown:

    LayerControlWhat it stops
    APIM NSGInbound allow from service tag AzureFrontDoor.Backend on 443; deny InternetAnyone who found the hostname
    APIM policycheck-header X-Azure-FDIDAnyone inside another tenant's Front Door
    APIM infraAllow 168.63.129.16 and 169.254.169.254Nothing — but omitting them breaks the platform
    AKS Serviceazure-load-balancer-internal: "true"The public IP existing at all
    AKS network policyDefault-deny ingress, allow from ingress namespaceEast-west movement after a pod compromise
    ◆ ◆ ◆
    06 / AIRSIDE

    What kubectl apply actually builds in your resource group

    Twelve lines of YAML. Four Azure resources. Zero portal clicks.

    This is the part of the chain that surprises people, because it is the part where a Kubernetes object silently becomes an Azure object.

    You write this:

    ingress-svc.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: ingress-nginx-controller
      namespace: ingress-nginx
      annotations:
        service.beta.kubernetes.io/azure-load-balancer-internal: "true"
        service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "apps-subnet"
        service.beta.kubernetes.io/azure-load-balancer-ipv4: 10.240.0.25
    spec:
      type: LoadBalancer
      externalTrafficPolicy: Local
      ports:
        - port: 443
          targetPort: 8443
      selector:
        app: ingress-nginx

    And this happens, without you asking:

     kubectl apply
          │
          ▼
     kube-apiserver  ──▶ Service object created, status.loadBalancer = {} (pending)
          │
          ▼
     cloud-controller-manager  (the Azure cloud provider, running in the cluster)
          │
          ├─▶ finds or creates the Standard SKU Azure Load Balancer
          │      kubernetes-internal   (in the MC_* node resource group)
          │
          ├─▶ frontend IP configuration
          │      private IP 10.240.0.25 from apps-subnet
          │      (or, for a public Service, a brand-new Public IP —
          │       each Service gets its own dedicated frontend IP)
          │
          ├─▶ backend pool
          │      every eligible node NIC / node IP in the cluster
          │      updated automatically as node pools scale
          │
          ├─▶ load balancing rule
          │      frontend 10.240.0.25:443  ─▶  backend :nodePort (30000-32767)
          │
          ├─▶ health probe
          │      externalTrafficPolicy: Local   → probes spec.healthCheckNodePort
          │      externalTrafficPolicy: Cluster → probes the service port (TCP by default)
          │
          └─▶ NSG rules on the node subnet, opened to match the LB rule
          │
          ▼
     Service.status.loadBalancer.ingress = 10.240.0.25   ← now EXTERNAL-IP populates
    

    Nobody clicked anything. The load balancer is a side effect of a YAML file.

    A handful of details worth carrying around in your head:

    The annotations that are actually worth knowing

    Annotation (prefix service.beta.kubernetes.io/)Effect
    azure-load-balancer-internal"true" → private frontend, no public IP. The single most important line in the manifest.
    azure-load-balancer-internal-subnetWhich subnet the private frontend binds to; defaults from cloud config.
    azure-load-balancer-ipv4 / -ipv6Pin a static address instead of taking whatever's free.
    azure-load-balancer-health-probe-protocolDefaults: HTTP for Local services, TCP for Cluster services.
    azure-load-balancer-health-probe-request-pathe.g. /healthz. Ignored on TCP probes or when appProtocol is empty.
    azure-load-balancer-health-probe-intervalSeconds between probes. Default 5.
    azure-load-balancer-health-probe-num-of-probeConsecutive failures before a node is pulled. Default 2.
    azure-load-balancer-tcp-idle-timeout4–100 minutes. Raise it for long-poll and streaming workloads.
    azure-load-balancer-disable-tcp-resetDefault "false" — TCP reset is on, and you want it on. A reset is a fast failure; a silent drop is a 30-second hang.
    azure-pip-name / azure-pip-prefix-idBring your own public IP or prefix for public Services.
    port_{port}_health-probe_*Per-port overrides when one Service exposes several ports with different health semantics.
    Probe interval is a real availability lever Default probing is 5 seconds × 2 failures. That is a ~10-second window in which the load balancer keeps sending live traffic to a node that has already stopped answering. On a latency-sensitive API, tightening the interval buys you seconds of recovery. On a cluster with hundreds of Services, tightening it everywhere buys you probe storms. Tune it where it matters, not globally.
    ◆ ◆ ◆
    07 / GROUND CREW

    Inside the fence: kube-proxy and the second hop nobody drew

    Cluster vs Local, and where your client IP goes to die.

    The Azure load balancer is Layer 4. It does not know what a pod is. It picks a node and hands the connection over, and from that moment the request is Kubernetes' problem.

    What happens next depends entirely on one field you probably left at its default.

     externalTrafficPolicy: Cluster   (the default)
     ─────────────────────────────────────────────────────────────
       LB ──▶ node-3 ──(kube-proxy SNATs, then forwards)──▶ node-7 ──▶ pod
                            │
                            └── source IP rewritten to node-3
                                extra network hop
                                even spread across all nodes
                                no imbalance if pods are uneven
    
     externalTrafficPolicy: Local
     ─────────────────────────────────────────────────────────────
       LB ──▶ node-3 ──▶ pod on node-3 (only)
                            │
                            └── client source IP preserved
                                one hop, lower latency
                                nodes with no pod fail the probe and get
                                excluded — traffic skews to pod-dense nodes
    

    Health probes are the enforcement mechanism, not a nice-to-have.

    The mechanism is worth spelling out, because it explains a class of bug that looks like magic. With Local, Kubernetes allocates a spec.healthCheckNodePort, and the Azure health probe targets that port rather than your application. The node answers healthy only if it is hosting at least one ready endpoint for the Service. Nodes without a pod fail the probe and drop out of rotation — which is exactly how the "only send traffic where the pod actually is" guarantee is implemented. With Cluster and shared health probe mode enabled, the probe instead targets kube-proxy on the healthCheckNodePort for all cluster-policy Services at once, which is how large clusters avoid drowning in per-Service probes.

    And the client IP question. By the time our Pune request reaches the pod, it has been through two proxies and possibly one SNAT. The real client address is not in the TCP source; it is in headers:

    HeaderSet byContains
    X-Azure-ClientIPFront DoorThe client's IP as Front Door determined it
    X-Azure-SocketIPFront DoorThe IP of the socket that actually connected to the PoP
    X-Forwarded-ForFront Door, appended by APIM/ingressThe chain — read it right-to-left, trust only the hops you control
    X-Azure-FDIDFront DoorYour profile GUID — the "came through the front door" proof
    X-Azure-RefFront DoorThe reference ID to quote in a support case. Log it.

    In ASP.NET Core this means ForwardedHeadersMiddleware is not optional — and it must be configured with known proxies or networks, or you have built an IP-spoofing endpoint and called it observability.

    Program.cs
    builder.Services.Configure<ForwardedHeadersOptions>(o =>
    {
        o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
        o.ForwardLimit     = 2;                          // APIM + ingress
        o.KnownNetworks.Clear();                        // (1) don't trust the defaults
        o.KnownProxies.Clear();
        o.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.240.0.0"), 16));
    });
    
    var app = builder.Build();
    app.UseForwardedHeaders();                          // (2) FIRST. Before everything.

    One more airside option

    An Azure Load Balancer plus an in-cluster ingress controller is the workhorse pattern, but it is not the only one. Application Gateway for Containers moves L7 routing, TLS and WAF out of the cluster and into a managed Azure resource driven by Gateway API resources — which is worth considering when you want WAF enforcement at the regional tier as well as the edge, or when you would rather not run and patch an ingress controller. The trade is the usual one: less to operate, less to tune, and a dependency on Azure's release cadence rather than your own Helm chart.

    ◆ ◆ ◆
    08 / DEPARTURES

    The runway you forgot: SNAT and outbound ports

    The failure that arrives on the day you scale from 50 nodes to 51.

    Everything so far has been about traffic coming in. The same load balancer is doing a second job on the way out, and this is where clusters die quietly.

    By default AKS creates one managed outbound public IP, and sets allocatedOutboundPorts to 0, which means "allocate automatically based on backend pool size":

    Nodes in backend poolSNAT ports per node
    ≤ 501,024
    51 – 100512
    101 – 200256
    201 – 400128
    401 – 80064
    801 – 1,00032

    Read that table again with an autoscaler in mind. Scaling from 50 nodes to 51 halves the outbound port budget on every node in the cluster, simultaneously. Your pods start failing to reach the database, the token endpoint, the payment gateway — and the trigger was a scale-out, which is the exact opposite of what everyone in the incident channel is looking for.

    The cruellest capacity bug in Azure is the one where adding a node is what breaks you.

    The arithmetic is fixed and simple: each outbound public IP provides 64,000 ephemeral ports. So

       max nodes = ( 64,000 / ports-per-node ) × number-of-outbound-IPs
    
       example:   4,000 ports per node,  7 outbound IPs
                  ( 64,000 / 4,000 ) × 7  =  112 nodes
    
    pin it explicitly — do not let the table decide for you
    az aks update \
      --resource-group $RG --name $CLUSTER \
      --load-balancer-managed-outbound-ip-count 7 \
      --load-balancer-outbound-ports 4000 \
      --load-balancer-idle-timeout 4          # default is 30 minutes

    Three habits that keep this boring:

    Symptoms to recognise: intermittent outbound connection timeouts, failures that correlate with node count rather than with traffic, and a SnatConnectionCount/AllocatedSnatPorts metric that looks fine on average and terrible at p99.

    ◆ ◆ ◆
    09 / THE PAPER TRAIL

    Every desk stamps the passport

    What to log at each hop so the 03:12 page takes ten minutes, not ten hours.

    A five-checkpoint architecture is only debuggable if every checkpoint writes down what it did and every log shares a key. Here is the minimum viable paper trail.

    CheckpointWhere it landsThe field that saves you
    Front Door accessFrontDoorAccessLogTrackingReference (the X-Azure-Ref), Pop, OriginName, TimeToFirstByte
    Front Door WAFFrontDoorWebApplicationFirewallLogruleName, action, details.matches — the exact field that tripped
    APIMGatewayLogs + Application InsightsCorrelationId, LastErrorSource, LastErrorReason, backend response code
    Azure Load BalancerLB metrics / VNet flow logsDipAvailability (health probe status per backend), AllocatedSnatPorts
    Ingress controllerContainer InsightsX-Forwarded-For, upstream address, upstream response time
    PodOpenTelemetry → App Insightstrace ID propagated from traceparent, plus X-Correlation-Id

    The single highest-leverage move: propagate one identifier from the edge all the way to the pod, and make sure it appears in every one of those six tables. Front Door's X-Azure-Ref is a reasonable root because it is generated before anything else in your system exists, and it is also the value Microsoft support will ask for. Stamp it into an APIM header, log it in the ingress controller's format string, attach it as a baggage item in OpenTelemetry, and the question "where did this request die?" becomes one query instead of six.

    If you cannot answer which checkpoint stopped it in under a minute, you don't have five layers of defence. You have five places to look.
    ◆ ◆ ◆
    10 / DISCRETION

    When to skip a checkpoint

    Not every flight needs an international terminal.

    This chain is not free. Front Door Premium plus APIM Premium plus a dedicated ingress tier is a real monthly number, and every hop adds latency, configuration surface and a new place to be wrong at 3am. Some honest guidance:

    SituationDo this
    Internal API, one region, corporate network onlySkip Front Door. Internal LB + APIM (or just ingress) is enough. Global anycast for a VPN-only audience buys nothing.
    Single API, single consumer, no monetisationSkip APIM. Do JWT validation in the ingress controller or the app. APIM earns its cost when you have products, subscriptions and many consumers.
    Public web app + API, multi-region, external partnersThe full chain. This is the architecture it was designed for.
    You want WAF at the regional tier tooFront Door WAF (global) + Application Gateway or App Gateway for Containers WAF (regional). Defence in depth, but budget for tuning two rule sets.
    Static content and SPA assetsFront Door caching in front of Blob static website. Never let a cacheable asset reach APIM — you are paying capacity units to serve a logo.
    Very low latency, internal, east-westService mesh (Istio add-on) inside the cluster. Front Door and APIM are north-south tools.

    And three anti-patterns that show up in almost every review:

    ◆ ◆ ◆
    — / THE LANDING

    03:12:00.184 UTC

    Our request from Pune made it. One hundred and eighty-four milliseconds, terminal door to pod, and in that time it was fingerprinted by a firewall in Chennai, had its token verified against Entra ID's published keys, had its quota decremented against a subscription it does not know it belongs to, crossed a private endpoint that has no route to the public internet, was handed to a Layer 4 load balancer nobody in your organisation deliberately created, and was steered to a node that had proved five seconds earlier that it was still holding a ready pod.

    It will never know any of that happened. That is the whole point. The measure of a good security chain is not how visible it is — it is how completely invisible it is to everything that has legitimate business getting through, and how absolutely final it is for everything that does not.

    The passenger boards. The gate closes. Somewhere in a log table, six rows share a tracking reference.

    ◆ ◆ ◆

    Further reading

    ← Back to all stories