<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Krishna Yadav</title>
    <description>The latest articles on DEV Community by Krishna Yadav (@krishna_y).</description>
    <link>https://dev.to/krishna_y</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4164464%2F559c22a4-8527-4952-830b-64937fa41b56.png</url>
      <title>DEV Community: Krishna Yadav</title>
      <link>https://dev.to/krishna_y</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krishna_y"/>
    <language>en</language>
    <item>
      <title>Spring Boot 4.1's SSRF Mitigation Is Opt-In: How to Actually Close the Hole</title>
      <dc:creator>Krishna Yadav</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:35:31 +0000</pubDate>
      <link>https://dev.to/krishna_y/spring-boot-41s-ssrf-mitigation-is-opt-in-how-to-actually-close-the-hole-2gij</link>
      <guid>https://dev.to/krishna_y/spring-boot-41s-ssrf-mitigation-is-opt-in-how-to-actually-close-the-hole-2gij</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqr6loifv202t400vqhhf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqr6loifv202t400vqhhf.png" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open the webhook sender in almost any payments service and you will find a method that reads a URL out of a database row and calls it. A merchant registered the address during onboarding. A partner bank supplied a callback host. A KYC vendor returned a link to a scanned document. The call is a single line, &lt;code&gt;restClient.get().uri(url).retrieve()&lt;/code&gt;, and it is also the line where an attacker can make your service fetch &lt;code&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;/code&gt; for them.&lt;/p&gt;

&lt;p&gt;Spring Boot 4.1 ships a first-party defence for this. I went through the &lt;a href="https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.1.0-RC1-Release-Notes" rel="noopener noreferrer"&gt;4.1.0 RC1 release notes&lt;/a&gt; and then looked at how the feature behaves in a service with hand-built HTTP clients. Two things are easy to miss. The filter does nothing until a bean exists for it, and it only covers the clients Boot itself builds.&lt;/p&gt;

&lt;p&gt;Below I cover both gaps, show a resolver-level guard for clients Boot cannot see, and write the tests that show the hole is shut. I also treat the code filter as the last of several layers, since egress control belongs underneath it. Then I turn to the other 4.1 change that matters for microservice teams: gRPC server and client auto-configuration now lives inside Boot, which makes mutual TLS between internal services a configuration exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why payment backends get hit
&lt;/h2&gt;

&lt;p&gt;A service in a private network can reach what the public internet cannot: the cloud instance metadata endpoint, the Kubernetes API server, an admin port bound to the pod network, a Redis instance left without a password because only cluster traffic reaches it. If an attacker controls where one outbound request goes, the service's network position becomes theirs.&lt;/p&gt;

&lt;p&gt;Payment systems have more of these attacker-influenced destinations than most. In the services I have worked on, they come from four places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook targets that merchants type into a dashboard.&lt;/li&gt;
&lt;li&gt;Callback and token endpoints belonging to partner banks and processors, stored in configuration or onboarding records.&lt;/li&gt;
&lt;li&gt;Document links (statements, logos, KYC scans) submitted through forms.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Location&lt;/code&gt; headers on responses from third parties, if the client follows them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a node where the metadata service still accepts unauthenticated GETs, the first request to &lt;code&gt;169.254.169.254&lt;/code&gt; can return temporary cloud credentials, and everything after that depends on how broad the instance role is.&lt;/p&gt;

&lt;p&gt;My time on the &lt;a href="https://krishnakky.com/blog/openbanking-psd2-api-development" rel="noopener noreferrer"&gt;OpenBanking PSD2 side of fintech&lt;/a&gt; shaped how I read partner-supplied hosts. The whole model there is calling other organisations' endpoints, and a hostname being on the partner's onboarding sheet says who signed a contract. It says nothing about where the name resolves today. Partner infrastructure gets compromised, lapsed domains get registered by someone new, and a name that pointed at a public address last month can be repointed at &lt;code&gt;10.0.4.17&lt;/code&gt; by whoever controls its DNS zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 4.1 gives you
&lt;/h2&gt;

&lt;p&gt;The new type is &lt;code&gt;InetAddressFilter&lt;/code&gt;. It is evaluated after DNS resolution, against the &lt;code&gt;InetAddress&lt;/code&gt; the client is about to connect to. That placement is the reason it works better than URL inspection. A string check for &lt;code&gt;169.254&lt;/code&gt; in the host loses to a decimal-encoded IP, an IPv4-mapped IPv6 literal, a trailing dot, or simply a domain the attacker owns whose A record is the metadata address. Looking at the address that will actually receive the SYN makes the spelling of the URL irrelevant.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InetAddressFilter.externalAddresses()&lt;/code&gt; is the built-in factory. As I read the release notes, it refuses loopback, private-range, multicast and other special-purpose addresses, including the link-local block that holds the metadata endpoint. Check the exact set against the Javadoc for your patch version rather than relying on my summary. Once a filter bean exists, Boot attaches it to the clients it auto-configures: &lt;code&gt;RestClient&lt;/code&gt;, &lt;code&gt;RestTemplate&lt;/code&gt; and &lt;code&gt;WebClient&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The opt-in is one bean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxyBeanMethods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutboundSecurityConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// No bean, no filtering. Boot 4.1 does not install this on its own.&lt;/span&gt;
    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nc"&gt;InetAddressFilter&lt;/span&gt; &lt;span class="nf"&gt;outboundAddressFilter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InetAddressFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;externalAddresses&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most write-ups on the feature stop at this snippet. The next two sections are about what it leaves open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gap one: no bean, no protection
&lt;/h2&gt;

&lt;p&gt;Take a project on 4.1 with no &lt;code&gt;InetAddressFilter&lt;/code&gt; bean and point a test at a WireMock instance on &lt;code&gt;127.0.0.1&lt;/code&gt;. My prediction, which follows directly from the opt-in design and which you can confirm in five minutes, is that the call succeeds. Nothing is installed, so nothing blocks.&lt;/p&gt;

&lt;p&gt;Here is the version of this that worries me. A platform team sees "Spring Boot 4.1 mitigates SSRF" in the release notes, raises the parent POM version across its services, and closes the ticket. I am describing a scenario, not a specific company. The upgrade changed no behaviour for SSRF. Every service is as exposed as before, and the tracker says otherwise.&lt;/p&gt;

&lt;p&gt;What I do about it is put the bean in a shared internal starter and add a startup check beside it. If a service has a &lt;code&gt;RestClient.Builder&lt;/code&gt; bean but no &lt;code&gt;InetAddressFilter&lt;/code&gt;, it refuses to boot outside local and test profiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"!local &amp;amp; !test"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutboundFilterGuard&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;SmartInitializingSingleton&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ObjectProvider&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InetAddressFilter&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;OutboundFilterGuard&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ObjectProvider&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InetAddressFilter&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;afterSingletonsInstantiated&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIfAvailable&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"No InetAddressFilter bean declared, so outbound SSRF protection is OFF. "&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"Import OutboundSecurityConfig, or record an explicit exception for this service."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;!local &amp;amp; !test&lt;/code&gt; profile expression is a trade-off. Local runs and integration tests talk to WireMock, Testcontainers and embedded servers, all of which live on loopback, and an enforcing filter would break them. The cost is that the guard never runs in those profiles, so the tests described later in this post, which exercise the real resolver explicitly, are what keep the protection covered in CI. If you want the filter active in test profiles too, you will need an allow exception for loopback that exists only there, and that exception must never ship in a production profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gap two: clients you build yourself
&lt;/h2&gt;

&lt;p&gt;Boot's filter reaches clients through its own auto-configuration. Define your own &lt;code&gt;HttpClient&lt;/code&gt;, &lt;code&gt;CloseableHttpClient&lt;/code&gt; or &lt;code&gt;OkHttpClient&lt;/code&gt; bean and wrap it in a request factory, and Boot's customisation never touches it. As far as I can tell nothing logs a warning either, so you find out by testing, not by reading startup output. If you have verified different behaviour on a specific 4.1.x build, put your finding next to mine: I haven't tested every client type.&lt;/p&gt;

&lt;p&gt;Real payment code ends up here constantly, because partner integrations ask for things the defaults can't do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mutual TLS with a keystore dedicated to one partner.&lt;/li&gt;
&lt;li&gt;A pool sized to a processor's cap on concurrent connections.&lt;/li&gt;
&lt;li&gt;Timeouts that differ per acquirer, so a slow one cannot starve the others.&lt;/li&gt;
&lt;li&gt;An OkHttp interceptor that produces the detached JWS signature a bank requires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each requirement leads to a hand-built client, and a hand-built client is outside Boot's reach. The remedy is to attach the address check to the client yourself. For Apache HttpClient 5 the narrowest point is the &lt;code&gt;DnsResolver&lt;/code&gt;, because every connection attempt passes through it. This version uses only JDK address classification, so it does not depend on Boot internals, and it takes its delegate as a constructor argument so tests can feed it canned answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DnsResolver&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;DnsResolver&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SystemDefaultDnsResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INSTANCE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DnsResolver&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;UnknownHostException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InetAddress&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isForbidden&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Host and address only. The full URL can carry tokens.&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BlockedDestinationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"Blocked outbound connection: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" resolved to "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHostAddress&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;resolveCanonicalHostname&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;UnknownHostException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolveCanonicalHostname&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isForbidden&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InetAddress&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isLoopbackAddress&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnyLocalAddress&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isLinkLocalAddress&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSiteLocalAddress&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isMulticastAddress&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAddress&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="c1"&gt;// 100.64.0.0/10 (carrier-grade NAT) and 198.18.0.0/15 (benchmarking)&lt;/span&gt;
            &lt;span class="c1"&gt;// are not covered by the JDK helpers above.&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xC0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;198&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// fc00::/7, IPv6 unique-local&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mh"&gt;0xFC&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/** UnknownHostException subtype so retry policies can tell policy from network failure. */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BlockedDestinationException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;UnknownHostException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;BlockedDestinationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On IPv6: &lt;code&gt;InetAddress&lt;/code&gt; hands back an &lt;code&gt;Inet4Address&lt;/code&gt; for IPv4-mapped literals such as &lt;code&gt;::ffff:10.0.0.1&lt;/code&gt;, so the checks above see the embedded IPv4 value. Other transition formats (NAT64 prefixes, 6to4) are not normalised that way, which is one more reason to run this behind an egress policy that does not care how an address is spelled.&lt;/p&gt;

&lt;p&gt;Wire it into a pooled connection manager and give the client to a request factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;RestClient&lt;/span&gt; &lt;span class="nf"&gt;acquirerRestClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PartnerTlsProperties&lt;/span&gt; &lt;span class="n"&gt;tls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;connectionManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PoolingHttpClientConnectionManagerBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDnsResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSSLSocketFactory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mutualTlsSocketFactory&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxConnPerRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;CloseableHttpClient&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpClients&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setConnectionManager&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionManager&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;disableRedirectHandling&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// a redirect is a new destination chosen by someone else&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RestClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acquirerBaseUrl&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestFactory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpComponentsClientHttpRequestFactory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Redirects
&lt;/h3&gt;

&lt;p&gt;I disable automatic redirect handling on any client whose target a tenant or partner can influence. A host you are entitled to call can reply &lt;code&gt;302 Location: http://169.254.169.254/...&lt;/code&gt;. With the resolver installed, the redirected hop goes through the same pooled manager and gets checked, so the guard would likely catch it. Even so, I would rather a human-written branch decide whether a redirect is acceptable than let the client follow one silently. Payment APIs seldom need redirects (a 3-D Secure challenge URL is sent to the browser, not followed by your server). In the few cases that do, write explicit code that validates the new target against the same rules and caps the hop count.&lt;/p&gt;

&lt;h3&gt;
  
  
  The resolver and DNS rebinding
&lt;/h3&gt;

&lt;p&gt;Checking at resolve time, inside the same call that returns the addresses the connection manager then uses, avoids the classic time-of-check/time-of-use trap where a validator resolves a name once and the client resolves it again and gets a different answer. That holds only because the check and the connect use the same resolver result. If you add a separate pre-flight "is this URL safe" step that does its own lookup, you have reintroduced the race.&lt;/p&gt;

&lt;p&gt;For OkHttp the equivalent hook is &lt;code&gt;OkHttpClient.Builder.dns(Dns)&lt;/code&gt;, and &lt;code&gt;isForbidden&lt;/code&gt; can be reused unchanged. I keep that method in one shared library class so a fix to a range lands in every client together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allowlists, and the first support ticket
&lt;/h3&gt;

&lt;p&gt;An address filter is a denylist by category. Where destinations form a closed set, use an allowlist instead: the hosts of your partner banks are known, so store them, compare against them, and treat everything else as an onboarding exception needing review. Hold the address filter for the open-ended cases such as merchant webhooks.&lt;/p&gt;

&lt;p&gt;Expect friction once merchants hit it. The first ticket is predictable: a merchant has a legitimate endpoint on a private address, perhaps a VPN-fronted ERP or a staging box on an internal range, and every delivery to it is now blocked. Decide the policy in advance. Mine is that there is no per-tenant bypass in the shared library. A merchant that needs private connectivity gets a dedicated delivery path, with its own network route and its own review, and not a flag in the filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it with tests
&lt;/h2&gt;

&lt;p&gt;Security code I haven't watched reject something is code I do not trust. Three tests cover the guard. The first two need no network; they check classification and resolution using canned answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolverTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;DnsResolver&lt;/span&gt; &lt;span class="nf"&gt;resolvingTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="n"&gt;literals&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DnsResolver&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;@Override&lt;/span&gt;
            &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;UnknownHostException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;literals&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;literals&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getByName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;literals&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;]);&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="nd"&gt;@Override&lt;/span&gt;
            &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;resolveCanonicalHostname&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;};&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
    &lt;span class="nd"&gt;@ValueSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"169.254.169.254"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// cloud metadata&lt;/span&gt;
        &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"10.0.4.17"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// a "public-looking" name pointing at a private range&lt;/span&gt;
        &lt;span class="s"&gt;"172.16.8.9"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"192.168.1.20"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"100.64.3.4"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// carrier-grade NAT&lt;/span&gt;
        &lt;span class="s"&gt;"::1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"fd00::1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// IPv6 unique-local&lt;/span&gt;
        &lt;span class="s"&gt;"::ffff:10.0.0.1"&lt;/span&gt;    &lt;span class="c1"&gt;// IPv4-mapped IPv6&lt;/span&gt;
    &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;rejectsInternalAddressesNoMatterWhatTheNameLooksLike&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;literal&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolvingTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;literal&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;assertThatThrownBy&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"partner.example.com"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isInstanceOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BlockedDestinationException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasMessageContaining&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Blocked outbound connection"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;rejectsWhenOnlyOneOfSeveralRecordsIsInternal&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Round-robin DNS can mix addresses; one bad record must poison the lookup.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolvingTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"93.184.216.34"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"10.0.0.8"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;assertThatThrownBy&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mixed.example.com"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isInstanceOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BlockedDestinationException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;allowsOrdinaryPublicAddresses&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolvingTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"93.184.216.34"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"partner.example.com"&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;hasSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third goes through the real client bean, so it exercises the wiring and not only the helper. It relies on a wildcard DNS service resolving to loopback, which means it needs outbound DNS from your CI runners; if yours do not have that, replace the hostname with one your internal test resolver maps to &lt;code&gt;127.0.0.1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;clientBeanRefusesNameThatResolvesToLoopback&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MockWebServer&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enqueue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MockResponse&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setBody&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secrets"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InetAddress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLoopbackAddress&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// localtest.me resolves to 127.0.0.1: the name looks external, the address is not.&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"http://localtest.me:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPort&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/latest/meta-data/"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="n"&gt;assertThatThrownBy&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;acquirerRestClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasRootCauseInstanceOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PublicOnlyDnsResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BlockedDestinationException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestCount&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isZero&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last assertion carries the weight. A request count of zero on the listener shows that the connection was refused before any bytes left the process, which is a stronger statement than "an exception was thrown". Write one test like this per client bean you own. If the codebase has eleven client beans and the test class has six cases, the five missing ones are your list of unprotected paths.&lt;/p&gt;

