CodeGym /Kursy /Spring Security /JWT: Bearer -> SecurityContext

JWT: Bearer -> SecurityContext

Spring Security
Poziom 25, Lekcja 1
Dostępny

1. Two worlds: token and current user

When you first add built-in JWT support, it can feel like Spring Security is doing something mystical: yesterday we just had a string in a header, and today the controller suddenly has access to JwtAuthenticationToken, @AuthenticationPrincipal Jwt, hasAuthority(...) starts working... and all of this without your filter. To avoid treating it like magic, it helps to split it into two worlds: the world of the HTTP request and the world of Spring Security inside the application.

In the HTTP world, we have headers, method, path, body. There, the token is just a string after the word Bearer. In the Spring Security world, the token turns into a concrete object of type Authentication, which “lives” in SecurityContext only for the duration of request processing. The built-in JWT path is a neat pipeline that handles exactly the translation from one world to another: “string from header” -> “verified token” -> “authenticated user for the current request”.

To see this pipeline as a whole, keep this diagram in mind:

flowchart TD
  R[HTTP request] --> H["Authorization: Bearer ..."]
  H --> F[BearerTokenAuthenticationFilter]
  F --> AM[AuthenticationManager]
  AM --> P[JwtAuthenticationProvider]
  P --> D[JwtDecoder]
  D --> J[Jwt]
  J --> C[JwtAuthenticationConverter]
  C --> AT[JwtAuthenticationToken]
  AT --> SC[SecurityContext]
  SC --> MVC["Controller / Service / @PreAuthorize"]

On the left is the “raw request”, on the right is the “current user inside the application”. Our entire lecture is a journey of the token along this route.

2. Bearer token and MVC boundaries

If you are used to a “normal” application, your hand may sometimes reach for request.getHeader("Authorization") in the controller and then for parsing the string with regular expressions. In educational projects this often looks like “well, it works”. But in the Spring Security world this is exactly the case where “works” does not mean “correct”: you are moving the security logic to the MVC level, breaking the unified error handling mechanism for 401/403, and turning security into a set of random decisions spread across controllers.

The right idea is this: controllers and services should work not with the header, but with the result of authentication. The header is input for the security layer. And that is exactly why the built-in path starts working before reaching MVC, inside filters.

To make it concrete, here is what a request looks like “on the ground”:

# -i: show response headers (useful for inspecting 401/403)
curl -i \
  # Pass the Bearer token in the standard Authorization header
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  # Protected endpoint (if authenticated() is required, the token must be sent with every request)
  http://localhost:8080/api/me

Pay attention to an important detail: if you built a stateless system, then “log in once and then keep going without a token” will no longer work. Browser magic with cookies/sessions does not help here. Every request that requires authenticated() must carry a Bearer token — otherwise there will be no current user.

3. BearerTokenAuthenticationFilter: extracting the token

The built-in JWT path starts with a filter with a very telling name: BearerTokenAuthenticationFilter. It does exactly two things, and that is precisely why it is so elegant: it extracts the token from the request and starts authentication without trying to be “everything at once”.

At the logic level, it looks roughly like this (simplified pseudocode, just to see the idea rather than argue about API details):

// 1) Extract the token from the request (usually from Authorization: Bearer ...)
String token = bearerTokenResolver.resolve(request);

if (token != null) {
    // 2) "Wrap" the raw token in an Authentication request (not trusted yet)
    Authentication authRequest = new BearerTokenAuthenticationToken(token);

    // 3) Hand the request to AuthenticationManager: it will find the right Provider and validate the token
    Authentication authResult = authenticationManager.authenticate(authRequest);

    // 4) Put the result (already authenticated Authentication) into the SecurityContext of the current request
    SecurityContextHolder.getContext().setAuthentication(authResult);
}

// 5) Always continue the filter chain: the next step will decide whether authenticated() is needed or the endpoint is public
chain.doFilter(request, response);

