CodeGym /Courses /Spring Security /Configuring JwtDecoder

Configuring JwtDecoder

Spring Security
Level 25, Lesson 2
Available

1. The role of JwtDecoder and explicit configuration

If you are seeing the word JwtDecoder for the first time, it is very easy to imagine “just another magical bean that needs to be enabled somewhere so that it stops complaining.” But in reality, things are much simpler here, and even pleasant: JwtDecoder is a specific point where we honestly tell the application which rules it should use to trust the incoming token. Explicit configuration is needed so that we do not “search for the key across the whole project,” do not guess why a token is sometimes accepted and sometimes not, and do not turn security into a lottery.

The key problem with the built-in JWT path is that it cannot guess your cryptography and your token contract. Spring Security is ready to do all the infrastructural work, but it needs to know two things. First, what to use to verify the signature (secret/public key). Second, which basic checks are mandatory (for example, the expiration time exp). So JwtDecoder is literally an “embedded notary”: it takes the token string, checks the signature, checks the minimum validity conditions, and returns a normal Jwt object that can be trusted further down the chain.

It is important not to confuse JwtDecoder with what we did when issuing the token. In our project, the token is issued by TokenService, while JwtDecoder handles only incoming requests. It is convenient to think of this as a division of labor: one component “prints passes” (token issuing), the other “checks passes at the entrance” (token validation).

Below is a small table that usually cures the “where does JWT even live in the application” headache almost immediately:

Area Component What it does What it does NOT do
Token issuance TokenService Builds claims, signs the JWT, returns the string to the client Does not validate incoming tokens
Token validation JwtDecoder Verifies the signature and basic validity, returns Jwt Does not log the user in, does not assign authorities
Authorization rules / method security Decides “allowed / not allowed” based on Authentication and authorities Does not verify the token signature

2. The mental model of JwtDecoder in the chain

Before writing code, it is helpful to stop for a minute and “put a picture in your head.” Spring Security in a servlet/MVC application thinks in terms of one basic story: a request arrives, filters and providers try to build Authentication, and then authorization compares Authentication with the rules. JwtDecoder sits exactly where we need to turn a “Bearer string” into a “verified JWT object,” and then into Authentication. In other words, JwtDecoder is not a filter and not a controller, but an infrastructural component inside the authentication part.

Imagine that the request brings you a string like this:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...

At this stage, the application has only text. But we need answers to three boring, yet critically important questions. First, is the token not broken? Second, is it signed with the key we trust? Third, has it not expired yet (not past the exp date)? These are exactly the questions that the decoder (and its validators) handle.

It is useful to remember the chain as a diagram. It is heavily simplified, but at the course level it is exactly what we need:

flowchart TD
    A[HTTP Request] --> B[BearerTokenAuthenticationFilter]
    B --> C[JwtAuthenticationProvider]
    C --> D[JwtDecoder]
    D --> E[Jwt]
    E --> F[JwtAuthenticationConverter]
    F --> G[JwtAuthenticationToken]
    G --> H[SecurityContext]

And here there is an important fork in the road: JwtDecoder returns a Jwt (token data), not Authentication. So it is responsible for token verification and unpacking, but not for turning claims into authorities — that is the converter’s job (later in today’s lecture).

3. Where to store JwtDecoder and the key

We are about to write code, and this is where a beginner usually runs into the classic trap: “I’ll just quickly put the secret here... and here... and here...”. Two days later, tokens are being signed with one key and validated with another, and finding the bug turns into archaeology. That is why the right place for JwtDecoder is your security/config layer, and the key (or secret) should appear in the application in only one form: as one bean used by both TokenService and JwtDecoder.

In our project, Secure Content Platform API, we follow the package structure from the course specification, so the built-in JWT branch configuration naturally lives here:

  • com.example.securecontent.security.config — security configs
  • (optional) com.example.securecontent.security.jwt — everything related to tokens

A good practice for a learning project looks like this: you have JwtProperties (to read settings from application.yml), you have a SecretKey bean (the key is created once), and then there are two consumers of that key: the token issuing service and the token validation decoder.

And yes, this is one of those rare cases where I want to say: “do not be shy about DI.” Dependency Injection really saves you from accidental mismatches here. If the key is created in one place, the system is harder to break, and that is a plus for security.

4. Secret from configuration → SecretKey

