How to Secure a Spring Boot API with JWT (Without Writing a Filter)

Add the OAuth2 Resource Server starter, declare a JwtDecoder bean, and add one line to your filter chain — .oauth2ResourceServer(o -> o.jwt(...)). Spring Security then parses every bearer token, checks its signature and expiry, and binds the claims into your handler. You do not write a servlet filter, and you do not add a JWT library: the one doing the work is already on the classpath.

Search this question and nearly every answer hands you the same thing: a OncePerRequestFilter you write yourself, wrapped around a third-party JWT library, pulling the token out of a header and calling setAuthentication by hand. It works. It is also fifty lines of security-critical code that Spring Security already contains, and hand-written authentication is where authentication bugs live.

This article does it the other way. Two beans, one line of filter-chain configuration, and no filter of your own. Everything below was built and run on Spring Boot 4.1.1 with Spring Security 7.1.1 — every status code, header and token in it is a transcript.

What one dependency does before you write anything

Start from a project — creating one takes about thirty seconds — and add Spring Security. Before you write a line, run it:

Using generated security password: c433d7a9-38e0-435f-ac50-14aa0641ac3d
This generated password is for development use only. Your security
configuration must be updated before running your application in production.

Every endpoint is now closed. An anonymous request:

HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=8F9E6E716B98FE21596E2C879AB7B06D; Path=/; HttpOnly
X-Content-Type-Options: nosniff
Cache-Control: no-cache, no-store, max-age=0, must-revalidate

The same request with user and that password returns 404 — authenticated, with nothing mapped at /. That is the starter's default: a locked application with one throwaway user, session cookies, and HTTP Basic.

Now add OAuth2 Resource Server — the Initializr choice whose job is verifying incoming JWTs — and run again. The password line is gone, and so is the user. Spring Boot's auto-configuration creates that default user only while nothing else provides authentication; the moment a resource server is on the classpath it backs off. If you have been following an older guide and are waiting for a password that never prints, that is why.

Warning

On Spring Boot 4 the dependency is spring-boot-starter-security-oauth2-resource-server. The reference lists the older spring-boot-starter-oauth2-resource-server as deprecated in favour of it. Any tutorial naming the short version predates Boot 4.

The filter nobody needs to write

Here is the part worth stopping on. Adding that starter puts nimbus-jose-jwt 10.9.1 on your classpath, and Spring Security 7.1.1 wires it into the filter chain itself. Parsing the Authorization header, decoding the token, checking the signature, checking expiry, turning claims into an Authentication — all of it is already implemented.

So the fifty-line filter is not a requirement. It is a choice, and it is the riskiest code in the application: a signature check with a missing branch, or an expiry comparison the wrong way round, fails open and nothing in your test suite notices. The framework's version has been read by a great many more people than yours will be.

The whole of it is one line of DSL and one bean, and the rest of this article is those two things plus the code that issues a token in the first place.

Two beans: one signs, one verifies

A JWT is a signed statement. Issuing one needs a private key; checking one needs only the public half — and that asymmetry is the reason to prefer RSA over a shared secret here. A service that merely verifies tokens never has to hold anything that could mint them.

@Bean
JwtEncoder jwtEncoder() {
    return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(rsaKey)));
}

@Bean
JwtDecoder jwtDecoder() throws Exception {
    return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
}

That is the entire cryptographic surface of the application. rsaKey is a 2048-bit pair generated when the configuration class is constructed:

private final RSAKey rsaKey = generateKey();

private static RSAKey generateKey() {
    try {
        KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
        gen.initialize(2048);
        KeyPair pair = gen.generateKeyPair();
        return new RSAKey.Builder((RSAPublicKey) pair.getPublic())
                .privateKey((RSAPrivateKey) pair.getPrivate())
                .keyID("demo")
                .build();
    } catch (Exception e) {
        throw new IllegalStateException(e);
    }
}

That keeps the article to one file, and it is the first of three things the last section says you must not ship.

The filter chain, line by line

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
            .authorizeHttpRequests(a -> a
                    .requestMatchers("/token").permitAll()
                    .anyRequest().authenticated())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .httpBasic(Customizer.withDefaults())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .build();
}

Four decisions, and each is worth naming:

authorizeHttpRequests — the token endpoint is open to anyone, everything else needs an authenticated caller. Order matters: the specific matcher comes first, and anyRequest() is the catch-all that must come last.

STATELESS — do not create a session, do not consult one. The token carries the identity on every request, so a session would be a second, contradictory source of truth. This is also what stops the JSESSIONID cookie you saw earlier.

csrf.disable() — and this one deserves more than a shrug, because "disable CSRF" is repeated everywhere without its condition. The Spring Security reference is specific: a backend application that does not serve browser traffic may choose to disable CSRF, and in that case no additional work is required. The reasoning is that CSRF exploits credentials a browser attaches automatically — cookies. A caller that must attach a bearer token by hand cannot be tricked into doing it cross-site. If the same application also serves HTML with a session cookie, the condition fails and the protection stays.

oauth2ResourceServer(o -> o.jwt(...)) — the line that turns on everything in the previous section.

Common mistake

Copying csrf.disable() into an application that also serves a server-rendered UI. The line is correct for a token-only API and wrong for a session-backed one, and nothing in the framework can tell which yours is.

Issuing a token

How the caller proves who they are before receiving a token is your choice; here it is HTTP Basic against an in-memory user, which is why httpBasic is in the chain above. Two more beans finish the configuration:

@Bean
UserDetailsService users(PasswordEncoder encoder) {
    return new InMemoryUserDetailsManager(
            User.withUsername("alice").password(encoder.encode("password")).roles("USER").build());
}

@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

Note that even the throwaway user's password is hashed rather than stored as text. Skip the encoder.encode(...) and the application still starts — it fails later, at the first login, with a 401 and this in the log:

WARN o.s.s.c.bcrypt.BCryptPasswordEncoder : Encoded password does not look like BCrypt

A stored password that is not a hash is not a shortcut you can take in development and fix later; it simply does not work. A real system looks that user up somewhere persistent instead — the same place your other data lives.

@PostMapping("/token")
public Map<String, String> token(Authentication authentication) {
    Instant now = Instant.now();
    String scope = authentication.getAuthorities().stream()
            .map(a -> a.getAuthority()).collect(Collectors.joining(" "));

    JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("https://code10x.in")
            .issuedAt(now)
            .expiresAt(now.plusSeconds(900))
            .subject(authentication.getName())
            .claim("scope", scope)
            .build();

    return Map.of("token", encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue());
}

Authentication arrives as a method parameter because the caller has already been authenticated by the chain — by the time this method runs, the identity is settled. And a protected endpoint gets the decoded token the same way:

@GetMapping("/api/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
    return Map.of("subject", jwt.getSubject(), "scope", jwt.getClaimAsString("scope"),
                  "expiresAt", jwt.getExpiresAt().toString());
}

No header parsing, no null checks, no "is this token still valid" branch. If the method runs at all, the token was valid — which is the whole benefit of letting the chain do it. It is the same shape as any other Spring Boot REST endpoint; the security is configuration, not code inside the handler.

What is actually inside the token

curl -u alice:password -X POST http://localhost:8080/token

What came back is 532 characters. Base64-decode the first two segments:

{"kid":"demo","alg":"RS256"}
{"iss":"https://code10x.in","sub":"alice","exp":1789045257,
 "iat":1789044357,"scope":"ROLE_USER FACTOR_PASSWORD"}

Header names the key and the algorithm. Payload carries the issuer, subject, issue and expiry times — the registered claims of RFC 7519 — plus the scope the controller built.

Now read that scope again. FACTOR_PASSWORD was never added by any line of application code.

Spring Security 7 records how someone authenticated, not just who they are. org.springframework.security.core.authority.FactorGrantedAuthority in spring-security-core 7.1.1 defines eight of these: PASSWORD_AUTHORITY, BEARER_AUTHORITY, OTT_AUTHORITY, WEBAUTHN_AUTHORITY, X509_AUTHORITY, CAS_AUTHORITY, SAML_RESPONSE_AUTHORITY and AUTHORIZATION_CODE_AUTHORITY. Authenticating with a password grants the first one alongside the user's roles.