Here it is important to understand the “behavior without a token”. If the request has no Authorization header or it does not contain Bearer ..., the filter does not have to fail. It just lets the request continue. And then authorizeHttpRequests decides what to do: if the endpoint is public — okay; if it requires authenticated() — you get 401.

Another important detail is that the filter does not have to “guess” where to get the token from. That is the job of BearerTokenResolver (usually, by default, it reads the Authorization header). And that is exactly the place where Spring Security says: “let’s not force everyone to write the same code by hand”.

4. AuthenticationManager and JwtAuthenticationProvider

At this stage, it is useful to remember an old course mantra: the filter does not validate credentials by itself. It only “submits an authentication request” and passes it to AuthenticationManager. For username/password we had DaoAuthenticationProvider. For the JWT model there is its own provider: JwtAuthenticationProvider.

From the standpoint of the mental model, it is all very similar:

- the filter creates an “input Authentication” (not trusted yet),
- AuthenticationManager selects the provider that understands this Authentication type,
- the provider returns an “output Authentication” (already authenticated).

In the JWT world, the “input” object usually carries the raw token string, and the “output” becomes JwtAuthenticationToken.

It is useful to fix this in a small table so you do not get confused:

Stage What kind of object is it Do we trust it Why is it needed
Before authentication BearerTokenAuthenticationToken No To deliver the token to AuthenticationManager
After decoding Jwt Yes (if the signature/lifetime are valid) To store claims in a convenient form
Final JwtAuthenticationToken Yes To give the application current user + authorities

Important: it is exactly JwtAuthenticationToken that will live in SecurityContext. And it is exactly what @PreAuthorize, hasAuthority(...), Authentication auth in the controller, and any other security mechanisms will see.

5. JwtDecoder: trust in the token

Before JwtDecoder, the token is just a string. You can read it, pass it along, or even parse the base64 parts, but you cannot trust the contents. JwtDecoder does two key things: it turns the JWT into a Jwt object with claims and verifies that the token can actually be trusted — the signature matches, the lifetime has not expired, and the format is intact.

The responsibility boundary matters here: the decoder is responsible for trusting the token, not for access rights. If the signature, lifetime, or format fail validation, the request will not reach the controller and will not turn into a current user.

A separate practical note: if the login endpoint signs the token with one key, but JwtDecoder is configured with another, you will get an endless “invalid token” and 401. This is not “Spring Security being picky”, it is simply being honest: “the signature does not match — I do not trust it”.

6. JwtAuthenticationConverter: user and rights

After JwtDecoder, we have a Jwt object — decoded and verified. But the application usually needs not just a set of claims, but something understandable: an Authentication that has a name and authorities. That is handled by JwtAuthenticationConverter.

It takes the Jwt, turns claims into a set of GrantedAuthority, and assembles the final JwtAuthenticationToken. Here it is important not to mix up two entities: Jwt is the token contents, while JwtAuthenticationToken is the authentication object that @PreAuthorize, hasAuthority(...), Authentication auth in the controller, and other security mechanisms work with.

For now, it is enough to remember one boundary: the decoder is responsible for trusting the token, and the converter is responsible for how claims become a user with a set of rights. Therefore, the situation “the token is valid, but I still get 403” most often belongs to mapping claims -> authorities, not to signature verification.

7. SecurityContext in stateless mode

A very common “phantom pain” in the JWT world sounds like this: “I already logged in! Why does the next request not remember me?” In the session model, the answer was: “because you have JSESSIONID, and the server remembers state”. In the stateless model, the answer is different: “because SecurityContext is not preserved between requests”.

In the built-in JWT path, SecurityContext is created/populated for the duration of a specific request, usually stored in a thread-local through SecurityContextHolder, and cleared after the response is formed. This matters not only as theory: it directly explains why the token must be sent with every request and why “a SecurityContext accidentally left somewhere” is a bug and a potential vulnerability.

This mechanism is easy to picture like this:

