f in x
Spring Security with JWT and OAuth2: Modern Authentication for Your APIs
> cd .. / HUB_EDITORIALE
Analisi dei dati e metriche

Spring Security with JWT and OAuth2: Modern Authentication for Your APIs

[2026-06-10] Author: Ing. Calogero Bono

You have a Java backend with Spring Boot, and your clients — mobile apps, React SPAs, external services — need to authenticate securely. You've heard about JWT and OAuth2, but when you open the official documentation, you face a jungle of configurations and terms that seem designed to confuse. The result? Too many projects end up exposing endpoints, leaving tokens without expiration, or worse, copy-pasting code from Stack Overflow without understanding if it actually works.

We, at Meteora Web, work daily with Java and Spring Boot stacks on our clients' projects. We come from eight years of managing real systems — from an ERP for a clothing store to a proprietary platform for managing social media presence across multiple companies. We've seen that a poorly configured authentication system costs much more than the time spent doing it right: exposed data, lost trust, and in some cases, hefty fines. That's why we decided to write this operational guide, focused on Spring Security with JWT and OAuth2, no fluff, with examples you can copy and test today.

Why JWT and OAuth2 together? A choice that pays off

The right question is: why aren't username/password with server-side sessions enough? If you have a traditional monolithic application, maybe yes. But as soon as multiple clients (mobile, web, external APIs) or a microservices architecture come into play, session management becomes a bottleneck. JWT (JSON Web Token) gives you a self-contained token: it contains user data (claims) and a signature that guarantees integrity. OAuth2, on the other hand, is the protocol that standardizes how to issue and validate these tokens across different parties (client, resource server, authorization server).

The combination wins because:

  • Stateless: the server doesn't need to store the session. Every request carries everything needed to be authenticated.
  • Scalable: you can add backend instances without sharing a session database.
  • Interoperable: any client (JavaScript, mobile, service) can use the same token if it follows the standard.
  • Delegable: OAuth2 allows delegating authentication to external providers (Google, GitHub, your own Identity Provider).

From a business perspective, implementing it wrong means losing customers. We see it every day: if login doesn't work, if the token expires without warning, the user abandons. And a lost user costs more than an hour of development.

Basic setup: the right dependencies

Let's assume you have a Spring Boot project (any 3.x version, which uses Spring Security 6). The first thing is to add the dependencies. We use Maven, but the concept is identical for Gradle.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

The jjwt library is what we use in our projects for JWT creation and validation. It's mature, well-maintained, and has no heavy dependencies. For OAuth2 Resource Server, Spring Boot provides native support: just add the oauth2-resource-server dependency and configure token decoding via a public key or introspection endpoint.

Spring Security configuration: SecurityFilterChain

In Spring Security 6, configuration is based on a SecurityFilterChain bean. Forget the old WebSecurityConfigurerAdapter. Here's our standard setup for a REST API using JWT.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter, 
                          AuthenticationProvider authenticationProvider) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationProvider = authenticationProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // API stateless -> CSRF not needed
            .cors(Customizer.withDefaults()) // enable CORS for cross-origin requests
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/", "/api/public/").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Three crucial things, all often overlooked in projects we review:

  • CSRF disabled: correct for stateless APIs, but never for apps that manage sessions via cookies.
  • Stateless session: Spring Security must not create an HTTP session; otherwise you nullify the advantage of JWT.
  • Filter before UsernamePasswordAuthenticationFilter: your custom filter reads the token and sets the Authentication in the SecurityContextHolder.

The JWT filter: heart of validation

We write a filter that extracts the token from the Authorization: Bearer <token> header, validates it, and populates the security context. Here's the operational version we use in our projects:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUsername(jwt);

        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

The most common mistake we fix in projects: not checking if Authentication is already present (avoids reprocessing the same token across multiple filters) and not properly handling token expiration exceptions. An expired JWT should not cause a 500 response, but a clear 401.

JwtService: generation and validation

The service that signs and verifies tokens. We use an HMAC secret key (256 bits) for development and an RSA key pair for production. Here's an example with HMAC:

@Service
public class JwtService {