Which means the innocent-looking getAuthorities().stream().map(...).joining(" ") above is now a decision rather than a shortcut: it copies the authentication method into a token that will be read by other services. Pick the authorities you intend to publish.

Interview tip

This is the kind of change that does not break anything and quietly alters your data. If a downstream service authorises on scope, an unexpected value has just appeared in it. Worth checking before you upgrade a system that mints tokens this way.

What happens when the token is wrong

Send the token with one extra character appended:

HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token",
  error_description="An error occurred while attempting to decode the Jwt:
                     Signed JWT rejected: Invalid signature",
  error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
  resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"

Nothing in the application handled that. The challenge follows RFC 6750 — the error, error_description and error_uri parameters are the bearer-token spec's, not Spring's invention — and resource_metadata comes from the newer RFC 9728, which tells a client where to learn how to authenticate properly.

A hand-written filter gives you a bare 401 unless you build all of that yourself, and most do not.

The three decisions this example defers

Everything above is a working authentication system and an incomplete one. Three things were skipped to keep it to two files, and each is the sort of omission that turns a demo into an incident.

Where the key lives. This example generates a key pair at startup. Every restart therefore invalidates every token in the wild, and two instances behind a load balancer disagree about which signatures are valid — one will reject the other's tokens. Production loads a key from a secret store or a key management service, and gives services that only verify nothing but the public half.

How long a token lives. 900 seconds here, and the number is a trade. Long tokens mean fewer round trips and a longer window in which a stolen one still works. Short tokens mean the opposite, and a refresh mechanism you now have to design. Whatever you pick, pick it deliberately.

Revocation, which does not exist. A signed token is valid until exp, and there is no server-side switch to withdraw it. "Log out" on the client deletes the token; it does not stop anyone who copied it. If you need real revocation you need a denylist checked on each request — which reintroduces exactly the shared state the stateless design was chosen to avoid. That is a legitimate choice; it is just not the one this configuration makes.

None of the three is exotic, and none is optional. A token API that skips them is not a fast version of a secure one — it is a different, weaker system that looks identical from the outside. Which is the argument for letting the framework own the parts it already owns, and spending your attention here instead: on the keys, the lifetimes, and what happens when a token needs to stop working before it expires — the same operational questions that decide whether a service registry or anything else in a distributed system is trustworthy.

Frequently asked questions

Do I need the jjwt or java-jwt library?
No. The OAuth2 Resource Server starter brings nimbus-jose-jwt 10.9.1, and Spring Security's JwtEncoder and JwtDecoder beans use it for you. Adding another JWT library means two implementations of the same cryptography in one application, and only one of them is wired into the filter chain.
Why does my token contain a scope I never added?
Because Spring Security 7 grants factor authorities when it authenticates someone. A password login also produces FACTOR_PASSWORD, so code that builds a scope claim by joining every authority picks it up. Filter the authorities you actually want rather than joining all of them.
Is it safe to disable CSRF?
Only under a condition. The Spring Security reference says a backend application that does not serve browser traffic may choose to disable CSRF. If the same application also serves HTML to browsers with a session cookie, that condition does not hold and you need the protection.
Can I log a user out of a JWT?
Not by itself. A signed token is valid until it expires, and nothing you do server-side withdraws it. Short lifetimes limit the damage; anything stronger needs a denylist or a revocation check, which puts state back into a design chosen for having none.
Where should the signing key live in production?
Not in the application. The example here generates a key pair at startup, which is fine for a demo and wrong for anything real — every restart invalidates every token, and instances do not agree. Load it from a secret store or a key management service, and give verification only the public half.

References

  1. Spring Security: OAuth2 Resource Server — JWTSpring
  2. Spring Security: Getting StartedSpring
  3. Spring Security: Cross Site Request ForgerySpring
  4. Spring Security: Authorize HttpServletRequestsSpring
  5. Spring Boot Reference: Spring SecuritySpring
  6. Spring Boot Reference: Build SystemsSpring
  7. RFC 7519: JSON Web TokenIETF
  8. RFC 6750: Bearer Token UsageIETF
  9. RFC 9728: OAuth 2.0 Protected Resource MetadataIETF
  10. Spring InitializrSpring