The Ultimate Spring Boot Solution for Getting the Real Client IP (99% Get It Wrong)

This article explains why the common getRemoteAddr() call often returns proxy addresses, details the underlying IP header propagation, and provides a production‑grade utility class, Spring Boot configuration, advanced interception, testing, and best‑practice guidelines for reliably obtaining the true client IP.

Java Architect Handbook
Java Architect Handbook
Java Architect Handbook
The Ultimate Spring Boot Solution for Getting the Real Client IP (99% Get It Wrong)

Problem: getRemoteAddr() returns proxy IP

Calling request.getRemoteAddr() in production often yields the address of a load balancer, gateway, or other intermediate middleware instead of the client’s real IP. Some naïve implementations also allow attackers to forge IP headers, creating security risks.

Understanding the IP transmission chain

A typical request flow passes through multiple layers:

Client → CDN → Load Balancer → Gateway → Application Server

Each layer may modify request metadata, which is why getRemoteAddr() becomes unreliable. The most trusted headers (from high to low) are:

X-Forwarded-For – proxy chain IP list (⭐⭐⭐⭐)

X-Real-IP – last proxy IP (⭐⭐⭐)

Proxy-Client-IP – Apache proxy IP (⭐⭐)

WL-Proxy-Client-IP – WebLogic proxy IP (⭐⭐)

Core rule : the left‑most IP in X-Forwarded-For is the original client IP; subsequent IPs are the addresses of each proxy, separated by commas.

Examples:

// No proxy
X-Forwarded-For: null

// Single proxy
X-Forwarded-For: 123.45.67.89

// Two‑level proxy
X-Forwarded-For: 123.45.67.89, 10.0.1.100

// Multi‑level proxy
X-Forwarded-For: 123.45.67.89, 203.0.113.195, 198.51.100.10

Production‑grade IP utility class (ready to reuse)

The following class has been validated in large‑scale production. It extracts the real client IP, filters internal and forged IPs, and works with multi‑level proxy scenarios.

import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

/**
 * IP utility class
 * Functions: safely obtain the real client IP, filter internal and forged IPs, compatible with multi‑level proxy scenarios
 * Applicable to: Spring Boot / Spring MVC projects
 */
public class IpUtils {
    private static final String UNKNOWN = "unknown";
    private static final String LOCALHOST_IP = "127.0.0.1";
    private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
    private static final String SEPARATOR = ",";
    private static final Set<String> INTERNAL_IP_SEGMENTS = new HashSet<>(Arrays.asList(
        "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
        "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
        "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31."
    ));

    /**
     * Get the real public client IP.
     * @param request HttpServletRequest
     * @return client IP (prefers public IP, falls back to internal/local IP)
     */
    public static String getClientRealIp(HttpServletRequest request) {
        // 1. Prefer X‑Forwarded‑For header (core field)
        String ip = parseXForwardedFor(request.getHeader("X-Forwarded-For"));
        if (isValidPublicIp(ip)) {
            return ip;
        }
        // 2. Parse other proxy‑related headers
        ip = getIpFromHeaders(request);
        if (isValidPublicIp(ip)) {
            return ip;
        }
        // 3. Fallback to getRemoteAddr (usually a proxy IP)
        ip = request.getRemoteAddr();
        return LOCALHOST_IPV6.equals(ip) ? LOCALHOST_IP : ip;
    }

    private static String parseXForwardedFor(String xffHeader) {
        if (xffHeader == null || xffHeader.trim().isEmpty()) {
            return null;
        }
        String[] ips = xffHeader.split(SEPARATOR);
        // Step 1: from right to left find first valid public IP (skip internal)
        for (int i = ips.length - 1; i >= 0; i--) {
            String ip = ips[i].trim();
            if (isValidIp(ip) && !isInternalIp(ip)) {
                return ip;
            }
        }
        // Step 2: no public IP, return first syntactically valid IP (may be internal)
        for (String ip : ips) {
            String trimmed = ip.trim();
            if (isValidIp(trimmed)) {
                return trimmed;
            }
        }
        return null;
    }

    private static String getIpFromHeaders(HttpServletRequest request) {
        String[] headers = {"X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP",
                "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};
        for (String header : headers) {
            String ip = request.getHeader(header);
            if (isValidIp(ip)) {
                return ip;
            }
        }
        return null;
    }

    private static boolean isValidIp(String ip) {
        return ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip) && isValidIpAddress(ip);
    }

    private static boolean isValidPublicIp(String ip) {
        return isValidIp(ip) && !isInternalIp(ip) && !isLocalhost(ip);
    }

    private static boolean isInternalIp(String ip) {
        if (ip == null) return false;
        return INTERNAL_IP_SEGMENTS.stream().anyMatch(ip::startsWith);
    }

    private static boolean isLocalhost(String ip) {
        return LOCALHOST_IP.equals(ip) || LOCALHOST_IPV6.equals(ip);
    }

    public static boolean isValidIpAddress(String ip) {
        if (ip == null || ip.isEmpty()) return false;
        String ipv4Pattern = "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$";
        if (ip.matches(ipv4Pattern)) return true;
        if (ip.contains(":")) return true; // simple IPv6 check
        return false;
    }
}

Spring Boot configuration to recognise proxy IP

Method 1: Java configuration

import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatProxyConfig {
    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatProxyCustomizer() {
        return factory -> factory.addConnectorCustomizers(connector -> {
            // optional relaxed characters
            connector.setProperty("relaxedQueryChars", "|{}[]");
            connector.setProperty("relaxedPathChars", "|{}[]");
            // header that carries the real IP
            connector.setProperty("remoteIpHeader", "x-forwarded-for");
            // header that carries the protocol (http/https)
            connector.setProperty("protocolHeader", "x-forwarded-proto");
            // trusted internal proxy IP ranges (core!)
            connector.setProperty("internalProxies",
                "192\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2[0-9]|3[0-1])\\.\\d{1,3}\\.\\d{1,3}");
        });
    }
}

Method 2: YAML configuration (recommended)

server:
  tomcat:
    remoteip:
      remote-ip-header: x-forwarded-for   # use X‑Forwarded‑For for real IP
      protocol-header: x-forwarded-proto # use X‑Forwarded‑Proto for scheme
      internal-proxies: |
        192\\.168\\.\\d{1,3}\\.\\d{1,3}|10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2[0-9]|3[0-1])\\.\\d{1,3}\\.\\d{1,3}

spring:
  mvc:
    log-request-details: true   # enable request detail logging (debug only)

Advanced features: IP interception and security protection

1. IP logging interceptor (records access logs)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class IpLoggingInterceptor implements HandlerInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(IpLoggingInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String clientIp = IpUtils.getClientRealIp(request);
        request.setAttribute("clientRealIp", clientIp);
        logger.info("Client access log - IP: {}, URI: {}, User-Agent: {}",
                clientIp, request.getRequestURI(), request.getHeader("User-Agent"));
        return true;
    }
}

// Web MVC configuration to register the interceptor
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Bean
    public IpLoggingInterceptor ipLoggingInterceptor() {
        return new IpLoggingInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ipLoggingInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/health", "/metrics");
    }
}

2. IP security filter (blacklist + rate limiting)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class IpSecurityFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(IpSecurityFilter.class);
    private final Set<String> blacklistedIps = ConcurrentHashMap.newKeySet();
    private final ConcurrentMap<String, RateLimitInfo> rateLimitMap = new ConcurrentHashMap<>();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // 1. Obtain real client IP
        String clientIp = IpUtils.getClientRealIp(httpRequest);

        // 2. Blacklist check
        if (blacklistedIps.contains(clientIp)) {
            logSecurityEvent("IP blacklist blocked", clientIp, httpRequest);
            sendErrorResponse(httpResponse, 403, "Your IP has been blocked");
            return;
        }

        // 3. Rate limiting (max 60 requests per minute)
        if (isRateLimited(clientIp)) {
            logSecurityEvent("Rate limit triggered", clientIp, httpRequest);
            sendErrorResponse(httpResponse, 429, "Too many requests, please try later");
            return;
        }

        // 4. Suspicious request detection (missing User-Agent or access to sensitive paths)
        if (isSuspiciousRequest(clientIp, httpRequest)) {
            logSecurityEvent("Suspicious request blocked", clientIp, httpRequest);
            blacklistedIps.add(clientIp);
            sendErrorResponse(httpResponse, 403, "Abnormal request detected, IP blocked");
            return;
        }

        // 5. All checks passed – forward request
        chain.doFilter(request, response);
    }

    private boolean isRateLimited(String ip) {
        RateLimitInfo info = rateLimitMap.computeIfAbsent(ip, k -> new RateLimitInfo());
        long now = System.currentTimeMillis();
        if (now - info.getWindowStart() > 60000) { // 1‑minute window expires
            info.reset(60, now);
        }
        return !info.tryAcquire();
    }

    private boolean isSuspiciousRequest(String ip, HttpServletRequest request) {
        String userAgent = request.getHeader("User-Agent");
        if (userAgent == null || userAgent.trim().isEmpty()) {
            return true; // missing User-Agent is suspicious
        }
        String uri = request.getRequestURI().toLowerCase();
        if (uri.contains("admin") || uri.contains("phpmyadmin") ||
                uri.contains("wp-admin") || uri.contains("shell")) {
            return true; // access to sensitive paths
        }
        return false;
    }

    private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
        response.setStatus(status);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write("{\"code\": " + status + ", \"message\": \"" + message + "\"}");
    }

    private void logSecurityEvent(String event, String ip, HttpServletRequest request) {
        logger.warn("Security event - Type: {}, IP: {}, URI: {}, User-Agent: {}",
                event, ip, request.getRequestURI(), request.getHeader("User-Agent"));
    }

    private static class RateLimitInfo {
        private int tokens;
        private long windowStart;
        private final int maxTokens = 60;

        RateLimitInfo() {
            reset(maxTokens, System.currentTimeMillis());
        }

        void reset(int tokens, long windowStart) {
            this.tokens = tokens;
            this.windowStart = windowStart;
        }

        long getWindowStart() {
            return windowStart;
        }

        boolean tryAcquire() {
            if (tokens > 0) {
                tokens--;
                return true;
            }
            return false;
        }
    }
}

Testing verification: ensure IP extraction is accurate

1. IP debug controller

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;

@RestController
public class IpDebugController {
    @GetMapping("/debug/ip")
    public Map<String, Object> debugIp(HttpServletRequest request) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("Real Client IP", IpUtils.getClientRealIp(request));
        result.put("RemoteAddr", request.getRemoteAddr());
        result.put("X-Forwarded-For", request.getHeader("X-Forwarded-For"));
        result.put("X-Real-IP", request.getHeader("X-Real-IP"));
        result.put("Proxy-Client-IP", request.getHeader("Proxy-Client-IP"));
        result.put("WL-Proxy-Client-IP", request.getHeader("WL-Proxy-Client-IP"));
        result.put("Method", request.getMethod());
        result.put("URI", request.getRequestURI());
        result.put("User-Agent", request.getHeader("User-Agent"));
        return result;
    }

    @GetMapping("/debug/ip-headers")
    public Map<String, String> getAllIpHeaders(HttpServletRequest request) {
        Map<String, String> headers = new LinkedHashMap<>();
        String[] ipHeaders = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP",
                "WL-Proxy-Client-IP", "HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED",
                "HTTP_X_CLUSTER_CLIENT_IP", "HTTP_CLIENT_IP", "HTTP_FORWARDED_FOR",
                "HTTP_FORWARDED", "HTTP_VIA", "REMOTE_ADDR"};
        for (String header : ipHeaders) {
            String value = request.getHeader(header);
            if (value != null && !value.trim().isEmpty()) {
                headers.put(header, value);
            }
        }
        return headers;
    }
}

2. Unit test covering core scenarios

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.junit.jupiter.api.Assertions.assertEquals;

class IpUtilsTest {
    @Test
    void testGetClientRealIp() {
        MockHttpServletRequest request = new MockHttpServletRequest();

        // Scenario 1: no proxy
        request.setRemoteAddr("123.45.67.89");
        assertEquals("123.45.67.89", IpUtils.getClientRealIp(request));

        // Scenario 2: single proxy
        request.addHeader("X-Forwarded-For", "123.45.67.89");
        request.setRemoteAddr("10.0.0.1"); // proxy IP
        assertEquals("123.45.67.89", IpUtils.getClientRealIp(request));

        // Scenario 3: multi‑proxy
        request.addHeader("X-Forwarded-For", "123.45.67.89, 10.0.1.100, 10.0.1.101");
        assertEquals("123.45.67.89", IpUtils.getClientRealIp(request));

        // Scenario 4: IPv6 address
        request.addHeader("X-Forwarded-For", "2001:db8::1");
        assertEquals("2001:db8::1", IpUtils.getClientRealIp(request));
    }
}

Production‑environment best practices

Store trusted proxy IP lists in a configuration centre (e.g., Nacos, Apollo) to allow dynamic updates without restarting services.

Use environment‑specific configurations: development trusts all local IPs, production trusts only designated internal proxies.

Deploy an IP monitoring service to handle blacklist events and periodically clean rate‑limit caches, preventing memory leaks.

For high‑concurrency scenarios, move the token‑bucket rate limiter to Redis to achieve distributed limiting.

Cache IP extraction results for a short period (e.g., 10 seconds) to reduce repeated parsing while keeping data fresh.

Common troubleshooting

1. Still getting proxy IP instead of real client IP

Verify that the load balancer or gateway correctly populates the X-Forwarded-For header.

2. Multi‑proxy chain returns an internal IP

Use the provided parseXForwardedFor method, which filters internal IPs from right to left and returns the first public IP.

3. Client can forge X-Forwarded-For

Configure internal-proxies to trust only internal proxy servers; the application will ignore client‑supplied X-Forwarded-For values that are not from trusted proxies.

Conclusion

Accurately obtaining the client’s real IP is essential for analytics, security, and rate‑limiting. The presented utility class, Tomcat configuration, and optional interceptors/filters provide a production‑grade solution that handles multi‑level proxies, filters forged and internal addresses, and integrates with downstream security mechanisms while remaining easy to adopt in existing Spring Boot projects.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

backendJavaproxySpring BootSecurityhttprate-limitingip-address
Java Architect Handbook
Written by

Java Architect Handbook

Focused on Java interview questions and practical article sharing, covering algorithms, databases, Spring Boot, microservices, high concurrency, JVM, Docker containers, and ELK-related knowledge. Looking forward to progressing together with you.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.