sequenceDiagram
  participant C as Client
  participant FS as "Security Filter Chain"
  participant SC as SecurityContextHolder
  participant MVC as "Controller/Service"

  C->>FS: "Request (+ Bearer token)"
  FS->>SC: setAuthentication(JwtAuthenticationToken)
  FS->>MVC: call controller/service
  MVC->>SC: read current user / authorities
  MVC-->>FS: return response
  FS->>SC: clearContext()
  FS-->>C: Response

The point is that SecurityContext is the “context of the current processing”, not “memory of the user forever”. In a stateless model, memory lives in the token on the client side, not in a server session.

8. Accessing the current user in code

When the security chain has done its job, controllers and services already get a complete picture of the world: “here is the current user, here are their rights”. And here we get a nice bonus of the built-in path: you can receive not only the abstract Authentication, but also concrete JWT objects without manually parsing headers.

The most “transparent” way is to accept JwtAuthenticationToken in the method:

import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeNameController {
    @GetMapping("/api/me/name")
    String name(JwtAuthenticationToken auth) {
        // auth is already the "final" Authentication that was placed into SecurityContext
        // getName() often (but not always) corresponds to subject (sub)
        return auth.getName();
    }
}

If you need the token claims themselves, it is convenient to get Jwt as the principal:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeSubController {
    @GetMapping("/api/me/sub")
    String sub(@AuthenticationPrincipal Jwt jwt) {
        // jwt is the decoded and validated token with all claims
        return jwt.getSubject(); // for example "alice"
    }
}

Authorities live inside JwtAuthenticationToken, and these are what hasAuthority(...), hasRole(...), and @PreAuthorize check later. If a request comes with a valid token but runs into 403, the first thing to inspect is not the controller or the header, but how claims were turned into authorities inside the authentication chain.

9. Common mistakes when working with built-in JWT

Mistake #1: reading the Authorization header in the controller and parsing the token manually.
This quickly seems like a “simple solution”, but in practice you throw Spring Security out of the process: errors stop being uniform, @PreAuthorize does not see the user, and parts of the endpoints live by one set of rules while the rest live by another. The built-in path is good precisely because it moves parsing/validation into the security layer.

Mistake #2: thinking that JwtDecoder “assigns rights” and being surprised by 403.
JwtDecoder is about trusting the token (signature, lifetime) and obtaining claims. Authorities appear later, at the JwtAuthenticationConverter stage. Therefore, the situation “the token is valid, but there are not enough rights” almost always belongs to the converter and claims mapping, not to the decoder.

Mistake #3: confusing Jwt and JwtAuthenticationToken.
Jwt is the “token data”, while JwtAuthenticationToken is the final Authentication object that Spring Security works with. If in one place you expect Jwt, and in another you use Authentication, but logically treat them as “the same thing”, strange casts and unpredictable ClassCastException errors begin.

Mistake #4: expecting that after one successful request you can make the next one without a token.
In the stateless model, SecurityContext lives only for the duration of one request and is cleared after the response. If the next request does not bring a Bearer token — from the server’s point of view you are anonymous again. That is not a “bug” and not “Spring Security forgot the user”; that is the whole point of stateless.

Mistake #5: logging the Bearer token in full “for debugging”.
The desire is understandable: “I’ll just take a look”. But a Bearer token is essentially a key to the door: whoever steals the string gets in. In logs, especially in team/CI environments, such strings love to “accidentally” leak. For debugging, it is usually enough to log sub, jti (if present), and the fact of an error, but not the whole token.

1
Zadanie
Spring Security, poziom 25, lekcja 1
Niedostępne
Imię bieżącego użytkownika przez JwtAuthenticationToken
Imię bieżącego użytkownika przez JwtAuthenticationToken
1
Zadanie
Spring Security, poziom 25, lekcja 1
Niedostępne
Odczyt claims przez @AuthenticationPrincipal Jwt
Odczyt claims przez @AuthenticationPrincipal Jwt
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION