By AndyPublished

Spring Boot JWT Authentication with Spring Security's OAuth2 Resource Server

The supported way to add JWT authentication to a Spring Boot API is Spring Security's OAuth2 Resource Server. Add the resource server starter, set spring.security.oauth2.resourceserver.jwt.issuer-uri, and declare a SecurityFilterChain with.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())). Spring then reads the bearer token, fetches the issuer's public keys, checks the signature, iss,exp and nbf, and turns scopes intoSCOPE_ authorities. You do not need a hand-written OncePerRequestFilter. This guide covers the Maven dependency (renamed in Spring Boot 4), configuration, roles, issuing your own tokens, and the jjwt versus Nimbus question. Versions checked: Spring Boot 4.1.1 and Spring Security 7.1.1 reference docs, jjwt 0.13.0.

How Does JWT Work in Spring Boot?

A client sends Authorization: Bearer <token>. With the resource server configured, the request passes through this chain:

  • ·BearerTokenAuthenticationFilter extracts the token and hands a BearerTokenAuthenticationToken to the AuthenticationManager.
  • ·JwtAuthenticationProvider calls a JwtDecoder, which parses the token, selects the key by kid, verifies the signature and runs the claim validators (timestamps with 60 seconds of default clock skew, plus issuer when configured).
  • ·JwtAuthenticationConverter maps claims to granted authorities. By default it reads scope or scp and prefixes each value with SCOPE_.
  • ·The result is a JwtAuthenticationToken whose principal is the decoded Jwt. Authorisation rules then run against its authorities.

Nothing is stored server side. Each request is authenticated from the token alone, which is why expiry and key rotation matter more than they do with sessions (see JWT vs session tokens).

JWT Spring Boot Dependency (Maven)

Spring Boot 4.0 renamed several starters to line up with its new modules. The resource server starter is nowspring-boot-starter-security-oauth2-resource-server. The old name,spring-boot-starter-oauth2-resource-server, still exists but the Boot 4.0 migration guide marks it deprecated. On Spring Boot 3.x, use the old name.

<!-- Spring Boot 4.x -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc</artifactId>  <!-- was spring-boot-starter-web -->
</dependency>

<!-- Spring Boot 3.x -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

The starter pulls in spring-security-oauth2-resource-server andspring-security-oauth2-jose. The second one holds the Nimbus-based JWT decoding and encoding. You do not need a separate JWT library to validate tokens.

JWT with OAuth2 in Spring Boot: Configure the Issuer

If your tokens come from an OAuth 2.0 or OpenID Connect provider (Keycloak, Okta, Auth0, Cognito, Entra ID, Spring Authorization Server), point Spring at the issuer:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/realms/acme
          # optional: skip discovery and name the key set directly
          # jwk-set-uri: https://idp.example.com/realms/acme/protocol/openid-connect/certs
          audiences: https://api.example.com

With issuer-uri, Spring looks up the provider's metadata (/.well-known/openid-configuration, or the OAuthoauth-authorization-server metadata) when the first JWT arrives, readsjwks_uri, and validates every token's iss against the configured value. If you want to know what that key set looks like, see what a JWKS is.

⚠
Set audiences. Issuer and signature checks prove the provider minted the token; only the aud check proves it was minted for your API. Without it, an access token issued to any other application in the same realm or tenant is accepted. See the audience claim.

Spring Boot also accepts public-key-location for a single PEM public key, and it backs off completely if you declare your own JwtDecoder bean, for exampleNimbusJwtDecoder.withIssuerLocation(issuer).build() orNimbusJwtDecoder.withJwkSetUri(uri).build().

JWT with Spring Security: the SecurityFilterChain

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("SCOPE_orders.read")
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
        return http.build();
    }
}

In a controller, take the decoded token as the principal rather than parsing the header yourself:

@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
    return Map.of("sub", jwt.getSubject(), "scopes", jwt.getClaimAsString("scope"));
}

When validation fails, the resource server returns 401 with a WWW-Authenticate: Bearerheader that carries an error and description. When a valid token lacks the needed authority, it returns 403. For a quick look at the claims your API actually received, paste the token into the jwtdecode.app decoder. It decodes locally in the browser.

JWT Role-Based Authentication in Spring Boot

By default only scopes become authorities, so hasRole("ADMIN") fails even when the token plainly contains "roles": ["ADMIN"]. hasRole looks for the authority ROLE_ADMIN. Tell the converter which claim to read and which prefix to use:

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthoritiesClaimName("roles");   // top-level claim holding a list or space-separated string
    roles.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(roles);
    return converter;
}

Spring Boot picks up this bean automatically. Two points catch people out:

  • ·You lose scopes when you replace the converter. The converter above yields only ROLE_ authorities. If you need both, write a Converter<Jwt, Collection<GrantedAuthority>> that combines a default JwtGrantedAuthoritiesConverter with your role mapping.
  • ·Nested claims need custom code. setAuthoritiesClaimName reads a top-level claim. Keycloak puts realm roles in realm_access.roles, so map it yourself:
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
    Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
    Collection<?> names = realm == null ? List.of() : (Collection<?>) realm.getOrDefault("roles", List.of());
    return names.stream()
        .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r))
        .toList();
});

Scopes describe what the client application is allowed to do; roles describe what the user is. Keep the two prefixes distinct so a rule never confuses them. Scopes vs roles covers the design side.

Issuing Your Own JWTs in Spring Boot

Most tutorials under "jwt with spring boot" build a login endpoint that signs tokens with a shared secret. That works, but it makes your API the identity provider, with password storage, refresh, revocation and key rotation to own. If you only need tokens for your own services, Spring Authorization Server or an external provider is usually less work. When you do issue tokens yourself, Spring Security already has an encoder. Since Spring Security 7.0, NimbusJwtEncoder has builders for a secret key, an RSA key pair or an EC key pair:

@Bean
JwtEncoder jwtEncoder(SecretKey key) {                 // HS256 by default
    return NimbusJwtEncoder.withSecretKey(key).build();
}

@Bean
JwtDecoder jwtDecoder(SecretKey key) {
    return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
}

String issue(Authentication auth) {
    Instant now = Instant.now();
    JwtClaimsSet claims = JwtClaimsSet.builder()
        .issuer("https://api.example.com")
        .subject(auth.getName())
        .audience(List.of("https://api.example.com"))
        .issuedAt(now)
        .expiresAt(now.plus(Duration.ofMinutes(15)))
        .claim("scope", "orders.read orders.write")
        .build();
    JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
    return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}

Load the secret from configuration or a vault, and make it at least 256 bits for HS256 (RFC 7518 §3.2 requires a key at least as long as the hash output). If more than one service verifies the tokens, prefer an asymmetric key pair (NimbusJwtEncoder.withKeyPair(publicKey, privateKey)) so verifiers never hold signing material; HS256 vs RS256 explains the trade-off. Keep access tokens short and pair them with the refresh token pattern.

JWT Java Library: jjwt vs Nimbus

Both are mature. The choice mostly depends on whether you are already inside Spring Security.

AspectNimbus JOSE + JWTjjwt
Used bySpring Security itself (NimbusJwtDecoder, NimbusJwtEncoder)Your own code, directly
Comes with the Boot starterYes, via spring-security-oauth2-joseNo, add jjwt-api, jjwt-impl and jjwt-jackson
JWKS fetching and cachingBuilt in (issuer-uri, jwk-set-uri)Not built in; you supply the key or a locator
JWE (encrypted tokens)Supported by Nimbus; not wired up by the resource serverSupported
Best fitValidating tokens from an identity provider; issuing tokens inside SpringSmall services that sign and parse their own HS256/RS256 tokens

The current jjwt API

jjwt changed its API in 0.12: the set-prefixed builder methods andparseClaimsJws are deprecated. Much of the code you will find online still uses them. The current form, per the 0.13.0 README:

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.13.0</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));

