The Mystery of the 403 Login: Understanding Spring Security's Two-Stage Authentication
"My login endpoint is returning 403 Forbidden! But I configured it as .permitAll() in SecurityConfig! Why is the JwtFilter still blocking it?" I spent 2 hours debugging this. Turns out, I had a fundamental misunderstanding of how Spring Security works. The JwtFilter doesn't "skip" endpoints - it runs on EVERYTHING. Let me explain the magic. π©β¨
The Bug That Confused Meβ
Everything was working fine. Then suddenly:
POST http://localhost:8082/api/auth/login
Body: {"email": "test@example.com", "password": "pass123"}
Response: 403 Forbidden β
My reaction: "WHAT?! Login is public! I configured it as .permitAll()!"
My SecurityConfig Looked Like Thisβ
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests()
.requestMatchers("/auth/login").permitAll() // β I SAID PERMIT ALL!
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
My thought process:
- "I added
/auth/loginto.permitAll()" - "So JwtFilter shouldn't run on it"
- "But it's still blocked!"
- "Is Spring Security broken?!"
Spoiler: Spring Security wasn't broken. I was wrong about how it works. π
The Discovery: Path Mismatch!β
After adding logging to my filter:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
System.out.println("π Filter running on: " + request.getRequestURI());
// ...
}
}
Console output:
π Filter running on: /api/auth/login
Me: "Wait... /api/auth/login? But I configured /auth/login!"
The bug:
// What I configured:
.requestMatchers("/auth/login").permitAll()
// What the actual path is:
GET /api/auth/login
β
Missing /api prefix!
Fixed version:
.requestMatchers("/api/auth/login").permitAll() // β
Now it matches!
But this raised a bigger question...
The Question That Led Me Down the Rabbit Holeβ
"How does SecurityConfig tell the JwtFilter which endpoints to skip? What's the magic behind
.permitAll()that makes the filter not run on login endpoints?"
My assumption: "SecurityConfig somehow signals to JwtFilter: 'Hey, don't run on these paths.'"
Reality: That's NOT how it works at all! π€―
The Truth: Two Separate Stagesβ
Spring Security operates in two stages, not one:
Client Request
β
βββββββββββββββββββββββββββββββββββββββββ
β STAGE 1: Filter Chain β
β (JwtAuthenticationFilter) β
β β
β Runs on EVERY request β
β Tries to authenticate if token exists β
β Doesn't decide if request is allowed β
βββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββ
β STAGE 2: Authorization β
β (SecurityConfig rules) β
β β
β Checks path against configured rules β
β Decides if request is allowed β
β Uses authentication from Stage 1 β
βββββββββββββββββββββββββββββββββββββββββ
β
Controller (if allowed)
Key insight: The filter runs on everything. SecurityConfig decides what's allowed.
Stage 1: The Filter Chain (Detective Work)β
What JwtAuthenticationFilter Doesβ
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// Step 1: Try to find Authorization header
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// Step 2: Extract token
String token = authHeader.substring(7);
// Step 3: Extract email from token
String email = jwtUtil.extractEmail(token);
// Step 4: Validate token
if (jwtUtil.validateToken(token, email)) {
// Step 5: Set authentication in SecurityContext
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
// β
Authentication set!
}
}
// Step 6: Continue filter chain (ALWAYS!)
filterChain.doFilter(request, response);
}
}
Important: Notice the last line - filterChain.doFilter(request, response) - ALWAYS executes!
What Happens on Different Requestsβ
Request 1: Login (no token)
POST /api/auth/login
Headers: (no Authorization header)
JwtAuthenticationFilter:
ββ Check: Authorization header?
β ββ NO β Skip authentication
ββ SecurityContext authentication: NOT SET
ββ Continue to next filter β
Request 2: Protected endpoint (with valid token)
GET /api/users
Headers: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
JwtAuthenticationFilter:
ββ Check: Authorization header?
β ββ YES β Extract token
ββ Validate token
β ββ Valid! β
ββ Load user details
ββ Set authentication in SecurityContext β
ββ Continue to next filter β
Request 3: Protected endpoint (no token)
GET /api/users
Headers: (no Authorization header)
JwtAuthenticationFilter:
ββ Check: Authorization header?
β ββ NO β Skip authentication
ββ SecurityContext authentication: NOT SET
ββ Continue to next filter β
Notice: The filter always continues! It doesn't block anything. It just gathers information.
Stage 2: Authorization (The Bouncer)β
After filters complete, Spring Security checks authorization:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests()
.requestMatchers("/api/auth/login").permitAll() // Rule 1
.requestMatchers("/api/auth/register").permitAll() // Rule 2
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Rule 3
.anyRequest().authenticated(); // Rule 4 (fallback)
return http.build();
}
How Authorization Matching Worksβ
Request comes in: GET /api/auth/login
β
Spring Security checks rules IN ORDER:
Rule 1: Does "/api/auth/login" match "/api/auth/login"?
β YES! β
β Check rule: .permitAll()
β Decision: ALLOW without checking authentication
β DONE! Request proceeds to controller
(Rules 2, 3, 4 are not checked because Rule 1 matched)
Request comes in: GET /api/users
β
Spring Security checks rules IN ORDER:
Rule 1: Does "/api/users" match "/api/auth/login"?
β NO β
Rule 2: Does "/api/users" match "/api/auth/register"?
β NO β
Rule 3: Does "/api/users" match "/api/admin/**"?
β NO β
Rule 4: Fallback rule .anyRequest().authenticated()
β Check: Is authentication set in SecurityContext?
β If YES β ALLOW β
β If NO β DENY 403 β
The Complete Flow: Before the Fixβ
Scenario: Login Request (BROKEN)β
POST /api/auth/login
Body: {"email": "test@example.com", "password": "pass123"}
Headers: (no Authorization header)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 1: JwtAuthenticationFilter β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Check: Authorization header exists? β
β β NO β
β β
β Action: Skip authentication β
β SecurityContext: authentication = NULL β
β β
β Continue to next filter β
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 2: SecurityConfig Authorization β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Request path: /api/auth/login β
β β
β Check Rule 1: "/auth/login".permitAll() β
β β Does "/api/auth/login" match "/auth/login"? β
β β NO β (missing /api prefix!) β
β β
β Check Rule 2: .anyRequest().authenticated() β
β β Requires authentication β
β β Is authentication set? NO β
β β
β Decision: DENY β β
β Response: 403 Forbidden β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β Login fails!
The Complete Flow: After the Fixβ
Scenario: Login Request (WORKING)β
POST /api/auth/login
Body: {"email": "test@example.com", "password": "pass123"}
Headers: (no Authorization header)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 1: JwtAuthenticationFilter β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Check: Authorization header exists? β
β β NO β
β β
β Action: Skip authentication β
β SecurityContext: authentication = NULL β
β β
β Continue to next filter β
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 2: SecurityConfig Authorization β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Request path: /api/auth/login β
β β
β Check Rule 1: "/api/auth/login".permitAll() β
β β Does "/api/auth/login" match "/api/auth/login"? β
β β YES! β
β
β β
β Rule: .permitAll() β
β β No authentication needed β
β β
β Decision: ALLOW β
β
β Response: Proceed to controller β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β
Login succeeds!
The Restaurant Analogyβ
Think of Spring Security like a restaurant with two layers:
Layer 1: The Host (JwtAuthenticationFilter)β
Guest arrives at restaurant
β
Host: "Welcome! Do you have a reservation card?"
β
Guest Option A: "Yes, here it is" (shows JWT token)
β
Host: "Let me verify this... (validates token)"
ββ Valid β "Great! I'll mark you as VIP Member" (sets authentication)
ββ Invalid β "This card is fake, but I'll still let you through to the next checkpoint"
β
Host: "Please proceed to the Maitre D'"
Guest Option B: "No, I don't have one" (no token)
β
Host: "That's okay, proceed anyway"
β
Host: "Please proceed to the Maitre D'"
Key point: The host doesn't decide who gets in. The host just checks for membership and records it.
Layer 2: The Maitre D' (SecurityConfig)β
Guest reaches Maitre D'
β
Maitre D' checks the door policy for this section:
Section: "Main Dining Room"
Policy sign: "WALK-INS WELCOME" (.permitAll())
β
Maitre D': "Come right in! No reservation needed" β
Section: "VIP Lounge"
Policy sign: "MEMBERS ONLY" (.authenticated())
β
Maitre D': "Let me check... did the Host mark you as VIP?"
ββ YES β "Welcome to VIP Lounge!" β
ββ NO β "Sorry, members only" β (403 Forbidden)
Section: "Executive Suite"
Policy sign: "EXECUTIVES ONLY" (.hasRole("ADMIN"))
β
Maitre D': "Let me check your membership level..."
ββ ADMIN role β "Welcome!" β
ββ Not ADMIN β "Sorry, executives only" β (403 Forbidden)
The host (filter) doesn't enforce the policy. The Maitre D' (SecurityConfig) does!
Why This Design Makes Senseβ
Separation of Concernsβ
JwtAuthenticationFilter:
Responsibility: Extract and validate JWT tokens
Decision: Is this token valid?
Action: Set authentication if valid
SecurityConfig:
Responsibility: Define access rules
Decision: Is this user allowed to access this path?
Action: Allow or deny request
Each component has ONE job!
Flexibilityβ
You can have multiple filters:
Filter Chain:
1. CorsFilter β Handle CORS
2. JwtAuthenticationFilter β Extract JWT
3. OAuth2Filter β Handle OAuth tokens
4. CustomHeaderFilter β Handle custom auth
5. ... (all run!)
Then SecurityConfig decides what's allowed based on ALL the authentication info gathered.
Debugging Advantageβ
Request fails with 403
β
Check: Did filter set authentication?
β If NO: Filter problem (token invalid, expired, etc.)
β If YES: Authorization problem (user lacks permission)
Clear separation makes debugging easier!
Common Misconceptions (That I Had!)β
β Misconception 1: "permitAll() skips the filter"β
Wrong!
.requestMatchers("/api/auth/login").permitAll()
// This DOES NOT mean:
// "Don't run JwtAuthenticationFilter on /api/auth/login"
// This MEANS:
// "Allow /api/auth/login even without authentication"
The filter still runs! It just doesn't matter if authentication is set or not.
β Misconception 2: "The filter decides what's allowed"β
Wrong!
The filter's job:
// β
Extract token
// β
Validate token
// β
Set authentication
// β Decide if request is allowed (NOT the filter's job!)
SecurityConfig's job:
// β
Check path against rules
// β
Check if authentication is required
// β
Decide if request is allowed
β Misconception 3: "Path order doesn't matter"β
Wrong!
// β BAD ORDER:
.anyRequest().authenticated() // Matches everything!
.requestMatchers("/api/auth/login").permitAll() // Never reached!
// β
GOOD ORDER:
.requestMatchers("/api/auth/login").permitAll() // Specific rules first
.anyRequest().authenticated() // General rule last
Spring Security checks rules from top to bottom and stops at the first match!
The Fixed SecurityConfig (Complete)β
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests()
// Public endpoints (NO authentication needed)
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/auth/register").permitAll()
.requestMatchers("/api/auth/unlock").permitAll()
// Admin endpoints (ADMIN role required)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// All other endpoints (authentication required)
.anyRequest().authenticated()
.and()
// Add JWT filter BEFORE UsernamePasswordAuthenticationFilter
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Key points:
- Specific paths first (
.permitAll(),.hasRole()) - General rule last (
.anyRequest().authenticated()) - Paths must match EXACTLY (including
/apiprefix!)
Debugging Checklistβ
When you get 403 on a public endpoint:
Step 1: Check Path Matchingβ
// Add logging to see actual request path:
System.out.println("Request URI: " + request.getRequestURI());
// Then verify it matches your SecurityConfig:
.requestMatchers("/api/auth/login").permitAll()
// β
// Make sure this EXACTLY matches request URI!
Step 2: Check Rule Orderβ
// β
CORRECT:
.requestMatchers("/api/auth/login").permitAll() // Specific first
.anyRequest().authenticated() // General last
// β WRONG:
.anyRequest().authenticated() // Matches everything!
.requestMatchers("/api/auth/login").permitAll() // Never reached
Step 3: Check Filter Logicβ
// In your filter, add logging:
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token, email)) {
// Set authentication
System.out.println("β
Authentication set for: " + email);
} else {
System.out.println("β Invalid token");
}
} else {
System.out.println("β οΈ No Authorization header");
}
Step 4: Check SecurityContextβ
// In your controller:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("Authentication: " + auth);
System.out.println("Is authenticated: " + (auth != null && auth.isAuthenticated()));
The Complete Request Flow (Visual)β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client β
β POST /api/auth/login β
β Body: {"email": "...", "password": "..."} β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Security Filter Chain β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1. CorsFilter β
β ββ Handle CORS headers β
β β
β 2. JwtAuthenticationFilter β
β ββ Check: Authorization header? β
β ββ If YES: Validate token, set authentication β
β ββ If NO: Continue (no authentication set) β
β β
β 3. UsernamePasswordAuthenticationFilter β
β ββ (skipped, not used with JWT) β
β β
β 4. FilterSecurityInterceptor β
β ββ Check request path against SecurityConfig rules β
β ββ Match: "/api/auth/login".permitAll() β
β ββ Decision: ALLOW β
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Controller β
β @PostMapping("/api/auth/login") β
β public ResponseEntity<LoginResponse> login(...) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Response β
β 200 OK β
β Body: {"token": "eyJhbGc...", "email": "..."} β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Summary: The Two-Stage Modelβ
Stage 1: Authentication (Filter)β
Question: "Who are you?"
Process:
1. Check for credentials (JWT token, OAuth token, etc.)
2. Validate credentials
3. Set authentication in SecurityContext
4. Continue to next stage
Decision: NEVER blocks (always continues)
Stage 2: Authorization (SecurityConfig)β
Question: "Are you allowed to access this?"
Process:
1. Check request path against configured rules
2. Check if authentication is required for this path
3. If required, check if authentication was set in Stage 1
4. Allow or deny based on rules
Decision: BLOCKS if not authorized
The Relationshipβ
| Component | Runs On | Decision | Can Block? |
|---|---|---|---|
| JwtAuthenticationFilter | Every request | "Is this token valid?" | No - always continues |
| SecurityConfig | Every request | "Is this user allowed?" | Yes - can return 403 |
My Key Takeawaysβ
-
Filters gather information, SecurityConfig decides access
- Filters don't skip endpoints
- They run on everything
- SecurityConfig determines what's allowed
-
.permitAll()means "allow without authentication"- NOT "skip the filter"
- NOT "don't check security"
- Just "authentication not required for this path"
-
Path matching must be EXACT
/auth/loginβ/api/auth/login- Always check your actual request paths!
- Use logging to verify
-
Order matters in SecurityConfig
- Specific rules first
- General rules last
- First match wins
-
Separation of concerns is beautiful
- Each component has ONE job
- Makes debugging easier
- Makes code more maintainable
To My Past Self (2 Hours Ago)β
Dear Past Me,
Stop banging your head against the desk. π
The JwtFilter is NOT blocking your login endpoint. SecurityConfig is.
You configured /auth/login but your request is /api/auth/login. Add the /api prefix.
Also, the filter doesn't "skip" endpoints. It runs on everything. SecurityConfig just says "this path doesn't need authentication."
You'll understand this after you add some logging and watch the request flow through both stages.
Trust me, you'll feel silly for not seeing this sooner. But hey, you learned something important about Spring Security's architecture!
Future You (2 hours later)
P.S. If you're reading this and thinking "this is obvious," congratulations - you already understand Spring Security! For those of us debugging 403 errors at midnight, this is a genuine revelation. πβ
Created: October 20, 2025 Debugging time: 2 hours Coffee consumed: 3 cups Facepalms: Too many Final emotion: π Enlightened!