&lt;p&gt;I have not pasted run output here because the numbers would depend on your build and Boot patch release. Run these against your own services and attach the result to the change that introduces the guard; that is the artefact a reviewer or auditor will ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defence in depth: the code filter is the last layer
&lt;/h2&gt;

&lt;p&gt;Everything above lives in the application. The application is also the layer that gets forgotten, misconfigured and bypassed, so treat it as the final wall and not the only one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Egress at the network layer.&lt;/strong&gt; A Kubernetes &lt;code&gt;NetworkPolicy&lt;/code&gt;, security group or egress firewall that denies the link-local range and RFC 1918 space for pods that only need to reach the internet is the real fix. If application code fails, the packet still goes nowhere. For services that must call arbitrary tenant URLs, route them through an egress proxy or a small webhook-dispatcher service in an isolated network segment, so only that one component holds the risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IMDSv2 with a hop limit of 1&lt;/strong&gt; on AWS. Version 2 of the metadata service wants a session token obtained with a PUT, which the usual blind-GET SSRF primitive cannot produce, and a hop limit of one keeps container-originated traffic from reaching it. For the metadata attack in particular, this is the highest-value infrastructure change I know of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlists&lt;/strong&gt; wherever the destination set is closed, as described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short timeouts and response caps.&lt;/strong&gt; SSRF probing often relies on timing differences to map a network. A two-second connect timeout and a one-megabyte body limit remove most of that signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress observability.&lt;/strong&gt; Tag outbound spans with the resolved address if you already trace requests. I wrote about wiring that up in &lt;a href="https://krishnakky.com/blog/distributed-tracing-opentelemetry-spring-boot-production" rel="noopener noreferrer"&gt;distributed tracing with OpenTelemetry on Spring Boot&lt;/a&gt;. A counter of blocked connections per tenant is a cheap detector, and a tenant whose count suddenly climbs deserves a look.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The other half: gRPC between your own services
&lt;/h2&gt;

&lt;p&gt;Closing the outbound door covers traffic leaving your estate. Traffic between your own services needs the same attention, and Boot 4.1 changes the setup cost here too. The Spring gRPC project (version 1.1.0 at the time of writing) has been brought into Boot, so server and channel configuration arrive through ordinary auto-configuration and properties and not through a separately versioned starter.&lt;/p&gt;

&lt;p&gt;For a ledger, risk and settlement split, gRPC gives typed contracts and low overhead. It also gives you one obvious place to enforce transport security, which plain HTTP between pods rarely gets in practice. I count plaintext inside the cluster as a finding.&lt;/p&gt;

&lt;p&gt;A ledger service exposing an endpoint looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LedgerGrpcService&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;LedgerServiceGrpc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LedgerServiceImplBase&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PostingEngine&lt;/span&gt; &lt;span class="n"&gt;postingEngine&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;LedgerGrpcService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostingEngine&lt;/span&gt; &lt;span class="n"&gt;postingEngine&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postingEngine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postingEngine&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;postEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostEntryRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;StreamObserver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PostEntryReply&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The idempotency key travels in the message body, not in transport metadata,&lt;/span&gt;
        &lt;span class="c1"&gt;// so a retry from any hop deduplicates against the same value.&lt;/span&gt;
        &lt;span class="nc"&gt;PostingResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postingEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDebitAccount&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreditAccount&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountMinor&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onNext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostEntryReply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setPostingId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onCompleted&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With auto-configuration, that &lt;code&gt;BindableService&lt;/code&gt; bean is discovered and registered without hand-assembling a &lt;code&gt;ServerBuilder&lt;/code&gt;. Transport security then becomes configuration. The property names below follow the Spring gRPC reference as I understand it, so confirm them against the 4.1.x documentation for your exact patch version before copying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bundle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;pem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ledger-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;keystore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;certificate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file:/etc/tls/ledger/tls.crt&lt;/span&gt;
            &lt;span class="na"&gt;private-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file:/etc/tls/ledger/tls.key&lt;/span&gt;
          &lt;span class="na"&gt;truststore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;certificate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file:/etc/tls/ca/internal-ca.crt&lt;/span&gt;
  &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt;
      &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;bundle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ledger-server&lt;/span&gt;
        &lt;span class="na"&gt;client-auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;need&lt;/span&gt;   &lt;span class="c1"&gt;# mutual TLS: a caller without a certificate never reaches your code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client-auth: need&lt;/code&gt; is the line that matters. A caller without a certificate chained to your internal CA fails during the handshake, before any of your code executes. With cert-manager issuing short-lived workload certificates, the day-to-day cost is small.&lt;/p&gt;

&lt;p&gt;The calling side reuses the SSL bundle mechanism, and the channel factory resolves named channels from configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxyBeanMethods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LedgerClientConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nc"&gt;LedgerServiceGrpc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LedgerServiceBlockingStub&lt;/span&gt; &lt;span class="nf"&gt;ledgerStub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GrpcChannelFactory&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// "ledger" maps to spring.grpc.client.channels.ledger.* in application.yml,&lt;/span&gt;
        &lt;span class="c1"&gt;// including its SSL bundle and target address.&lt;/span&gt;
        &lt;span class="nc"&gt;ManagedChannel&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createChannel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ledger"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LedgerServiceGrpc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBlockingStub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDeadlineAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two halves cover different trust boundaries. Channels to &lt;code&gt;ledger.payments.svc.cluster.local&lt;/code&gt; are internal and authenticated by mutual TLS. The &lt;code&gt;InetAddressFilter&lt;/code&gt; and the custom &lt;code&gt;DnsResolver&lt;/code&gt; belong to the HTTP clients that reach out to the world. Keep them visibly apart in configuration. One line is worth adding to the code review checklist: &lt;strong&gt;channel targets come from configuration, never from request data.&lt;/strong&gt; If anyone writes &lt;code&gt;ManagedChannelBuilder.forTarget(valueFromARequest)&lt;/code&gt;, they have rebuilt the SSRF hole one protocol over, and the HTTP-layer controls will not see it because gRPC does not travel through those clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behaviour under failure