Before creating JwtDecoder, we need a key. In our learning version, we use a symmetric secret (HMAC) because it is easier to understand: the same secret signs the token (in TokenService) and verifies the signature (in JwtDecoder). In more “adult” systems, there are often asymmetric keys (public/private), JWK, and all that, but we are not going there today — otherwise the lecture would turn into a 12-season series.

Let’s start with configuration. Suppose that in application.yml (or application-local.yml) we have a key like this:

app:
  security:
    jwt:
      # Take the secret from an environment variable so we do not store the real key in the repository
      # The default is only for local runs / a learning project
      secret: ${APP_SECURITY_JWT_SECRET:change-me-change-me-change-me-32-bytes}

The idea is simple: we do not keep the real secret in the repository, and for local runs we provide a default value. Ideally, the default secret should be long enough, otherwise you can get an error at the library level with HMAC algorithms. If you are thinking right now, “but can it be short? I am only learning,” yes, but then you will be learning from errors rather than from JWTs.

Now let’s create the properties class. In Spring Boot, it is convenient to do this with @ConfigurationProperties. I like records for their brevity: less code means fewer chances to make a mistake.

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.security.jwt")
// This record is a “container” for JWT settings from application.yml
public record JwtProperties(String secret) {
    // secret is the string representation of the secret (as received from configuration)
}

For such a record to actually be injectable into the config, it must become a bean. Usually this is solved in one of two familiar ways: either the application has @ConfigurationPropertiesScan enabled, or the class is explicitly registered via @EnableConfigurationProperties(JwtProperties.class). What matters here is the registration itself: without it, JwtProperties will not make it into JwtKeyConfig.

Now let’s create a configuration that “pulls” the secret from JwtProperties and creates a SecretKey. Here it is important to be consistent: if you treat the secret as a string, then both TokenService and the decoder must use it in the same way. The most common mistake is that one component uses UTF-8 bytes, while another uses Base64 decode, and the keys end up different even though “visually” the string is the same.

A simple learning version (secret as a plain string):

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@Configuration
public class JwtKeyConfig {

    @Bean
    SecretKey jwtSecretKey(JwtProperties props) {
        // Important: fix one consistent way to turn a string into bytes
        // If this is UTF-8 here, then the same must be used in TokenService.
        byte[] bytes = props.secret().getBytes(StandardCharsets.UTF_8);

        // "HmacSHA256" is the algorithm name for SecretKeySpec (do not confuse it with JWT "HS256")
        return new SecretKeySpec(bytes, "HmacSHA256");
    }
}

If you use this approach, you must use jwtSecretKey in token signing as well. Then everything will match, and JwtDecoder will be able to verify the signature.

A separate micro-comment on “HmacSHA256.” This is not a JWT algorithm in the terms of HS256 (although it is logically related), but the algorithm name for SecretKeySpec. At this point, you usually want to ask: “why is it so complicated?” The answer: because the cryptography world lives by its own historical standards, and here we are just building a bridge between a “secret” and a “key object.”

5. NimbusJwtDecoder and SecurityFilterChain

Now we have a key, and it is time to assemble the decoder. In Spring Security, the typical JwtDecoder implementation is NimbusJwtDecoder. It is not “magical”; it simply uses Nimbus JOSE + JWT under the hood and knows how to do what is needed for the resource server scenario.

A minimal JwtDecoder bean looks like this:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import javax.crypto.SecretKey;

@Configuration
public class JwtDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
        // Create a decoder that will verify the signature of incoming tokens with this key
        return NimbusJwtDecoder.withSecretKey(jwtSecretKey).build();
    }
}

At this stage, the decoder already knows how to verify the signature, and that is enough for the chain to “catch” the token as a JWT. The next important step is to make sure that SecurityFilterChain actually uses this decoder. Yes, sometimes Spring Boot can automatically pick up the bean by type, but in a learning project I prefer explicitness: the student should be able to see with their own eyes what exactly the application uses to validate the token.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain apiSecurity(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
        // Explicitly tell the resource server part: "use this JwtDecoder"
        http.oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.decoder(jwtDecoder))
        );

        return http.build();
    }
}

Pay attention to one nuance: if your custom JWT filter from the previous day is still alive in the project, you should not “leave it just in case.” In the end, you will get a situation where “two guards at the door are arguing whose guest list is more correct,” and, as usual, the developer will be to blame. On one set of endpoints, there should be one active bearer authentication path.

Another important nuance: stateless. The decoder itself does not make the application stateless. Stateless is a session management policy and the overall model. So in a real config you usually see this together:

import org.springframework.security.config.http.SessionCreationPolicy;

// ...
// Disable sessions: each request must be authenticated only by token
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

But today we are not re-explaining stateless — we are simply noting that the decoder is part of the stateless chain, not a separate checkbox.

6. How to test JwtDecoder

After configuring the decoder, you naturally want to “make sure with your own hands” that it is alive. And here there is an important psychological trap: you may get 401, and it may seem that the decoder is not working. But in reality, the decoder is working perfectly — the token is just invalid. So the test must be meaningful: we take the token issued by our POST /api/auth/login endpoint and use it to hit a protected endpoint.

The simplest practical test at the project level is to create a “debug” endpoint in the /api/me area that returns what exactly reached the SecurityContext. For example, at the controller level you can temporarily accept @AuthenticationPrincipal Jwt (this is convenient specifically for checking the decoder because it shows the decoded token, not the raw header).

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
public class MeDebugController {

    @GetMapping("/api/me/debug/jwt-sub")
    public String subject(@AuthenticationPrincipal Jwt jwt) {
        // This endpoint is useful for debugging: it shows the "sub" claim from an already verified token
        // In a production project, such endpoints are usually removed or access is restricted.
        return jwt.getSubject();
    }
}

If the decoder works, then when you send a request with a valid Bearer token, you will get the subject (sub) from the token. If the token is missing, the request will stop earlier (usually 401). If the token is expired, it will also be 401. If the token is signed with the wrong key, again 401. And that is good: your controller should not be responsible for token validity.

Now about what the decoder does not do (it is important to say this explicitly, otherwise the next lecture will hurt more). The decoder is not required to understand your roles, authorities, or business logic. It does not turn claims into draft:publish and does not add ROLE_ADMIN. It simply returns a Jwt, and then the converter builds Authentication. So the typical symptom “the token is valid, but I still get 403” usually means not a decoder problem, but an authorities mapping problem.

And one more small sanity check for beginners: the decoder has nothing to do with the login endpoint. If the login endpoint works, that does not mean the decoder is configured. And vice versa: the decoder can be perfect even if your login endpoint is broken. These are different parts of the system.

7. Typical mistakes when configuring JwtDecoder

Mistake #1: tokens are signed with one key and checked with another (or with the same one, but encoded differently).
This is the most common story. You store the secret as a string, in TokenService you call getBytes(UTF_8), and in the decoder you accidentally call Base64.getDecoder().decode(secret) — and the keys become different. On the surface, “the secret is the same,” but in practice the signature will never pass verification, and you will see 401 for every request with a Bearer token.

Mistake #2: the secret is too short, and you get strange errors “out of nowhere.”
HMAC has key size requirements, and if the secret is short, some libraries complain rather bluntly, while others are a bit more mysterious. A beginner often concludes, “Spring Security is broken,” although in reality they simply asked the system to sign tokens with a key at the “qwerty” level. In a learning project, it is best to build the habit right away: the secret should be long and stored in external configuration.

Mistake #3: JwtDecoder is hidden somewhere in a random helper class, and a week later nobody understands what validates the token.
Security configuration should be readable. If the decoder is created in a utility class “because the code was already there,” then you lose the main value of the built-in path: architectural clarity. It is much better to keep JwtDecoder in security/config and explicitly plug it into the DSL via decoder(jwtDecoder).

Mistake #4: you enabled the built-in JWT path but forgot to disable (or remove from the chain) the custom JWT filter.
As a result, some requests go through your filter, while others go through the built-in chain, and you get strange effects: sometimes the authorities are “doubled,” sometimes the SecurityContext is overwritten, sometimes the errors look unpredictable. On one active set of endpoints there should be one owner of bearer authentication, otherwise the project turns into a farce where nobody is to blame, but everything is on fire.

Mistake #5: expecting JwtDecoder to solve authorization.
The decoder decides whether the token can be trusted and whether claims can be extracted from it safely. It does not decide whether this user can publish a draft. If, after connecting the decoder, the token starts being accepted but you get 403 in editor/admin areas, this very often means that the claims are not being converted into the right authorities. And that is not a decoder bug, but the next step: configuring claim-to-authority mapping.

1
Task
Spring Security, level 25, lesson 2
Locked
Explicit JwtDecoder Based on a Ready SecretKey
Explicit JwtDecoder Based on a Ready SecretKey
1
Task
Spring Security, level 25, lesson 2
Locked
SecretKey and JwtDecoder from application.yml
SecretKey and JwtDecoder from application.yml
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION