API security beyond JWT with mTLS HMAC signing in Spring Boot
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

API Security Beyond JWT: API Keys, mTLS, HMAC Signing & Rate Limiting in Spring Boot

JWT is ubiquitous, but it is not the answer to every API security problem. Machine-to-machine services need certificates, not bearer tokens. Third-party integrations need hashed API keys with per-key rate limits. Financial and webhook endpoints need HMAC request signing to prevent payload tampering and replay attacks. This guide covers the full API security toolkit — beyond JWT — with real Spring Boot implementations you can take to production today.

Table of Contents

  1. Why JWT Alone Is Not Enough: The API Security Landscape in 2026
  2. API Key Authentication: Generation, Hashing & Rotation in Spring Boot
  3. Mutual TLS (mTLS) for Service-to-Service Authentication
  4. HMAC Request Signing: Preventing Replay Attacks
  5. Rate Limiting at Multiple Levels in Spring Boot
  6. API Gateway Security: Kong & Spring Cloud Gateway
  7. Security Testing Your APIs: Tools & Automation

Why JWT Alone Is Not Enough: The API Security Landscape in 2026

API Security Defense Layers | mdsanwarhossain.me
API Security Defense Layers — mdsanwarhossain.me

JWT (JSON Web Token) became the default authentication mechanism for REST APIs largely because it is stateless, self-contained, and works elegantly with browser-based single-page applications. But stateless is not the same as secure, and a mechanism designed for user sessions carries significant drawbacks when applied universally across all API communication patterns.

The first fundamental limitation is token theft without revocation. A stolen JWT remains valid until its expiration timestamp — and if you issue long-lived tokens to avoid constant re-authentication, that window can be hours or days. You can implement a token blacklist in Redis, but now you have introduced state into what was meant to be a stateless system, and every validation call must hit that blacklist. At 50,000 requests per second, that is 50,000 Redis lookups per second just for authentication.

The second limitation is that JWTs are designed for human-to-machine flows. They carry user identity claims (sub, email, roles) and are issued via OAuth2 authorization flows — implicit, authorization code, or client credentials. For machine-to-machine (M2M) communication where services authenticate to each other inside a Kubernetes cluster, the client credentials flow produces JWTs that still carry unnecessary overhead and require a token server to be available on every authentication path.

The third limitation is that JWTs provide no payload integrity. The signature verifies that the token was issued by the expected issuer, but it says nothing about the HTTP request body. A man-in-the-middle attacker who intercepts a request can replay it, modify query parameters, or change the request body. For sensitive operations — financial transactions, webhook deliveries, administrative commands — you need request-level signing that covers the entire HTTP payload, not just the bearer token in the Authorization header.

The Security Decision Matrix

Here is a practical decision matrix for choosing the right authentication mechanism:

Scenario Recommended Mechanism Why
User-facing REST API (SPA/mobile) JWT + OAuth2 (short TTL) Human identity, federated login
Third-party developer API Hashed API Keys + rate limits Simple, revocable, per-consumer limits
Internal microservice-to-microservice mTLS (Istio/cert-manager) Cryptographic identity, no token server
Financial/payment API HMAC-SHA256 request signing Payload integrity + replay prevention
Webhook delivery (inbound) HMAC signature header verification Verify sender without TLS client cert
Public API with abuse prevention API Key + distributed rate limiting Per-consumer quotas, graceful 429s

In most production platforms, you will use all of these mechanisms simultaneously — JWT for your user-facing APIs, mTLS for east-west (service-to-service) traffic inside the cluster, API keys for your external developer program, and HMAC signing for your financial and webhook endpoints. The mistake is assuming JWT covers everything and leaving the rest unaddressed.

API Key Authentication: Generation, Hashing & Rotation in Spring Boot

API keys are the simplest authentication mechanism for server-to-server and third-party developer scenarios. The critical implementation mistake teams make is storing raw API keys in the database — if your database is breached, every API key is immediately compromised. The correct approach is to store only a SHA-256 hash of the key, identical to how passwords should be stored.

Generating Cryptographically Secure API Keys

Java's SecureRandom provides cryptographically strong random bytes. The key should be at least 32 bytes (256 bits) to make brute-force infeasible:

import java.security.SecureRandom;
import java.util.Base64;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;

@Service
public class ApiKeyService {

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    private static final int KEY_BYTES = 32; // 256-bit key

    /**
     * Generates a new API key and returns both the raw key (shown once to the user)
     * and the SHA-256 hash stored in the database.
     */
    public ApiKeyPair generateApiKey(String clientId, String[] scopes) {
        byte[] keyBytes = new byte[KEY_BYTES];
        SECURE_RANDOM.nextBytes(keyBytes);

        // Prefix with "sk_" to make keys identifiable in logs/leaked credential scans
        String rawKey = "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
        String hashedKey = hashKey(rawKey);

        ApiKeyEntity entity = ApiKeyEntity.builder()
            .clientId(clientId)
            .keyHash(hashedKey)
            .keyPrefix(rawKey.substring(0, 8)) // Store prefix for identification
            .scopes(Arrays.asList(scopes))
            .createdAt(Instant.now())
            .active(true)
            .build();

        apiKeyRepository.save(entity);

        // Return raw key ONCE — never stored, never logged
        return new ApiKeyPair(rawKey, entity.getId());
    }

    public String hashKey(String rawKey) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(rawKey.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }

    public Optional<ApiKeyEntity> validateApiKey(String rawKey) {
        String hash = hashKey(rawKey);
        return apiKeyRepository.findByKeyHashAndActiveTrue(hash);
    }
}

Spring Security Filter for API Key Validation

Register a OncePerRequestFilter that intercepts requests, extracts the API key from the X-API-Key header, validates the hash against the database, and injects the authentication into Spring Security's context:

@Component
@RequiredArgsConstructor
public class ApiKeyAuthFilter extends OncePerRequestFilter {

    private final ApiKeyService apiKeyService;
    private final ApiKeyRateLimiter rateLimiter;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String apiKey = request.getHeader("X-API-Key");

        if (apiKey == null || apiKey.isBlank()) {
            chain.doFilter(request, response);
            return;
        }

        Optional<ApiKeyEntity> keyEntity = apiKeyService.validateApiKey(apiKey);

        if (keyEntity.isEmpty()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Invalid API key\"}");
            return;
        }

        ApiKeyEntity entity = keyEntity.get();

        // Check per-key rate limit before proceeding
        if (!rateLimiter.tryConsume(entity.getClientId())) {
            response.setStatus(429);
            response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimiter.getLimit(entity.getClientId())));
            response.setHeader("X-RateLimit-Remaining", "0");
            response.setHeader("Retry-After", "60");
            response.getWriter().write("{\"error\":\"Rate limit exceeded\",\"retryAfter\":60}");
            return;
        }

        // Build authentication with granted authorities from key scopes
        List<GrantedAuthority> authorities = entity.getScopes().stream()
            .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
            .collect(Collectors.toList());

        ApiKeyAuthToken auth = new ApiKeyAuthToken(entity.getClientId(), authorities);
        auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        SecurityContextHolder.getContext().setAuthentication(auth);
        chain.doFilter(request, response);
    }
}

// Register the filter before UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           ApiKeyAuthFilter apiKeyFilter) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/public/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

Zero-Downtime Key Rotation

Key rotation without downtime requires supporting two concurrent active keys per client during a transition window. Clients rotate by generating a new key, updating their systems to use the new key, then revoking the old key — the overlap window (typically 24–48 hours) ensures no requests are rejected during the transition:

@Transactional
public ApiKeyPair rotateApiKey(String clientId, UUID oldKeyId) {
    ApiKeyEntity oldKey = apiKeyRepository.findById(oldKeyId)
        .orElseThrow(() -> new KeyNotFoundException(oldKeyId));

    // Generate new key before deactivating old one
    ApiKeyPair newPair = generateApiKey(clientId, oldKey.getScopes().toArray(new String[0]));

    // Mark old key as rotating — still valid for 48 hours
    oldKey.setStatus(KeyStatus.ROTATING);
    oldKey.setExpiresAt(Instant.now().plus(48, ChronoUnit.HOURS));
    apiKeyRepository.save(oldKey);

    log.info("API key rotation initiated for client={}, oldKeyPrefix={}, newKeyId={}",
        clientId, oldKey.getKeyPrefix(), newPair.getKeyId());

    return newPair;
}

// Scheduled job to deactivate expired rotating keys
@Scheduled(fixedDelay = 3600000) // every hour
public void deactivateExpiredRotatingKeys() {
    int count = apiKeyRepository.deactivateExpiredRotatingKeys(Instant.now());
    if (count > 0) {
        log.info("Deactivated {} expired rotating API keys", count);
    }
}

Mutual TLS (mTLS) for Service-to-Service Authentication

mTLS and HMAC Authentication Flow | mdsanwarhossain.me
mTLS and HMAC Authentication Flow — mdsanwarhossain.me

In standard TLS, only the server presents a certificate. The client verifies the server's identity, but the server has no cryptographic proof of who the client is. Mutual TLS (mTLS) extends this handshake: both the client and server present X.509 certificates, and both sides verify the other's certificate against a trusted Certificate Authority (CA). The result is bidirectional cryptographic identity — the server knows exactly which service is calling it, and the client knows exactly which server it is talking to, without any tokens or passwords in the application layer.

Spring Boot Server-Side mTLS Configuration

Configure Spring Boot to require client certificates by setting client-auth: need in the SSL properties:

# application.yml — Server requiring client certificates
server:
  ssl:
    enabled: true
    key-store: classpath:certs/server-keystore.p12
    key-store-password: ${SERVER_KEYSTORE_PASSWORD}
    key-store-type: PKCS12
    trust-store: classpath:certs/ca-truststore.p12
    trust-store-password: ${CA_TRUSTSTORE_PASSWORD}
    trust-store-type: PKCS12
    client-auth: need  # NEED = require cert; WANT = optional; NONE = no cert check
    protocol: TLS
    enabled-protocols: TLSv1.3

On the calling service side, configure the RestTemplate or WebClient to present a client certificate on every outbound request:

@Configuration
public class MtlsClientConfig {

    @Value("${client.ssl.key-store}")
    private Resource keyStore;

    @Value("${client.ssl.key-store-password}")
    private String keyStorePassword;

    @Value("${client.ssl.trust-store}")
    private Resource trustStore;

    @Value("${client.ssl.trust-store-password}")
    private String trustStorePassword;

    @Bean
    public WebClient mtlsWebClient() throws Exception {
        KeyStore ks = KeyStore.getInstance("PKCS12");
        ks.load(keyStore.getInputStream(), keyStorePassword.toCharArray());

        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        kmf.init(ks, keyStorePassword.toCharArray());

        KeyStore ts = KeyStore.getInstance("PKCS12");
        ts.load(trustStore.getInputStream(), trustStorePassword.toCharArray());

        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(ts);

        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

        SslContext nettySSL = SslContextBuilder.forClient()
            .keyManager(kmf)
            .trustManager(tmf)
            .protocols("TLSv1.3")
            .build();

        HttpClient httpClient = HttpClient.create()
            .secure(spec -> spec.sslContext(nettySSL));

        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}

Extracting Client Identity from the Certificate

After the TLS handshake completes, the client's certificate is available in the X-Client-Cert-DN header (if behind a proxy) or directly in the HttpServletRequest. Extract the Common Name (CN) to identify the calling service:

@Component
public class MtlsAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        X509Certificate[] certs = (X509Certificate[])
            request.getAttribute("jakarta.servlet.request.X509Certificate");

        if (certs == null || certs.length == 0) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Client certificate required\"}");
            return;
        }

        X509Certificate clientCert = certs[0];
        String dn = clientCert.getSubjectX500Principal().getName();
        String cn = extractCN(dn); // e.g., "payment-service"

        // Verify CN against allowlist of known services
        if (!allowedServiceCNs.contains(cn)) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write("{\"error\":\"Service not authorized: " + cn + "\"}");
            return;
        }

        // Check cert expiration explicitly (TLS handles this, but belt-and-suspenders)
        try {
            clientCert.checkValidity();
        } catch (CertificateExpiredException | CertificateNotYetValidException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\":\"Client certificate expired\"}");
            return;
        }

        List<GrantedAuthority> authorities = List.of(
            new SimpleGrantedAuthority("ROLE_SERVICE"),
            new SimpleGrantedAuthority("SERVICE_" + cn.toUpperCase())
        );

        UsernamePasswordAuthenticationToken auth =
            new UsernamePasswordAuthenticationToken(cn, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(auth);
        chain.doFilter(request, response);
    }

    private String extractCN(String dn) {
        for (String part : dn.split(",")) {
            part = part.trim();
            if (part.startsWith("CN=")) {
                return part.substring(3);
            }
        }
        return "";
    }
}

Generating Test Certificates with keytool

# Create a CA key and self-signed certificate
keytool -genkeypair -alias ca -keyalg RSA -keysize 4096 \
  -dname "CN=Internal CA,O=MyCompany,C=US" \
  -validity 3650 -keystore ca-keystore.p12 -storetype PKCS12 \
  -storepass changeit -ext bc:c

# Create server certificate signed by the CA
keytool -genkeypair -alias server -keyalg RSA -keysize 2048 \
  -dname "CN=api-service,O=MyCompany,C=US" \
  -validity 365 -keystore server-keystore.p12 -storetype PKCS12 \
  -storepass changeit

# Generate CSR for server cert
keytool -certreq -alias server -keystore server-keystore.p12 \
  -storepass changeit -file server.csr

# Sign CSR with CA
keytool -gencert -alias ca -keystore ca-keystore.p12 \
  -storepass changeit -infile server.csr -outfile server.crt \
  -validity 365 -ext SAN=dns:api-service,dns:api-service.default.svc.cluster.local

# Repeat for client certificate (payment-service)
keytool -genkeypair -alias payment-service -keyalg RSA -keysize 2048 \
  -dname "CN=payment-service,O=MyCompany,C=US" \
  -validity 365 -keystore client-keystore.p12 -storetype PKCS12 \
  -storepass changeit

In Kubernetes, use cert-manager to automate certificate issuance and rotation. Define a Certificate resource and cert-manager handles the CA signing and secret injection, rotating certificates automatically before expiry:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: payment-service-mtls
  namespace: payments
spec:
  secretName: payment-service-tls
  duration: 24h       # Short-lived certs
  renewBefore: 8h     # Renew 8h before expiry
  dnsNames:
    - payment-service
    - payment-service.payments.svc.cluster.local
  issuerRef:
    name: internal-ca-issuer
    kind: ClusterIssuer

HMAC Request Signing: Preventing Replay Attacks

HMAC (Hash-based Message Authentication Code) signing addresses a threat model that neither JWT nor mTLS covers: payload tampering and replay attacks at the application layer. An attacker who can intercept a legitimate signed request can replay it minutes or hours later unless the signature includes a timestamp and a unique nonce.

The signing scheme popularized by AWS Signature Version 4 is the gold standard. The canonical string covers: HTTP method, URL path, query string, selected headers, timestamp, and a hash of the request body. Any modification to any of these fields invalidates the signature.

HMAC-SHA256 Signing Implementation

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;

public class HmacSigner {

    private static final String HMAC_ALGORITHM = "HmacSHA256";

    /**
     * Builds the canonical string to sign.
     * Format: METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nBODY_HASH
     */
    public static String buildStringToSign(String method, String path,
                                           String query, String timestamp,
                                           String nonce, byte[] body) {
        String bodyHash = sha256Hex(body);
        return String.join("\n",
            method.toUpperCase(),
            path,
            query != null ? query : "",
            timestamp,
            nonce,
            bodyHash
        );
    }

    /**
     * Computes HMAC-SHA256 of the canonical string with the shared secret.
     */
    public static String sign(String secret, String stringToSign) {
        try {
            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
            SecretKeySpec keySpec = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
            mac.init(keySpec);
            byte[] hmacBytes = mac.doFinal(
                stringToSign.getBytes(StandardCharsets.UTF_8));
            return "hmac-sha256=" + HexFormat.of().formatHex(hmacBytes);
        } catch (Exception e) {
            throw new IllegalStateException("HMAC signing failed", e);
        }
    }

    private static String sha256Hex(byte[] data) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            return HexFormat.of().formatHex(digest.digest(
                data != null ? data : new byte[0]));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }
}

// Client usage — add headers to outgoing request
@Component
@RequiredArgsConstructor
public class HmacRequestInterceptor implements ClientHttpRequestInterceptor {

    private final String sharedSecret; // loaded from Vault/K8s Secret

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution)
            throws IOException {
        String timestamp = String.valueOf(Instant.now().getEpochSecond());
        String nonce = UUID.randomUUID().toString();
        String path = request.getURI().getPath();
        String query = request.getURI().getQuery();

        String stringToSign = HmacSigner.buildStringToSign(
            request.getMethod().name(), path, query, timestamp, nonce, body);
        String signature = HmacSigner.sign(sharedSecret, stringToSign);

        request.getHeaders().set("X-Timestamp", timestamp);
        request.getHeaders().set("X-Nonce", nonce);
        request.getHeaders().set("X-Signature", signature);

        return execution.execute(request, body);
    }
}

Server-Side HMAC Validation Filter

The validation filter must check the timestamp window, verify the nonce has not been used before (stored in Redis with TTL matching the window), recalculate the HMAC, and use constant-time comparison to prevent timing attacks:

@Component
@RequiredArgsConstructor
public class HmacValidationFilter extends OncePerRequestFilter {

    private static final long TIMESTAMP_WINDOW_SECONDS = 300; // 5 minutes
    private final StringRedisTemplate redisTemplate;
    private final HmacKeyResolver keyResolver; // resolves shared secret per client

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String timestamp = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String receivedSig = request.getHeader("X-Signature");

        if (timestamp == null || nonce == null || receivedSig == null) {
            chain.doFilter(request, response); // let other filters handle
            return;
        }

        // 1. Validate timestamp window — reject stale requests
        long requestTime = Long.parseLong(timestamp);
        long now = Instant.now().getEpochSecond();
        if (Math.abs(now - requestTime) > TIMESTAMP_WINDOW_SECONDS) {
            sendError(response, 401, "Request timestamp outside acceptable window");
            return;
        }

        // 2. Check nonce uniqueness — prevent replay attacks
        String nonceKey = "hmac:nonce:" + nonce;
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent(nonceKey, "1", Duration.ofSeconds(TIMESTAMP_WINDOW_SECONDS * 2));
        if (Boolean.FALSE.equals(isNew)) {
            sendError(response, 401, "Duplicate nonce — possible replay attack");
            return;
        }

        // 3. Read and cache body (HttpServletRequest body can only be read once)
        CachedBodyHttpServletRequest cachedRequest =
            new CachedBodyHttpServletRequest(request);
        byte[] body = cachedRequest.getContentAsByteArray();

        // 4. Recalculate signature
        String clientId = request.getHeader("X-Client-ID");
        String secret = keyResolver.resolveSecret(clientId);

        String stringToSign = HmacSigner.buildStringToSign(
            request.getMethod(),
            request.getRequestURI(),
            request.getQueryString(),
            timestamp,
            nonce,
            body
        );
        String expectedSig = HmacSigner.sign(secret, stringToSign);

        // 5. Constant-time comparison — CRITICAL to prevent timing attacks
        if (!MessageDigest.isEqual(
                expectedSig.getBytes(StandardCharsets.UTF_8),
                receivedSig.getBytes(StandardCharsets.UTF_8))) {
            sendError(response, 401, "Invalid signature");
            return;
        }

        chain.doFilter(cachedRequest, response);
    }

    private void sendError(HttpServletResponse response, int status, String message)
            throws IOException {
        response.setStatus(status);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\":\"" + message + "\"}");
    }
}

Rate Limiting at Multiple Levels in Spring Boot

Rate limiting is not a single control — it operates at multiple dimensions simultaneously. At the API gateway level, you rate limit by IP address to stop basic scraping and DDoS. At the application level, you rate limit by user identity or API key to enforce fair use and prevent account abuse. For specific endpoints (login, password reset, OTP), you apply tighter limits to stop credential stuffing and brute force attacks.

Distributed Rate Limiting with Bucket4j and Redis

Resilience4j's RateLimiter is an in-memory, per-instance rate limiter — meaning each pod enforces its own limit. In a horizontally scaled deployment, a client can exceed your intended limit by hitting multiple pods. Bucket4j with Redis provides distributed rate limiting with shared state across all instances:

<!-- pom.xml -->
<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.10.0</version>
</dependency>
<dependency>
    <groupId>io.github.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.7.0</version>
</dependency>
@Service
@RequiredArgsConstructor
public class DistributedRateLimiter {

    private final RedissonClient redisson;

    // Token bucket: 1000 tokens, refill 1000 per minute
    private static final Bandwidth STANDARD_LIMIT = Bandwidth.builder()
        .capacity(1000)
        .refillGreedy(1000, Duration.ofMinutes(1))
        .build();

    // Tighter limit for sensitive endpoints: 10 attempts per 15 minutes
    private static final Bandwidth STRICT_LIMIT = Bandwidth.builder()
        .capacity(10)
        .refillGreedy(10, Duration.ofMinutes(15))
        .build();

    public ConsumptionProbe tryConsume(String bucketKey, Bandwidth bandwidth,
                                       long numTokens) {
        ProxyManager<String> proxyManager = Bucket4jRedisson.casBasedProxyManager(redisson);

        Bucket bucket = proxyManager.builder()
            .addLimit(bandwidth)
            .build(bucketKey, BucketConfiguration.builder()
                .addLimit(bandwidth)
                .build());

        return bucket.tryConsumeAndReturnRemaining(numTokens);
    }

    public boolean isAllowed(String clientId, String endpoint) {
        Bandwidth limit = endpoint.contains("auth") ? STRICT_LIMIT : STANDARD_LIMIT;
        String key = "ratelimit:" + endpoint + ":" + clientId;
        ConsumptionProbe probe = tryConsume(key, limit, 1);
        return probe.isConsumed();
    }

    public long getRemainingTokens(String clientId, String endpoint) {
        Bandwidth limit = endpoint.contains("auth") ? STRICT_LIMIT : STANDARD_LIMIT;
        String key = "ratelimit:" + endpoint + ":" + clientId;
        ConsumptionProbe probe = tryConsume(key, limit, 0); // Peek without consuming
        return probe.getRemainingTokens();
    }
}

Rate Limit Response Headers and 429 Handling

RFC 6585 defines the 429 Too Many Requests status code. Your API should always include rate limit context headers so clients can implement backoff logic rather than blindly retrying:

@Component
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {

    private final DistributedRateLimiter rateLimiter;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String clientId = extractClientId(request);
        String endpoint = request.getRequestURI();

        ConsumptionProbe probe = rateLimiter.tryConsumeAndGetProbe(clientId, endpoint);

        // Always set rate limit headers on every response
        response.setHeader("X-RateLimit-Limit", "1000");
        response.setHeader("X-RateLimit-Remaining",
            String.valueOf(probe.getRemainingTokens()));
        response.setHeader("X-RateLimit-Reset",
            String.valueOf(Instant.now().plusSeconds(
                probe.getNanosToWaitForRefill() / 1_000_000_000).getEpochSecond()));

        if (!probe.isConsumed()) {
            long retryAfterSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.setStatus(429);
            response.setHeader("Retry-After", String.valueOf(retryAfterSeconds));
            response.setContentType("application/json");
            response.getWriter().write(String.format(
                "{\"error\":\"Rate limit exceeded\",\"retryAfter\":%d,\"limit\":1000}",
                retryAfterSeconds));
            return;
        }

        chain.doFilter(request, response);
    }

    private String extractClientId(HttpServletRequest request) {
        // Priority: API Key client ID > Authenticated user > IP address
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated()) {
            return "user:" + auth.getName();
        }
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null) {
            return "key:" + apiKey.substring(0, Math.min(8, apiKey.length()));
        }
        return "ip:" + request.getRemoteAddr();
    }
}

API Gateway Security: Kong & Spring Cloud Gateway

Centralizing authentication and rate limiting at the API gateway layer has two major advantages: it offloads work from each microservice, and it provides a single enforcement point that cannot be bypassed by hitting services directly (assuming services are only accessible from inside the cluster).

Spring Cloud Gateway with API Key Validation

@Configuration
public class GatewaySecurityConfig {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder,
                               ApiKeyGatewayFilter apiKeyFilter,
                               RateLimitGatewayFilter rateLimitFilter) {
        return builder.routes()
            .route("payment-api", r -> r
                .path("/api/v1/payments/**")
                .filters(f -> f
                    .filter(apiKeyFilter)        // Validate API key
                    .filter(rateLimitFilter)     // Enforce rate limits
                    .addResponseHeader("X-Content-Type-Options", "nosniff")
                    .addResponseHeader("X-Frame-Options", "DENY")
                    .addResponseHeader("X-XSS-Protection", "0")
                    .addResponseHeader("Strict-Transport-Security",
                        "max-age=31536000; includeSubDomains; preload")
                    .addResponseHeader("Content-Security-Policy",
                        "default-src 'none'; frame-ancestors 'none'")
                    .addResponseHeader("Cache-Control",
                        "no-store, no-cache, must-revalidate")
                    .removeRequestHeader("X-Forwarded-For") // Prevent spoofing
                    .requestSize(new DataSize(1, DataUnit.MEGABYTES)) // Limit request size
                )
                .uri("lb://payment-service")
            )
            .build();
    }
}

@Component
@RequiredArgsConstructor
public class ApiKeyGatewayFilter implements GatewayFilter {

    private final ApiKeyService apiKeyService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");

        if (apiKey == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return apiKeyService.validateApiKeyReactive(apiKey)
            .flatMap(entity -> {
                // Inject client identity for downstream services
                ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .header("X-Client-ID", entity.getClientId())
                    .header("X-Client-Scopes", String.join(",", entity.getScopes()))
                    .build();
                return chain.filter(exchange.mutate().request(mutatedRequest).build());
            })
            .switchIfEmpty(Mono.defer(() -> {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }));
    }
}

OWASP Recommended Security Headers

Beyond authentication, the API gateway is the ideal place to inject security response headers. These headers are recommended by OWASP for all API responses:

Header Recommended Value Threat Mitigated
Strict-Transport-Security max-age=31536000; includeSubDomains SSL stripping
X-Content-Type-Options nosniff MIME sniffing attacks
X-Frame-Options DENY Clickjacking
Content-Security-Policy default-src 'none' XSS, injection
Cache-Control no-store Sensitive data caching
Referrer-Policy no-referrer Info leakage in referer header

Security Testing Your APIs: Tools & Automation

Security controls that are not tested are security controls that will fail silently. API security testing should be part of your CI/CD pipeline — not an annual penetration test that produces a PDF no one reads. Modern DevSecOps pipelines run automated security scans on every pull request and block merges when high-severity issues are found.

OWASP ZAP for Automated Scanning

OWASP ZAP (Zed Attack Proxy) can run in headless mode as part of your CI/CD pipeline. Configure it as a GitHub Actions step that runs a baseline scan against your API, checking for the OWASP API Security Top 10:

# .github/workflows/security-scan.yml
name: API Security Scan

on:
  pull_request:
    branches: [main, develop]

jobs:
  zap-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start application (test environment)
        run: docker-compose -f docker-compose.test.yml up -d
        timeout-minutes: 5

      - name: Wait for application health
        run: |
          timeout 60 bash -c 'until curl -sf http://localhost:8080/actuator/health; do sleep 2; done'

      - name: OWASP ZAP API Baseline Scan
        uses: zaproxy/action-api-scan@v0.7.0
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          docker_name: 'ghcr.io/zaproxy/zaproxy:stable'
          target: 'http://localhost:8080/v3/api-docs'
          format: openapi
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-I -l WARN -z "-config api.addrs.addr.name=.* -config api.addrs.addr.regex=true"'
          fail_action: true  # Fail the build on high-risk findings

      - name: Upload ZAP report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-security-report
          path: report_html.html

Postman Security Test Scripts

Add security assertions to your Postman collection tests to verify that your API correctly rejects unauthorized requests, enforces rate limits, and returns appropriate error codes:

// Postman Test Script — Security assertions
pm.test("Response should not expose stack traces", () => {
    const body = pm.response.text();
    pm.expect(body).to.not.include("at com.example");
    pm.expect(body).to.not.include("Exception");
    pm.expect(body).to.not.include("Caused by");
});

pm.test("Security headers are present", () => {
    pm.expect(pm.response.headers.get("X-Content-Type-Options")).to.equal("nosniff");
    pm.expect(pm.response.headers.get("X-Frame-Options")).to.equal("DENY");
    pm.expect(pm.response.headers.get("Strict-Transport-Security")).to.include("max-age=");
});

pm.test("401 for missing API key", () => {
    // This test runs against a duplicate request with no X-API-Key header
    pm.expect(pm.response.code).to.be.oneOf([401, 403]);
});

pm.test("Rate limit headers are present", () => {
    pm.expect(pm.response.headers.has("X-RateLimit-Limit")).to.be.true;
    pm.expect(pm.response.headers.has("X-RateLimit-Remaining")).to.be.true;
});

Spring Boot Integration Tests for Security

Use Spring's @WebMvcTest combined with MockMvc to write fast, in-process security tests that verify your filter chain behaves correctly:

@WebMvcTest(PaymentController.class)
@Import({SecurityConfig.class, ApiKeyAuthFilter.class, HmacValidationFilter.class})
class PaymentApiSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ApiKeyService apiKeyService;

    @MockBean
    private HmacKeyResolver hmacKeyResolver;

    @Test
    void shouldReturn401WhenApiKeyIsMissing() throws Exception {
        mockMvc.perform(post("/api/v1/payments")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"amount\":100}"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void shouldReturn401WhenHmacSignatureIsInvalid() throws Exception {
        when(apiKeyService.validateApiKey(anyString())).thenReturn(Optional.of(validKey()));
        when(hmacKeyResolver.resolveSecret(anyString())).thenReturn("test-secret");

        mockMvc.perform(post("/api/v1/payments")
                .header("X-API-Key", "sk_validkey123")
                .header("X-Timestamp", String.valueOf(Instant.now().getEpochSecond()))
                .header("X-Nonce", UUID.randomUUID().toString())
                .header("X-Signature", "hmac-sha256=invalidsignature")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"amount\":100}"))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.error").value("Invalid signature"));
    }

    @Test
    void shouldReturn401WhenTimestampIsTooOld() throws Exception {
        when(apiKeyService.validateApiKey(anyString())).thenReturn(Optional.of(validKey()));

        long sixMinutesAgo = Instant.now().minusSeconds(360).getEpochSecond();

        mockMvc.perform(post("/api/v1/payments")
                .header("X-API-Key", "sk_validkey123")
                .header("X-Timestamp", String.valueOf(sixMinutesAgo))
                .header("X-Nonce", UUID.randomUUID().toString())
                .header("X-Signature", "hmac-sha256=any")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"amount\":100}"))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.error")
                .value("Request timestamp outside acceptable window"));
    }

    @Test
    void shouldReturn429WhenRateLimitExceeded() throws Exception {
        when(apiKeyService.validateApiKey(anyString())).thenReturn(Optional.of(validKey()));
        when(rateLimiter.isAllowed(anyString(), anyString())).thenReturn(false);

        mockMvc.perform(get("/api/v1/data")
                .header("X-API-Key", "sk_validkey123"))
            .andExpect(status().isTooManyRequests())
            .andExpect(header().exists("Retry-After"))
            .andExpect(header().exists("X-RateLimit-Remaining"));
    }

    @Test
    void shouldNotExposeStackTracesInErrorResponses() throws Exception {
        mockMvc.perform(post("/api/v1/payments")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{invalid json}"))
            .andExpect(jsonPath("$.stackTrace").doesNotExist())
            .andExpect(jsonPath("$.trace").doesNotExist());
    }
}

The key principle across all security testing is fail-open detection: your tests should verify that security controls are active by asserting that unauthorized requests fail, not just that authorized requests succeed. A filter that is misconfigured to be disabled will make all your positive tests pass while leaving your API completely unprotected.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Leave a Comment

Related Posts

Last updated: April 5, 2026