&lt;/h2&gt;

&lt;p&gt;A hardened path still has to fail sensibly. Two changes are worth making before the first load test, not after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blocked calls must not retry.&lt;/strong&gt; Many resilience configurations treat &lt;code&gt;UnknownHostException&lt;/code&gt; as transient and retry it with backoff. A blocked address is a policy decision, so retrying it three times only triples the log volume and delays the alert. That is why &lt;code&gt;BlockedDestinationException&lt;/code&gt; is its own subtype above: exclude it from the retry predicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook delivery needs a terminal state for rejected destinations.&lt;/strong&gt; When the filter rejects a merchant's endpoint, the event cannot silently disappear. Park it and flag the merchant record for review. If you deliver through an outbox, as in &lt;a href="https://krishnakky.com/blog/transactional-outbox-idempotent-consumers-spring-boot-payment-systems" rel="noopener noreferrer"&gt;the transactional outbox and idempotent consumers post&lt;/a&gt;, a blocked destination becomes a terminal status on the outbox row and not an endless retry loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also plan for false positives from things that legitimately call loopback or private addresses: health checks, sidecar proxies, Actuator scrapes and calls to a local agent. Those should not use the clients you are guarding. If they do, keep them on a separate client bean with a narrow, documented purpose and no tenant-controlled input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order of work for an existing estate
&lt;/h2&gt;

&lt;p&gt;If I were given a fintech microservice estate on 4.1 and a quarter to make it safer, this is the order I would go in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ship the shared starter that declares &lt;code&gt;InetAddressFilter.externalAddresses()&lt;/code&gt;, along with the startup guard that fails a deploy when the bean is missing.&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;HttpClient&lt;/code&gt;, &lt;code&gt;CloseableHttpClient&lt;/code&gt;, &lt;code&gt;OkHttpClient&lt;/code&gt; and Feign builders. Every hit is a client the Boot filter cannot see. Give each the resolution-time guard and a test like the ones above.&lt;/li&gt;
&lt;li&gt;Turn off automatic redirects on anything that calls a tenant-controlled URL.&lt;/li&gt;
&lt;li&gt;Enforce IMDSv2 with hop limit one on every node, and add egress policy that blocks the link-local and private ranges for workloads that have no reason to reach them.&lt;/li&gt;
&lt;li&gt;Move internal gRPC to mutual TLS through SSL bundles, and ban request-derived channel targets in review.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It removes a whole class of incident from the risk register, and the framework now gives you a starting point where there used to be a blank file.&lt;/p&gt;

&lt;p&gt;If you suspect your outbound clients are less covered than the dashboards claim, my background across &lt;a href="https://krishnakky.com/#experience" rel="noopener noreferrer"&gt;airline-scale traffic and fintech platforms&lt;/a&gt; is a reasonable place to begin a conversation. Send the list of HTTP client beans in your busiest service through &lt;a href="https://krishnakky.com/#contact" rel="noopener noreferrer"&gt;the contact section&lt;/a&gt;, and I will tell you which of them the Boot filter never touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://krishnakky.com/blog/openbanking-psd2-api-development" rel="noopener noreferrer"&gt;OpenBanking PSD2 API Development&lt;/a&gt;: the partner-integration side of fintech, where every outbound call needs a threat model.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://krishnakky.com/blog/distributed-tracing-opentelemetry-spring-boot-production" rel="noopener noreferrer"&gt;Distributed Tracing with OpenTelemetry on Spring Boot in Production&lt;/a&gt;: how to make blocked and suspicious egress visible.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://krishnakky.com/blog/transactional-outbox-idempotent-consumers-spring-boot-payment-systems" rel="noopener noreferrer"&gt;Transactional Outbox and Idempotent Consumers for Spring Boot Payment Systems&lt;/a&gt;: reliable webhook delivery, including what to do when a destination is rejected.&lt;/li&gt;
&lt;/ul&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>security</category>
      <category>java</category>
      <category>springboot</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Startup to Acquisition: Lessons from Building and Selling an eCommerce Platform</title>
      <dc:creator>Krishna Yadav</dc:creator>
      <pubDate>Mon, 05 Oct 2026 17:02:26 +0000</pubDate>
      <link>https://dev.to/krishna_y/from-startup-to-acquisition-lessons-from-building-and-selling-an-ecommerce-platform-40fe</link>
      <guid>https://dev.to/krishna_y/from-startup-to-acquisition-lessons-from-building-and-selling-an-ecommerce-platform-40fe</guid>
      <description>&lt;p&gt;In February 2014 I was twenty-two, halfway through an M.Tech, and convinced I could build a marketplace that would change how local vendors sold online. Three years later the platform was serving eighty-plus vendors, processing real money, and I signed the papers that handed it to someone else.&lt;/p&gt;

&lt;p&gt;This is the short version of that story, and the seven things it taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Small vendors in our region had no affordable way to sell online. Big marketplaces charged steep commissions and buried small sellers. We wanted to build a multi-vendor platform where each shop got its own storefront with shared logistics, payments, and traffic.&lt;/p&gt;