String jws = Jwts.builder()
    .subject("user-123")
    .issuer("https://api.example.com")
    .audience().add("https://api.example.com").and()
    .issuedAt(new Date())
    .expiration(Date.from(Instant.now().plusSeconds(900)))
    .claim("roles", List.of("ADMIN"))
    .signWith(key)
    .compact();

Claims claims = Jwts.parser()
    .verifyWith(key)
    .requireIssuer("https://api.example.com")
    .requireAudience("https://api.example.com")
    .clockSkewSeconds(30)
    .build()
    .parseSignedClaims(jws)
    .getPayload();

parseSignedClaims throws a JwtException subtype (ExpiredJwtException, SignatureException,IncorrectClaimException, MissingClaimException) when a check fails. signWith(key) picks the algorithm from the key; for an HMAC key it picks the strongest HS algorithm the key length allows.

JWT in Spring Boot 4: What Changed

Spring Boot 4.0.0 was released on 20 November 2025, built on Spring Framework 7 and Spring Security 7. At the time of writing, the reference documentation covers 4.1.1. For JWT authentication the changes that matter are:

  • ·Starter names: spring-boot-starter-security-oauth2-resource-server, -oauth2-client and -oauth2-authorization-server replace the old names, and spring-boot-starter-web becomes spring-boot-starter-webmvc.
  • ·Lambda DSL only: the chained .and() configuration style was removed in Spring Security 7, so old tutorials using http.csrf().disable().and() will not compile.
  • ·Encoder builders: NimbusJwtEncoder.withSecretKey and withKeyPair are new in Spring Security 7.0. Previously you built a JWKSource by hand.
  • ·Jackson 3: Boot 4 moves to Jackson 3 (tools.jackson group ID), which matters if you plug in custom claim serialisation. Java 17 remains the minimum.

The property names under spring.security.oauth2.resourceserver.jwt and theoauth2ResourceServer(...) configuration are the same in Boot 3 and Boot 4.

Testing JWT-Protected Endpoints

spring-security-test can inject an already-authenticated JWT into MockMvc, so tests do not need a running identity provider:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

mockMvc.perform(get("/orders/42")
        .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
    .andExpect(status().isOk());

This skips the decoder, so also keep at least one integration test that sends a real signed token through the full chain. When real tokens are rejected, the usual causes are an iss that differs by a trailing slash, a missing audience, or clock drift beyond the 60-second default. The common JWT errors guide lists fixes by message.

Common Spring Boot JWT Mistakes

  • ·Writing a custom JWT filter. Many tutorials extend OncePerRequestFilter, parse the header with jjwt and set the SecurityContext by hand. That duplicates the resource server and usually skips something: the audience check, algorithm pinning, the RFC 6750 error responses, or key rotation. Use the built-in support unless you have a specific reason not to.
  • ·Setting only jwk-set-uri. Keys alone prove who signed the token, not which issuer configuration it belongs to. Set issuer-uri as well (or add an issuer validator to your own JwtDecoder) so iss is still checked.
  • ·Short HMAC secrets. A secret such as "mysecret" is too short for HS256, and libraries reject or warn about it. Generate 32 random bytes or more and store them base64-encoded.
  • ·Long-lived access tokens. A stateless resource server cannot revoke a token before it expires. Keep access tokens to minutes and handle logout with refresh-token revocation; see JWT logout and revocation.
  • ·Accepting ID tokens. An OpenID Connect ID token is addressed to the client application, not your API. If audiences is set correctly, ID tokens fail the audience check, which is the right result.

Summary

For JWT authentication in Spring Boot, use Spring Security's OAuth2 Resource Server rather than a custom filter: spring-boot-starter-security-oauth2-resource-server on Boot 4 (thespring-boot-starter-oauth2-resource-server name on Boot 3),issuer-uri plus audiences, andoauth2.jwt(withDefaults()) in the filter chain. Map role claims with aJwtAuthenticationConverter. When you must issue tokens yourself, useNimbusJwtEncoder inside Spring, or the jjwt 0.12+ API (Jwts.builder().subject(), Jwts.parser().verifyWith(key).build().parseSignedClaims()) outside it.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder