Why Spring Security returns 401 or 403 — and how to know the real reason (Web + Reactive)

Why Spring Security returns 401 or 403 — and how to know the real reason (Web + Reactive)

If you’ve worked with Spring Security long enough, you’ve probably seen this situation:

“Why am I getting 401 or 403, and how do I know what actually failed?”

The HTTP status code is technically correct — but the real root cause is hidden.

In this post, I’ll show how to systematically debug 401 / 403 issues in both Spring MVC (Servlet) and Spring WebFlux (Reactive) applications.


1. 401 vs 403 — what Spring Security really means

Before debugging, it’s important to understand how Spring Security interprets these responses:

Status Meaning (Security perspective)
401 Authentication failed or missing
403 Authentication succeeded, but access was denied

Important:
Spring Security makes this decision before your controller is ever called.

If you don’t know which step rejected the request, debugging quickly becomes guesswork.


2. The real decision flow (simplified)

A request goes through Spring Security in this order:

  1. Security filter chain
  2. Authentication
  3. Authorization
  4. Controller

A failure at any of these steps results in either 401 or 403.

So the real question is:

Which step rejected my request?


3. Enable the ONE log that actually helps

Spring MVC (Servlet)

In most cases, this single line already tells you what went wrong:

logging.level.org.springframework.security=DEBUG

With this enabled, you’ll see:

  • Which filter handled the request
  • Whether authentication was attempted
  • Why authorization failed

Typical messages look like:

Denied access because no granted authority matched

or

Failed to authenticate since token was expired

Spring WebFlux (Reactive)

Reactive security is much quieter by default.

Enable both of these:


logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security.web.server=DEBUG

Without them, you often only see:

403 Forbidden

with no explanation at all.


4. Common 401 root causes (not the obvious ones)

Token exists, but is rejected

  • Token expired
  • Signature mismatch
  • Wrong issuer or audience
  • Clock skew issues

Debug tip:
Log the decoded JWT before authentication fails.


Authentication filter never runs

This is more common than people expect:

  • Wrong request matcher
  • Security chain order issue
  • Missing BearerTokenAuthenticationFilter

Result: Spring treats the request as anonymous → 401


5. Common 403 root causes (the tricky ones)

Role exists, but doesn’t match

Example:

.hasRole("ADMIN")

But the token contains:

ROLE_ADMIN

or:

admin

Spring Security is very strict about role and authority matching.


Authorities not mapped correctly (JWT)

If you don’t explicitly map JWT claims to authorities:

  • Authentication succeeds
  • Authorization silently fails

Result:

  • ✅ authenticated
  • ❌ authorized → 403

6. Why Reactive (WebFlux) feels harder to debug

Two main reasons:

  1. No thread-local security context
  2. Errors happen inside reactive operators

By the time you see the response, the original cause is often already swallowed.

You must log earlier, inside:

  • Authentication converter
  • Authorization manager

7. My personal debugging checklist

When I see 401 or 403, I always ask:

  1. Did authentication run at all?
  2. Was the token parsed successfully?
  3. What authorities were extracted?
  4. Which rule denied access?

If I can’t answer any of these, the system is under-logged.


Final thoughts

401 and 403 are symptoms, not explanations.

Spring Security is deterministic — once you see where the decision was made, the fix is usually obvious.

If you’re debugging blind, the problem is almost always insufficient logging.


What’s next:
In upcoming posts, I’ll cover:

  • Logging the exact reason Spring denied access
  • JWT role-mapping mistakes that cause 403
  • Security differences between MVC and WebFlux

Part of the Spring Boot SSO Series

❤️ Support This Blog


If this post helped you, you can support my writing with a small donation. Thank you for reading.


Comments

Popular posts from this blog

fixed: embedded-redis: Unable to run on macOS Sonoma

Copying MDC Context Map in Web Clients: A Comprehensive Guide

Reset user password for your own Ghost blog