&lt;p&gt;My co-founder handled business. I handled everything that touched a terminal. We bootstrapped — no angel round, no incubator. Every hour of engineering time came directly out of the hours I was supposed to spend on coursework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building on a Twelve-Dollar VPS
&lt;/h2&gt;

&lt;p&gt;I picked Java and Spring for the backend, MySQL for persistence, jQuery for the frontend. Not trendy, but I trusted it wouldn't collapse at midnight when I was the only person on call.&lt;/p&gt;

&lt;p&gt;The MVP took ten weeks of nights and weekends. One Spring monolith, one database, one Tomcat server on a VPS that cost twelve dollars a month. It was ugly. It worked.&lt;/p&gt;

&lt;p&gt;The first real challenge was payment integration. Two Indian payment gateways, each with its own callback format, retry logic, and definition of "success." I wrote an adapter layer that normalized both into common internal events — &lt;code&gt;PAYMENT_CAPTURED&lt;/code&gt;, &lt;code&gt;PAYMENT_FAILED&lt;/code&gt;, &lt;code&gt;PAYMENT_REFUND_INITIATED&lt;/code&gt; — so the rest of the order pipeline didn't care which gateway handled a transaction. That pattern ended up being the most reusable code in the entire system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling and Cracking
&lt;/h2&gt;

&lt;p&gt;Each new vendor exposed a new edge case. Weight-based items. Variant pricing. Bundle deals. Every feature request was a negotiation between "this helps one vendor" and "this complicates things for everyone."&lt;/p&gt;

&lt;p&gt;Around vendor forty, the monolith started cracking. Page loads crept past three seconds. I extracted the inventory service into its own process with its own database. Not a full microservices migration — one pragmatic extraction that relieved the biggest bottleneck.&lt;/p&gt;

&lt;p&gt;Meanwhile I was attending lectures from 9 AM to 1 PM, building the platform from 2 PM to midnight, and squeezing thesis research into whatever gaps remained. The saving grace: every distributed systems concept from the M.Tech curriculum — consistency models, fault tolerance, CAP theorem — I was seeing in production the same week I read about them in papers. The gap between theory and practice wasn't a gap. It was the same Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Exit
&lt;/h2&gt;

&lt;p&gt;By early 2017 the platform was profitable but growth had plateaued. A regional eCommerce company approached us. They wanted our vendor network and our technology — specifically the vendor management system and the payment adapter layer, which they said would save them six to eight months of development.&lt;/p&gt;

&lt;p&gt;The negotiation took two months. We closed in May 2017. The terms were fair. The vendors kept their storefronts, the customers kept their accounts. I walked out with no regrets and a clear picture of what I wanted next: building at larger scale, which eventually led me to Oracle Financial Services, Kotak Securities, and AirAsia where I now work on systems processing 130 million daily transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven Lessons for Technical Founders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Ship the ugly version first.&lt;/strong&gt; Our MVP was architecturally embarrassing. It was also live, collecting payments, and teaching us what users actually needed. The beautiful rewrite can happen after you've proven the business.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pick boring technology.&lt;/strong&gt; Java and Spring in 2014 won zero hackathons. But when our payment integration broke at midnight, I could find a Stack Overflow answer in under a minute. Boring technology compounds reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Solve the class, not the instance.&lt;/strong&gt; When vendor thirty-seven asks for a feature, ask what general problem it represents. Build the general solution or don't build it at all. Every special case is a maintenance liability that outlives whoever requested it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Wrap every external dependency in an adapter.&lt;/strong&gt; Payment gateways, shipping APIs, SMS providers — they will change their interfaces, go down, or get replaced. Your codebase should never know or care which vendor sits behind the adapter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Cofounder alignment beats cofounder skills.&lt;/strong&gt; We never argued about direction because we agreed on the fundamentals before we started: bootstrap, stay profitable, build something vendors actually use. Technical skills can be hired. Strategic alignment cannot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Know your exit conditions before you need them.&lt;/strong&gt; We didn't have a formal trigger for when to sell, and that made the decision more emotional than necessary. Write down the conditions under which you'd sell, shut down, or raise money — before you launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Burnout is not a badge of honor.&lt;/strong&gt; I pushed through it because I was young and stubborn. One full day off per week would not have killed the company and would have made me a better engineer the other six days.&lt;/p&gt;

&lt;p&gt;That startup gave me scars and skills in roughly equal proportion. If you're building something right now — side project, startup, internal tool at a company that doesn't appreciate it — the work compounds. The code you're embarrassed by today teaches you to write something better tomorrow.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://krishnakky.com/blog/startup-to-acquisition-lessons" rel="noopener noreferrer"&gt;krishnakky.com&lt;/a&gt; — the full version has more detail on the technical architecture, payment integration patterns, and the acquisition negotiation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>entrepreneurship</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI-Driven Development: Running Autonomous Agents Across a Microservices Codebase</title>
      <dc:creator>Krishna Yadav</dc:creator>
      <pubDate>Mon, 05 Oct 2026 16:57:07 +0000</pubDate>
      <link>https://dev.to/krishna_y/ai-driven-development-running-autonomous-agents-across-a-microservices-codebase-1g48</link>
      <guid>https://dev.to/krishna_y/ai-driven-development-running-autonomous-agents-across-a-microservices-codebase-1g48</guid>
      <description>&lt;p&gt;At AirAsia, I own production microservices on the Manage My Booking platform — flight changes, fare summaries, price-slash, post-booking ancillaries. Over a dozen services, each with its own repo, pipeline, and accumulated quirks. The platform processes 130 million requests a day.&lt;/p&gt;

