Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 054 / 100

Spring Security JWT authentication in Java & Spring Boot

Stateless API auth with signed tokens, the Spring way.

Expona Admin7 min read

Why stateless JWT auth matters in Spring Boot APIs

Every API we build in production eventually hits the same wall: session storage doesn't scale, database lookups on every request tank throughput, and maintaining login state across microservices becomes a nightmare. Spring Security JWT authentication in Java & Spring Boot solves this by moving auth validation to the edge—your API verifies signed tokens without touching a session store. In this guide, we'll walk through the Spring way of handling JWT auth: let the framework do the heavy lifting, configure your identity provider once, and let stateless requests flow through your services at scale.

The resource server approach—let Spring validate JWTs for you

The biggest mistake we see is teams rolling custom JWT filters. They decode tokens manually, parse claims by hand, and build their own authority logic. Spring Security has a built-in resource server that does this correctly and handles edge cases we'd miss. When you configure your app as an OAuth 2.0 resource server, Spring intercepts every request, validates the JWT signature against your issuer's public key, and attaches the token's claims to the security context. No reinventing the wheel.

Here's the minimal config—most teams stop here and it's production-ready:

java
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.web.SecurityFilterChain;
import java.util.function.Customizer;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        
        return http.build();
    }
}

That single line http.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) tells Spring to automatically validate incoming JWT tokens. Any request without a valid Authorization: Bearer <token> header gets a 401. Any token with an invalid signature gets rejected. Spring pulls the issuer's public keys automatically—no manual key management in your codebase.

In your controller, the authenticated principal is available immediately:

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public ResponseEntity<Order> getOrder(
            @PathVariable Long id,
            @AuthenticationPrincipal Jwt jwt) {
        
        String userId = jwt.getSubject();
        // Your business logic here
        return ResponseEntity.ok(new Order(id, userId));
    }
}

Spring injects the validated JWT directly. The token has already been verified against the issuer's signature. If you're here, it's legitimate.

Configure the issuer—fetch keys automatically from your identity provider

Hardcoding public keys is a support nightmare. Every key rotation means redeployment. Instead, tell Spring where your identity provider lives, and it fetches public keys automatically. Most auth services (Auth0, Okta, AWS Cognito, Keycloak) expose a JWKS endpoint that publishes their current keys. Spring's resource server queries this endpoint on startup and caches the keys.

In your application.yml:

java
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-provider.com
          jwks-uri: https://your-auth-provider.com/.well-known/jwks.json

If your provider follows the OpenID Connect standard (which most do), you only need the issuer-uri. Spring will discover the JWKS endpoint automatically by fetching /.well-known/openid-configuration:

java
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-provider.com

Spring then validates three critical things:

  • The JWT signature matches the issuer's public key.
  • The token's iss (issuer) claim matches your configured issuer.
  • The token hasn't expired (checks the exp claim).

For local development or when your auth provider doesn't expose JWKS, you can specify a static public key:

java
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-provider.com
          public-key-location: classpath:public-key.pem

But use this only in non-production environments. It defeats the purpose of automatic key rotation.

Short-lived access tokens with refresh token rotation

Handing out 1-year JWT tokens is like handing out 1-year session cookies—if a token leaks, you're vulnerable for a year. Real production systems use short-lived access tokens (5–15 minutes) paired with refresh tokens that live longer but rotate on each use.

Most identity providers handle this for you. When your frontend (mobile, SPA, whatever) logs in, it gets an access token (short life) and a refresh token (long life). The access token flows in every API request. When it expires, the frontend uses the refresh token to get a new pair—and the old refresh token is invalidated. This way, a leaked token has a narrow damage window.

In Spring Boot, you don't implement refresh token logic—that's your auth provider's job. Your API just validates the access token. Configure your issuer to hand out short-lived tokens, and you're done:

java
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-provider.com
# Your auth provider is configured to issue access tokens with 10-minute expiry
# and refresh tokens with 7-day expiry.

