Skip to main content

The Mystery of the 403 Login: Understanding Spring Security's Two-Stage Authentication

Β· 11 min read
Mahmut Salman
Software Developer

"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:

  1. "I added /auth/login to .permitAll()"
  2. "So JwtFilter shouldn't run on it"
  3. "But it's still blocked!"
  4. "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:

  1. Specific paths first (.permitAll(), .hasRole())
  2. General rule last (.anyRequest().authenticated())
  3. Paths must match EXACTLY (including /api prefix!)

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​

ComponentRuns OnDecisionCan Block?
JwtAuthenticationFilterEvery request"Is this token valid?"No - always continues
SecurityConfigEvery request"Is this user allowed?"Yes - can return 403

My Key Takeaways​

  1. Filters gather information, SecurityConfig decides access

    • Filters don't skip endpoints
    • They run on everything
    • SecurityConfig determines what's allowed
  2. .permitAll() means "allow without authentication"

    • NOT "skip the filter"
    • NOT "don't check security"
    • Just "authentication not required for this path"
  3. Path matching must be EXACT

    • /auth/login β‰  /api/auth/login
    • Always check your actual request paths!
    • Use logging to verify
  4. Order matters in SecurityConfig

    • Specific rules first
    • General rules last
    • First match wins
  5. 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!