Securing the Distributed Ecosystem: A Deep Dive into Spring Security and Stateless JWT

Authentication remains one of the most misunderstood and poorly implemented aspectsof modern backend systems. n Not because frameworks are weak — but because security requires correct design decisions, not just dependencies.

In this article, we’ll walk through how to properly secure Spring Boot APIs using Spring Security and JWT, with:

  • Correct password hashing
  • Stateless authentication
  • A custom JWT filter
  • Clean separation of responsibilities
  • Fully working, stable code

1. Why Authentication Still Breaks in Modern Applications

Despite mature frameworks, authentication remains a top attack surface due to:

  • Plain-text or reversible password storage
  • Misunderstanding encryption vs hashing
  • Blind reliance on framework defaults
  • Incorrect JWT usage (tokens without validation)
  • Mixing authentication logic into controllers

Security failures are usually design failures, not tooling failures.

Spring Security is powerful — but only if used intentionally.

2. Encryption vs Hashing: The Most Common Password Mistake

A critical clarification:

| Encryption | Hashing |
|—-|—-|
| Reversible | One-way |
| Requires key | No key |
| Bad for passwords | Correct for passwords |


Why passwords must NOT be encrypted

  • Encrypted passwords can be decrypted
  • Key compromise = full credential leak
  • Violates OWASP recommendations

What secure password storage requires

  • One-way hashing
  • Unique salt
  • Computational cost

Spring Security solves this with BCrypt.

3. Password Hashing That Actually Works (BCrypt)

Configuration

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}


Password creation

String hashed = passwordEncoder.encode("password");

Password verification

passwordEncoder.matches(rawPassword, storedHash);
  • Salted
  • Slow by design
  • Resistant to brute force
  • Industry standard

4. JWT: Why Tokens Instead of Sessions?

JWT (JSON Web Tokens) enable stateless authentication.

Session-based authentication

  • The server stores the session
  • Hard to scale
  • Stateful

JWT-based authentication

  • The server does NOT store the session
  • Token is self-contained
  • Horizontally scalable

JWT is not “better” — it’s better for APIs and distributed systems.

5. JWT Token Generation (Core Logic)

JwtService.java

package com.example.jwtdemo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
@Service
public class JwtService {
    private static final String SECRET = "this-is-a-very-secure-secret-key-which-is-at-least-256-bits";
    private static final long EXPIRATION = 1000 * 60 * 60;
    private Key getSignKey() {
        return Keys.hmacShaKeyFor(SECRET.getBytes());
    }
    public String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .compact();
    }
    public String extractUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

  • Signed
  • Expiring
  • Tamper-proof
  • Stateless

6. Why You Need a JWT Filter (Most Tutorials Miss This)

Spring Security does NOT automatically validate JWTs.

You must:

  1. Extract token from header
  2. Validate signature
  3. Extract user
  4. Populate Security Context

This must happen before controllers run.

7. JWT Filter Using OncePerRequestFilter

JwtAuthFilter.java

package com.example.jwtdemo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    public JwtAuthFilter(JwtService jwtService) {
        this.jwtService = jwtService;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) {
        try {
            String authHeader = request.getHeader("Authorization");
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                String jwt = authHeader.substring(7);
                String username = jwtService.extractUsername(jwt);
                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(username, null, null);
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

  • Executes once per reques
  • Stateless
  • Thread-safe
  • Clean separation of concerns

8. Spring Security Configuration (Correct & Minimal)

SecurityConfig.java

package com.example.jwtdemo.config;
import com.example.jwtdemo.security.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
    private final JwtAuthFilter jwtAuthFilter;
    public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Why this matters

  • CSRF is disabled only because the API is stateless
  • Sessions disabled explicitly
  • JWT filter runs before authentication
  • Public and protected endpoints are clear

9. Login Endpoint with Password Hashing

AuthController.java

package com.example.jwtdemo.controller;
import com.example.jwtdemo.security.JwtService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
    private final JwtService jwtService;
    private final PasswordEncoder passwordEncoder;
    private String storedUser = "admin";
    private String storedPassword;
    public AuthController(JwtService jwtService, PasswordEncoder passwordEncoder) {
        this.jwtService = jwtService;
        this.passwordEncoder = passwordEncoder;
        this.storedPassword = passwordEncoder.encode("password");
    }
    @PostMapping("/login")
    public String login(@RequestParam String username,
                        @RequestParam String password) {
        if (storedUser.equals(username) && passwordEncoder.matches(password, storedPassword)) {
            return jwtService.generateToken(username);
        }
        throw new RuntimeException("Invalid credentials");
    }
}
  • The password is never stored in plain text
  • Token generated only after verification
  • Clean responsibility boundaries

10. Authentication Flow (End-to-End)

Client → POST /auth/login

→ Password verified

*→ JWT generated*

Client → API request

→ Authorization: Bearer <JWT>

→ JwtAuthFilter validates the token

→ SecurityContext populated

→ Controller executes

No session. n No server-side token storage. n Fully stateless. n

11. Login API – Input & Output

Request

POST http://localhost:8080/auth/login?username=admin&password=password

Body

username=admin

password=password

Response (Output)

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTc2OTc2OTMxMSwiZXhwIjoxNzY5NzcyOTExfQ.XbRQxBnuybpyJ7noMOLsg7Z6saeOAwE_D_5nK7ZFnNI

Liked Liked