&lt;p&gt;My problem was never writing code. It was the context-switching overhead. Bouncing between a fare-calculation refactor, a circuit breaker fix three repos over, and a schema migration for the ancillary service — every switch cost 20–30 minutes of mental reload. Entire days evaporated into friction.&lt;/p&gt;

&lt;p&gt;I started using &lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; not to write code for me, but to keep multiple workstreams moving in parallel. It operates directly in the terminal — reads the codebase, runs commands, writes files, stages changes. An agent, not an autocomplete engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission Scopes: The Non-Negotiable First Step
&lt;/h2&gt;

&lt;p&gt;You cannot hand an AI agent unrestricted access to a production codebase. That is how you get a force-push to main at 2 AM.&lt;/p&gt;

&lt;p&gt;I set up scoped permissions per project in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run test*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run lint*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(mvn test*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git diff*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git status*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git log*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git add -p*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git checkout main*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm -rf*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(docker*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can read, write, edit, run tests and linters, view diffs, and stage changes. It cannot push, switch to main, delete directory trees, or touch Docker. Every push and merge goes through me. Payment-adjacent services get tighter restrictions. Internal tooling repos are looser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Phase Model
&lt;/h2&gt;

&lt;p&gt;I restructured feature delivery into three phases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inception (human only).&lt;/strong&gt; I define the scope, draw sequence diagrams, identify affected services, and write interface contracts. This produces a spec document — the agent's input. Thirty to sixty minutes here, and the quality directly determines how useful the agent is downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Construction (agents + review).&lt;/strong&gt; Each agent gets a spec, a repo, and constraints. I run parallel terminal sessions — one agent per service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terminal 1:&lt;/strong&gt; Agent on booking-service, implementing the new endpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal 2:&lt;/strong&gt; Agent on pricing-engine, adding fare calculation logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal 3:&lt;/strong&gt; Agent on ancillary-catalog, updating schema and data layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; Reviewing diffs, running integration tests, handling cross-service architecture decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agents don't talk to each other. I am the coordination layer. When the pricing agent finishes, I review it and feed interface changes to the booking agent as context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operations (human only).&lt;/strong&gt; Post-merge monitoring, production validation, performance profiling. I watch dashboards, check distributed traces in Zipkin, validate circuit breaker behavior. AI agents have no business in production operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results After Six Months
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Feature delivery time dropped ~40%.&lt;/strong&gt; Cross-service features that took 4–5 days now take 2–3. The reduction comes from parallel Construction — three repos at once instead of sequential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit test coverage increased.&lt;/strong&gt; Writing tests is mechanical work agents handle well. I used to skip edge-case tests when tired. The agent does not get tired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review quality improved.&lt;/strong&gt; When I write code, I review it with the same mental model — and miss my own blind spots. Agent-written code gets genuinely fresh-eyed review. The separation between author and reviewer becomes real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit history became cleaner.&lt;/strong&gt; Selective staging and single-purpose commits made &lt;code&gt;git bisect&lt;/code&gt; actually useful.&lt;/p&gt;

&lt;p&gt;Not everything parallelizes. Tightly coupled changes where Service B depends on Service A's final interface still bottleneck. Those still see gains, but more like 15–20%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The agent optimizes locally.&lt;/strong&gt; It writes correct code for the service it sees while introducing interface mismatches with services it cannot see. Cross-service contracts must come from a human holding the full system model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spec quality is the bottleneck.&lt;/strong&gt; "Add error handling to the booking endpoint" gets generic try-catch blocks. "Return HTTP 409 with BOOKING_CONFLICT when the fare class is unavailable, include available fare classes in the response body, emit a Kafka event to pricing-update" gets exactly what you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't let agents refactor while implementing.&lt;/strong&gt; Mixed diffs — new functionality tangled with cleanup — are impossible to review. Implement against the existing structure; refactor in a separate PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check in every 15–20 minutes.&lt;/strong&gt; Not because the agent will break something catastrophic (permission scopes prevent that), but because a five-minute course correction at the 20-minute mark beats throwing away 90 minutes of wrong-direction implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;This is not about AI replacing developers. It is about restructuring the workflow so human attention goes where it matters: architecture, system-level reasoning, production reliability. The mechanical act of translating a well-defined spec into working code with tests — that is what agents handle well. At the scale we operate at AirAsia, that mechanical work was eating a disproportionate amount of my time.&lt;/p&gt;

&lt;p&gt;The spec-writing phase now takes longer than it used to. That tradeoff is worth it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://krishnakky.com/blog/ai-driven-development-claude-code" rel="noopener noreferrer"&gt;krishnakky.com&lt;/a&gt;. Read the full version with detailed Git integration workflow, war stories, and what I'm experimenting with next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building Microservices at 130 Million Requests Per Day</title>
      <dc:creator>Krishna Yadav</dc:creator>
      <pubDate>Mon, 05 Oct 2026 16:56:59 +0000</pubDate>
      <link>https://dev.to/krishna_y/building-microservices-at-130-million-requests-per-day-42cl</link>
      <guid>https://dev.to/krishna_y/building-microservices-at-130-million-requests-per-day-42cl</guid>
      <description>&lt;p&gt;I've spent the last three-plus years building and operating the microservices behind AirAsia Move — the travel super-app that handles flights, hotels, and ancillaries for millions of travelers across Southeast Asia. The system processes over 130 million requests per day, roughly 1,500 requests per second sustained, with spikes well above that during flash sales.&lt;/p&gt;

&lt;p&gt;I own services on the Manage My Booking platform: flight changes, fare summaries, price-slash features, post-booking ancillaries. A single user action like changing a flight can fan out to eight or nine downstream calls. At this scale, the engineering problems aren't about whether your code compiles. They're about what happens when one of those downstream services is 200ms slower than usual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Circuit Breakers with Resilience4j
&lt;/h2&gt;

&lt;p&gt;The pattern that proved its weight in gold was the circuit breaker. We use &lt;a href="https://resilience4j.readme.io/docs/getting-started" rel="noopener noreferrer"&gt;Resilience4j&lt;/a&gt; across all Spring Boot services — if a downstream service starts failing, stop calling it instead of piling up timeouts that cascade through the system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@CircuitBreaker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pricingService"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallbackMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cachedFareFallback"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;FareSummary&lt;/span&gt; &lt;span class="nf"&gt;getFareSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;bookingId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pricingClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchFare&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookingId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;FareSummary&lt;/span&gt; &lt;span class="nf"&gt;cachedFareFallback&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;bookingId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Pricing service unavailable for booking {}, using cache"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bookingId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fareCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLastKnown&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookingId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fare&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fare&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withStaleFlag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ServiceUnavailableException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No cached fare available"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We pair breakers with fallbacks. If the pricing service is down, the user still sees a fare — it might be a few minutes stale, but that beats a blank screen or a 500.&lt;/p&gt;

&lt;p&gt;We tuned thresholds through load testing with JMeter. The defaults were too aggressive and tripped breakers during normal latency variance. A 50% failure rate over a sliding window of 20 calls, with a 30-second open-state wait, matched our actual traffic pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distributed Tracing
&lt;/h2&gt;

&lt;p&gt;When a request crosses ten services, debugging without distributed tracing is a lost cause. We use Spring Cloud Sleuth for trace propagation and Zipkin for visualization. We sample 10% of traces in production — at 1,500 RPS, that's 150 traces per second, more than enough to spot patterns.&lt;/p&gt;

&lt;p&gt;The biggest tracing win wasn't debugging individual slow requests. We noticed every Tuesday between 2–4 AM UTC, latency spiked on the inventory service. A batch job was running full table scans on the same database the API was reading from. Moved the job to a read replica, spikes gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cascading Timeout
&lt;/h2&gt;

&lt;p&gt;During a Diwali sale, the payment gateway started responding 300ms slower than usual. Not enough to fail — just enough to back up our thread pool. We had a fixed pool of 200 threads with a 5-second payment timeout and a bounded queue of 500. When every thread was blocked on slow payment calls, the queue filled. Upstream services calling us started timing out. Within 90 seconds, three services were effectively down.&lt;/p&gt;

&lt;p&gt;The root cause wasn't the payment gateway. It was our thread pool and timeout configuration.&lt;/p&gt;

&lt;p&gt;We made three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cut the payment timeout from 5s to 2s.&lt;/strong&gt; If it hasn't responded in 2 seconds during peak load, it's not going to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Added a bulkhead&lt;/strong&gt; — isolated the payment call to its own thread pool so one slow dependency can't starve everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switched the circuit breaker to a time-based sliding window&lt;/strong&gt; so it reacts faster during traffic spikes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Memory Leak
&lt;/h2&gt;

&lt;p&gt;Our flight-change service was getting OOMKilled by Kubernetes every 48 hours. Two days of profiling with JVisualVM found a &lt;code&gt;ConcurrentHashMap&lt;/code&gt; used as an in-memory cache with no eviction. Every unique booking ID got cached and never expired. Under sustained 1,500 RPS, it grew until the JVM ran out of heap.&lt;/p&gt;

&lt;p&gt;Five lines of code, two days of investigation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FareSnapshot&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fareCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Caffeine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maximumSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expireAfterWrite&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Contract testing from day one.&lt;/strong&gt; When Service A renames &lt;code&gt;fare_amount&lt;/code&gt; to &lt;code&gt;fareAmount&lt;/code&gt;, both services' unit tests pass. Staging explodes. &lt;a href="https://martinfowler.com/articles/consumerDrivenContracts.html" rel="noopener noreferrer"&gt;Consumer-driven contract tests&lt;/a&gt; catch this at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured logging earlier.&lt;/strong&gt; Searching Kibana for a booking ID across 10 services when half log &lt;code&gt;bookingId=ABC123&lt;/code&gt; and the other half log &lt;code&gt;Processing booking ABC123 for user xyz&lt;/code&gt; is painful. Consistent field names should be a service template requirement on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More aggressive load shedding.&lt;/strong&gt; At 90% capacity, return 429s for low-priority endpoints and keep booking and payment paths fully served, instead of letting everything degrade equally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Building microservices at this scale is about understanding failure modes. Every pattern — circuit breakers, tracing, async messaging, bulkheads — exists because something broke in production and we needed it to not break the same way again. The system processes 130 million requests a day not because we got the design right on the first try, but because we built the instrumentation to see what was breaking and the patterns to contain the blast radius.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://krishnakky.com/blog/building-microservices-at-scale" rel="noopener noreferrer"&gt;krishnakky.com&lt;/a&gt; — the full version has additional code examples, Kafka event-driven architecture details, and a third production war story about consumer group rebalances.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>java</category>
      <category>springboot</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
