Spring Security + JWT: Protecting Your Application Like a Fortress
A complete guide to Spring Security and JWT authentication — covering the filter chain, JWT token flow, BCrypt password hashing, SecurityFilterChain configuration, method-level security with @PreAuthorize, OAuth2 basics, CORS, CSRF protection, and RBAC.
Why Security Matters: Locks on Your House
Imagine your application is a house. Without security, anyone can walk in, open your fridge, read your diary, and leave the door wide open. Spring Security is the full home security system — locks on every door, cameras watching every room, and a guard at the gate who checks IDs before letting anyone in.
Every web application faces three core security questions:
- Authentication: Who are you? (Show your ID at the door)
- Authorization: What are you allowed to do? (Guests can enter the living room, but only family goes upstairs)
- Protection: Are the walls strong enough? (Prevent break-ins like CSRF, XSS, and session hijacking)
Spring Security answers all three — and it does it with a powerful design called the Filter Chain.
The Security Filter Chain: Your Application's Assembly Line
Think of a factory assembly line. Every item (HTTP request) passes through stations (filters) in order. Each station checks one thing. If a check fails, the item gets rejected and never reaches the end. If it passes all stations, it arrives at your controller.
Here is the order Spring Security processes every request:
HTTP Request
|
v
[CorsFilter] -- Is this request from an allowed website?
|
v
[CsrfFilter] -- Does this request have a valid CSRF token?
|
v
[JwtAuthFilter] -- Does this request carry a valid JWT token?
|
v
[AuthorizationFilter] -- Does this user have permission for this endpoint?
|
v
[Your Controller] -- Finally! Process the business logic.
If any filter says "no," the request stops right there. Your controller never even sees it. This is defense in depth — multiple layers of protection, like a castle with walls, a moat, and archers on the towers.
JWT: Your Digital Movie Ticket
A JSON Web Token (JWT) is like a movie ticket. When you buy a movie ticket, it has your seat number, the show time, and the theater number printed on it. The usher at the door does not call the box office to verify you paid — they just look at your ticket, check that it is valid, and let you in.
JWT works the same way:
- You log in with your username and password (buy the ticket)
- The server gives you a JWT token (the ticket itself)
- Every future request, you show the token (flash the ticket at the usher)
- The server validates the token without checking the database (the usher reads the ticket)
A JWT has three parts separated by dots: header.payload.signature
// A JWT looks like this (three Base64 chunks separated by dots):
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwicm9sZXMiOlsiVVNFUiJdLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAzNjAwMH0.abc123signature
// Decoded payload:
{
"sub": "john", // subject (username)
"roles": ["USER"], // what this person can do
"iat": 1700000000, // issued at (when the ticket was printed)
"exp": 1700036000 // expires at (when the ticket stops working)
}
The signature is the security seal. The server signs the token with a secret key. If anyone tampers with the payload (tries to change "USER" to "ADMIN"), the signature will not match, and the server rejects it. It is like a hologram on your ticket — forge it, and you get caught.
The Complete JWT Authentication Flow
Let us walk through the entire login-to-access flow step by step.
Step 1: The User Entity
First, we need a User class that stores credentials in the database.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password; // stored as BCrypt hash, NEVER plain text
@Column(nullable = false)
private String role; // "USER", "ADMIN", "MODERATOR"
@Column(nullable = false)
private String email;
// getters, setters, constructors
}
Step 2: BCrypt Password Hashing
Storing passwords in plain text is like writing your PIN on your debit card. If someone steals the database, they get every password instantly. BCrypt is a one-way scrambler — it turns "mypassword123" into a long, random-looking string. Even if two users have the same password, BCrypt produces different hashes because it adds random "salt" each time.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 12 rounds of hashing
}
}
// What BCrypt does:
// "mypassword123" -> "$2a$12$LJ3m4ys8Hp5Rq.Xz9vKnet..."
// Same password again -> "$2a$12$R8pQw7Yt.N2mFkA5dB3kZu..." (different hash!)
When a user logs in, BCrypt does not "decode" the stored hash. Instead, it hashes the submitted password the same way and compares the results. If they match, the password is correct.
Step 3: UserDetailsService — Teaching Spring Who Your Users Are
Spring Security does not know where your users live — in a database? A file? An API? You tell it by implementing UserDetailsService.
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword()) // BCrypt hash from DB
.roles(user.getRole()) // "USER" becomes ROLE_USER
.build();
}
}
Step 4: JWT Utility — Creating and Validating Tokens
This class is the ticket printer and ticket checker combined.
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration:3600000}") // default: 1 hour in milliseconds
private long expiration;
// Print a new ticket
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// Generate a refresh token (longer-lived)
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 24)) // 24 hours
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// Read the username from the ticket
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Check if the ticket is still valid
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return resolver.apply(claims);
}
private Key getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
Step 5: JWT Authentication Filter — The Usher
This filter runs on every request. It checks: does this request have a valid JWT? If yes, it tells Spring Security "this person is authenticated." If not, it does nothing and lets the next filter handle it.
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
public JwtAuthFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. Get the Authorization header
String authHeader = request.getHeader("Authorization");
// 2. No token? Let the request continue (might be a public endpoint)
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 3. Extract the token (remove "Bearer " prefix)
String token = authHeader.substring(7);
String username = jwtUtil.extractUsername(token);
// 4. If we got a username and no one is authenticated yet...
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 5. Validate the token
if (jwtUtil.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
// 6. Tell Spring Security: this user is authenticated
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
Step 6: Authentication Controller — Login and Refresh
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
// constructor injection for all dependencies
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody RegisterRequest request) {
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword())); // BCrypt hash
user.setEmail(request.getEmail());
user.setRole("USER"); // default role
userRepository.save(user);
return ResponseEntity.ok("User registered successfully");
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
// Spring Security verifies username + password
authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
UserDetails userDetails = userDetailsService
.loadUserByUsername(request.getUsername());
String accessToken = jwtUtil.generateToken(userDetails);
String refreshToken = jwtUtil.generateRefreshToken(userDetails);
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest request) {
String username = jwtUtil.extractUsername(request.getRefreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.isTokenValid(request.getRefreshToken(), userDetails)) {
String newAccessToken = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(
new AuthResponse(newAccessToken, request.getRefreshToken()));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
SecurityFilterChain Configuration: Wiring It All Together
This is the master control panel. It tells Spring Security which endpoints are public, which require authentication, and where to put the JWT filter in the chain.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize and @Secured
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public SecurityConfig(JwtAuthFilter jwtAuthFilter,
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
this.jwtAuthFilter = jwtAuthFilter;
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Disable CSRF for stateless JWT APIs (no session = no CSRF risk)
.csrf(csrf -> csrf.disable())
// Configure CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Set session management to stateless (no server-side sessions)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Define which endpoints are public vs protected
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // login, register
.requestMatchers("/api/public/**").permitAll() // public content
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin only
.anyRequest().authenticated() // everything else needs auth
)
// Set the authentication provider
.authenticationProvider(authenticationProvider())
// Add JWT filter BEFORE Spring's username/password filter
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Method-Level Security: Fine-Grained Access Control
The SecurityFilterChain controls access at the URL level. But sometimes you need finer control — like saying "only admins can delete, but any authenticated user can read." That is where @PreAuthorize and @Secured come in.
@RestController
@RequestMapping("/api/posts")
public class BlogPostController {
private final BlogPostService postService;
// Anyone authenticated can read
@GetMapping
public List<BlogPost> getAllPosts() {
return postService.findAll();
}
// Only the post author or admins can update
@PreAuthorize("hasRole('ADMIN') or #postId == authentication.principal.id")
@PutMapping("/{postId}")
public BlogPost updatePost(@PathVariable Long postId,
@RequestBody BlogPost post) {
return postService.update(postId, post);
}
// Only admins can delete
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{postId}")
public void deletePost(@PathVariable Long postId) {
postService.delete(postId);
}
// Only moderators and admins
@Secured({"ROLE_MODERATOR", "ROLE_ADMIN"})
@PutMapping("/{postId}/approve")
public BlogPost approvePost(@PathVariable Long postId) {
return postService.approve(postId);
}
// SpEL expression: user can only see their own comments
@PreAuthorize("#username == authentication.name")
@GetMapping("/user/{username}/comments")
public List<Comment> getUserComments(@PathVariable String username) {
return postService.getCommentsByUser(username);
}
}
Key difference: @PreAuthorize uses SpEL (Spring Expression Language) and can access method parameters. @Secured is simpler — it only checks roles. Use @PreAuthorize when you need expressions, @Secured when you just need role checks.
Role-Based Access Control (RBAC): The Hierarchy of Permissions
RBAC is like the key card system in an office building. An intern's card opens the front door. A manager's card opens the front door plus the meeting rooms. The CEO's card opens everything. Each role grants a set of permissions, and higher roles include all the permissions of lower roles.
// Define roles as an enum
public enum AppRole {
USER, // can read posts, write comments
MODERATOR, // USER + approve/reject comments
ADMIN // MODERATOR + delete posts, manage users
}
// Role hierarchy configuration
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("MODERATOR")
.role("MODERATOR").implies("USER")
.build();
}
// Now @PreAuthorize("hasRole('USER')") allows MODERATOR and ADMIN too
CORS: Who Is Allowed to Talk to Your API?
Imagine your API is a private phone line. CORS (Cross-Origin Resource Sharing) is the caller ID system — it checks where the call is coming from and decides whether to pick up.
Without CORS configuration, browsers block requests from different domains. Your React app on localhost:3000 cannot talk to your Spring API on localhost:8080 unless you explicitly allow it.
// Option 1: Global CORS in SecurityConfig (shown above)
// Option 2: Per-controller CORS
@CrossOrigin(origins = "http://localhost:3000", maxAge = 3600)
@RestController
@RequestMapping("/api/posts")
public class BlogPostController {
// all endpoints in this controller allow localhost:3000
}
// Option 3: application.yml configuration
// spring:
// web:
// cors:
// allowed-origins: http://localhost:3000
// allowed-methods: GET, POST, PUT, DELETE
// allowed-headers: Authorization, Content-Type
CSRF Protection: Preventing Invisible Attacks
CSRF is like someone slipping a signed check into your pocket. You visit a malicious website, and it secretly sends a request to your bank using your existing session cookies. The bank thinks it is you because the cookies are valid.
For JWT-based APIs, CSRF is not a concern because we do not use cookies for authentication. The JWT is sent in the Authorization header, and browsers do not automatically attach headers like they do with cookies. That is why we disable CSRF in our config: .csrf(csrf -> csrf.disable()).
For session-based apps (server-rendered pages with Thymeleaf), CSRF protection is critical:
// Keep CSRF enabled for session-based apps
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
// In your Thymeleaf form, Spring adds the token automatically:
// <form th:action="@{/api/posts}" method="post">
// <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
// ...
// </form>
OAuth2 Basics: Let Google and GitHub Handle Login
OAuth2 is like using your driver's license to check in at a hotel. The hotel does not issue you an ID — they trust the government (Google, GitHub) that issued your license. You prove who you are through a trusted third party.
Spring Security makes OAuth2 login almost trivial:
// 1. Add the dependency
// <dependency>
// <groupId>org.springframework.boot</groupId>
// <artifactId>spring-boot-starter-oauth2-client</artifactId>
// </dependency>
// 2. Configure in application.yml
// spring:
// security:
// oauth2:
// client:
// registration:
// google:
// client-id: ${GOOGLE_CLIENT_ID}
// client-secret: ${GOOGLE_CLIENT_SECRET}
// scope: openid, profile, email
// github:
// client-id: ${GITHUB_CLIENT_ID}
// client-secret: ${GITHUB_CLIENT_SECRET}
// scope: user:email
// 3. Update SecurityFilterChain
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2Login(oauth -> oauth
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)))
.build();
}
The OAuth2 flow works in four steps: (1) User clicks "Sign in with Google," (2) Browser redirects to Google, (3) User approves, Google sends an authorization code back, (4) Your server exchanges the code for user info. Spring handles steps 2-4 automatically.
Complete application.yml Security Configuration
# application.yml
jwt:
secret: ${JWT_SECRET:myBase64EncodedSecretKeyThatIsAtLeast256BitsLong==}
expiration: 3600000 # 1 hour in milliseconds
refresh-expiration: 86400000 # 24 hours
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: validate # never use create/update in production
show-sql: false
# CORS settings
app:
cors:
allowed-origins: http://localhost:3000,http://localhost:3001
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
max-age: 3600
# Logging security events
logging:
level:
org.springframework.security: DEBUG # turn this off in production
com.example.security: INFO
Production Security Checklist
- Use HTTPS everywhere (never send JWTs over plain HTTP)
- Set short access token expiration (15-60 minutes)
- Store refresh tokens securely (httpOnly cookies or encrypted database)
- Use a strong, random JWT secret (at least 256 bits)
- BCrypt with 10-12 rounds for password hashing
- Rate limit login endpoints to prevent brute force attacks
- Log authentication failures for monitoring
- Never expose stack traces in error responses
- Validate all input — security starts at the request body
- Keep dependencies updated — security patches matter
Frequently Asked Questions
1. What is the difference between authentication and authorization?
Authentication is proving who you are — like showing your ID at the airport. Authorization is proving what you are allowed to do — like your boarding pass that says you can board flight 42 but not flight 99. Spring Security handles authentication first (via UserDetailsService and AuthenticationManager), then authorization (via SecurityFilterChain rules and @PreAuthorize). You must be authenticated before you can be authorized.
2. Why do we disable CSRF for JWT-based APIs?
CSRF attacks work by tricking your browser into sending cookies automatically with requests. Since JWT tokens are stored in memory (not cookies) and sent manually in the Authorization header, the browser cannot be tricked into sending them. No automatic cookies means no CSRF risk. However, if you store JWTs in cookies (which some applications do), you must keep CSRF protection enabled.
3. What happens when the access token expires?
When the access token expires, the server returns a 401 Unauthorized response. The client should then send the refresh token to the /api/auth/refresh endpoint to get a new access token — without asking the user to log in again. Think of it like this: the access token is a day pass at a theme park (short-lived), and the refresh token is your season pass (long-lived). When your day pass expires, you show your season pass to get a new day pass.
4. Should I use @PreAuthorize or @Secured?
Use @PreAuthorize in most cases because it supports SpEL expressions, which let you write conditions like "only the author of this post can edit it" (@PreAuthorize("#postId == authentication.principal.id")). Use @Secured when you only need simple role checks and want the annotation to be more readable. You cannot mix Spring Expression Language into @Secured — it only accepts role names.
5. How is BCrypt different from SHA-256 for password hashing?
SHA-256 is a general-purpose hash — it is fast by design, which means attackers can try billions of passwords per second. BCrypt is intentionally slow. It is designed specifically for passwords and includes a "cost factor" (the number of rounds) that makes each hash take longer. With BCrypt at 12 rounds, an attacker trying a billion passwords would take centuries instead of seconds. BCrypt also automatically adds a random "salt" to each hash, so two users with the same password get different hashes.