    private static final String SECRET_KEY = System.getenv("JWT_SECRET"); // never hardcoded!

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public String generateToken(UserDetails userDetails) {
        return buildToken(new HashMap<>(), userDetails);
    }

    private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 10 hours
                .signWith(getSignInKey())
                .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSignInKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Note: the secret key must always come from an environment variable or a vault. In our security audits, 60% of projects we examine have the key hardcoded in the code. It's the first thing we check.

OAuth2 Resource Server with native JWT

Spring Boot supports OAuth2 Resource Server in an almost declarative way. If your JWT is issued by an external provider (e.g., Google, Auth0, Keycloak), you can skip the custom filter entirely:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://accounts.google.com
          jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs

Alternatively, if you manage token issuance yourself (centralized authorization), you can configure an RSA public key in the properties file and Spring will automatically validate the signature. We prefer this approach when we have a monolithic backend with its own user domain, because it gives us full control without depending on an external provider.

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(publicKey).build();
}

The advantage is that you don't need to write the filter: Spring Security does it for you, based on the Resource Server configuration. This reduces code and potential bugs.

Refresh tokens and logout: handling that saves user experience

A JWT has a short expiration (typically 15 minutes to 1 hour). To avoid forcing users to re-enter credentials, you use a refresh token with a longer lifespan (e.g., 7 days). The refresh token must be stored server-side (e.g., in a database) and be revocable. When the access token expires, the client calls /api/auth/refresh with the refresh token and gets a new access token.

Implementing refresh is straightforward: in your authentication controller, after verifying credentials, issue both tokens. Then, for refresh, receive the refresh token, look it up in the DB, check that it's not expired or revoked, and generate a new access token (and optionally a new refresh token).

For logout, the best strategy is to invalidate the refresh token (delete from DB or mark as revoked). The access token, being stateless, cannot be revoked until its natural expiration. To mitigate risk, use an in-memory blacklist (Redis) of expired/logged-out tokens, checked in the JWT filter. It's not perfect but it's practical.

Advanced security: mistakes to avoid and best practices

We've seen too many implementations that forget:

  • Validate the audience: if your service A issues tokens for service B, the token must include the correct audience. Otherwise, a stolen token can be used on unauthorized resources.
  • Don't expose sensitive information in claims: never include passwords, unnecessary roles, or personal data.
  • Limit token size: a large JWT slows every request. Include only essential claims.
  • Use HTTPS everywhere: if you pass the token in plain HTTP, it's like writing the password on a sticky note.
  • Rate limiting on login and refresh endpoints: to prevent brute force. Spring Boot offers Bucket4j, or you can integrate a gateway like NGINX.

At Meteora Web, we take a practical approach: every time we implement an authentication system, we simulate a replay attack and token rotation. It's an investment that pays off immediately: a breach costs an average of $4.5 million, according to IBM. For an SME, one attack is enough to shut down.

In summary — what to do now

  1. Add dependencies for Spring Security and jjwt to your pom.xml.
  2. Configure SecurityFilterChain with stateless policy, CORS, and JWT filter.
  3. Implement JwtService and JwtAuthenticationFilter as above, testing with a protected endpoint.
  4. Add refresh token and logout for a smooth user experience.
  5. Check that the secret key is in an environment variable, not in the code.
  6. Enable HTTPS and rate limiting on sensitive endpoints.
  7. If using an external provider, leverage Spring Boot's native OAuth2 Resource Server support.

If you're just starting, begin with the official Spring Security JWT samples on GitHub. But don't stop there: adapt the configuration to your domain, your users, your volumes. If you have a concrete project and want a review, contact us — we like to start from numbers and real risks, not from theory.

Sponsored Protocol

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Co-founder di Meteora Web. Ingegnere informatico, sviluppo ecosistemi digitali ad alte prestazioni. AI, automazione, SEO tecnica e infrastrutture web. Scrivo di tecnologia per rendere complesso… semplice.

[ Read Full Dossier ]

Hai bisogno di applicare questa strategia?

Esegui il protocollo di contatto per iniziare un progetto con noi.

> INIZIA_PROGETTO

Sponsored

> MW_JOURNAL

> READ_ALL()