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:
- Extract token from header
- Validate signature
- Extract user
- 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