If the frontend sends an expired access token, Spring rejects it with a 401. The frontend catches this, swaps the refresh token for a new access token, and retries the request. Your API never sees expired tokens.

The only case where you'll handle token refresh in Spring is if you're also your own auth provider—which is rare. Usually you'll delegate to Keycloak, Auth0, or similar.

Map claims to authorities—convert roles and scopes to GrantedAuthority

JWT tokens contain claims—metadata about the user. Common ones are scope (permissions), roles (role names), and custom claims your auth provider adds. Spring's default behavior extracts the scope claim and maps each scope to a GrantedAuthority with prefix SCOPE_.

If your token looks like this:

java
{
  "sub": "user-123",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "scope": "read:orders write:orders",
  "roles": ["admin", "user"]
}

Spring automatically creates authorities SCOPE_read:orders and SCOPE_write:orders. But if you want to use the roles claim instead, or map custom claims, you need a JwtAuthenticationConverter:

java
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasAuthority("ROLE_admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(o -> o
                .jwt(j -> j.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );
        
        return http.build();
    }

    @Bean
    public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        
        // Extract authorities from the 'roles' claim instead of 'scope'
        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthoritiesClaimName("roles");
        authoritiesConverter.setAuthorityPrefix("ROLE_");
        
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return converter;
    }
}

Now Spring looks for a roles claim, extracts each role, and prefixes it with ROLE_. Your token with "roles": ["admin", "user"] creates authorities ROLE_admin and ROLE_user. In your controller, you can check them:

java
@PostMapping("/admin/users")
@PreAuthorize("hasAuthority('ROLE_admin')")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest req) {
    // Only users with ROLE_admin can reach here
    return ResponseEntity.ok(userService.create(req));
}

For more complex scenarios—custom claim transformations, business logic based on claims—write a custom converter:

java
@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> customJwtAuthenticationConverter() {
    return jwt -> {
        List<String> roles = jwt.getClaimAsStringList("roles");
        List<String> teams = jwt.getClaimAsStringList("teams");
        
        List<GrantedAuthority> authorities = new ArrayList<>();
        if (roles != null) {
            authorities.addAll(roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                .toList());
        }
        if (teams != null) {
            authorities.addAll(teams.stream()
                .map(t -> new SimpleGrantedAuthority("TEAM_" + t))
                .toList());
        }
        
        return new JwtAuthenticationToken(jwt, authorities);
    };
}

Map whatever claims your identity provider sends into whatever authority model your app needs. Spring keeps it flexible.

Common mistakes

  • Hardcoding secrets in code. Never store your issuer's private key in your Spring app. Your app is a resource server—it only validates with public keys. Signing happens at your auth provider.
  • Not validating the audience (aud claim). A JWT signed for one API might be replayed against another. Add spring.security.oauth2.resourceserver.jwt.audiences to restrict tokens to your API.
  • Ignoring token expiry. Short-lived tokens matter. Configure your auth provider to issue tokens with 10–15 minute expiry, not hours or days.
  • Custom filters instead of oauth2ResourceServer. Spring's resource server is tested, maintained, and handles edge cases. Use it.
  • Storing sensitive data in JWT claims. Tokens are signed, not encrypted. Anyone with the token can read the claims. Don't put passwords, API keys, or PII in the JWT payload.
  • Not handling key rotation. If your auth provider rotates keys (they should), Spring fetches new keys automatically if you use issuer-uri. Don't hardcode keys in properties.

Key takeaways

  • Use http.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) to let Spring validate JWTs—don't write custom filters.
  • Configure issuer-uri so Spring fetches public keys automatically and validates token signatures.
  • Use short-lived access tokens (5–15 minutes) with your auth provider; your Spring app just validates them.
  • Map token claims to authorities with a JwtAuthenticationConverter if you need custom role or permission logic.
  • Test that expired, unsigned, or replayed tokens are rejected—Spring handles this out of the box if configured correctly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles