arrow-rightgithublinkedinscroll-to-topyoutubezig-zag

Stateless JWT Authentication with Spring Security

Last Updated On

Introduction

Spring Security doesn’t make configuring stateless authentication straightforward out of the box. Much of its documentation revolves around using dedicated authorization servers, but this may not work for every scenario. In this article, we’ll explore how to implement JWT-based authentication without authorization server.

You can find all the code referenced in this article in the Github repository


No Authorization Server?

While SaaS offerings or self-hosted Keycloak are popular choices, they come with trade-offs:

  • Cost. SaaS platforms like Okta and Auth0 often have generous free tier. However, as your user base grows costs can skyrocket.
  • Vendor Lock-In. Using external platforms means your user data resides elsewhere. Migrating users can be a painful process.
  • Maintenance Overhead. Hosting your own authorization server (like Spring Authorization Server or Keycloak) requires separate configuration, deployment, and ongoing maintenance.

Sometimes we need a simpler approach.


JWT vs. OAuth/OpenID Connect

Before diving into the code, let’s briefly discuss the differences:

  • JWT (JSON Web Token): A simple token format used for stateless authentication
  • OAuth2 and OpenID Connect: Protocols that define how authorization and authentication are implemented, often integrating with social platforms (e.g., Google or Facebook login).

If you’re building a basic web app, JWT auth might be a great option. For larger apps, OAuth2/OpenID Connect provides richer features and support more complex workflows.


Setting Up the Project

Alright, let's dive into this. We will create a simple Spring Boot project:

  1. Navigate to Spring Initializr
  2. We’ll use only two dependencies
    • Spring Web
    • OAuth2 Resource Server (includes Spring Security)
  3. Click “Generate” and import the project into your IDE

Basic App Controller:

First, create an AppController class to define two endpoints:

@RestController
class AppController {

    @GetMapping("/private")
    public Map<String, String> privateEndpoint() {
        return Map.of("message", "Hello From Private Endpoint");
    }

    @GetMapping("/public")
    public Map<String, String> publicEndpoint() {
        return Map.of("message", "Hello From Public Endpoint");
    }
}

Initial Spring Security Config

Since we have spring security as a dependency, all requests will be blocked, even for our public endpoint. Let's fix that by configuring our own SecurityFilterChain that will override Spring's default mechanism:

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth ->
                auth.requestMatchers("/public").permitAll()
                    .requestMatchers("/token").permitAll()
                    .anyRequest().authenticated()
            )
            .build();
    }
}

At this point our public endpoint is accessible.

However, we can't access private endpoint because we need to receive a token. So let's transition to configuring jwt auth.


Disable Statful Authentication

To ensure our application operates in a stateless manner, we need to disable Spring’s default session-based security features. Spring Security comes pre-configured for stateful authentication, so we’ll add the necessary configurations to our http setup to turn off these session-based features:

//...
http
  .csrf(AbstractHttpConfigurer::disable) // (1)
  .formLogin(AbstractHttpConfigurer::disable) // (2)
  .logout(AbstractHttpConfigurer::disable) // (3)
  .rememberMe(AbstractHttpConfigurer::disable) // (4)
  .httpBasic(AbstractHttpConfigurer::disable) // (5)
  .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // (6)
//...

Here's an explanation of each item:

  1. Disables CSRF protection as it's not needed for JWT-based REST APIs. JWT tokens usually sent in headers, not cookies
  2. Disables Spring's default login form since we're using custom JWT authentication via token endpoint and not through HTML form
  3. Disables default logout endpoint as JWT authentication is stateless and there's no server-side session to invalidate
  4. Disables remember-me functionality since we're not using sessions or cookies
  5. Disables HTTP Basic Authentication in favor of JWT
  6. Configures the app to be stateless, not creating any sessions

Enable JWT validation

Next, we need to configure Spring Security to validate incoming requests using JWT tokens. To achieve this, we’ll add the following line to our http configuration:

//...
http
  .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
//...

This line is going to do the following:

  • Configure Spring application as an OAuth 2.0 Resource Server
  • Tell Spring Security to validate JWT tokens for incoming requests
  • Use default JWT validation settings (like checking signature, expiration, etc.)
  • Check if there's a JWT token in the Authorization: Bearer <token> header

However, if we start the application, Spring Security is going to throw the following error:

APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.TokenService required a bean of type 'org.springframework.security.oauth2.jwt.JwtEncoder' that could not be found.
Action:
Consider defining a bean of type 'org.springframework.security.oauth2.jwt.JwtEncoder' in your configuration.

That's because Spring doesn't know how to validate JWT token yet.


Quick JWT Recap

At this point, let’s take a moment to step back and briefly revisit the internals of JWT and how JWT validation works.

A JWT token is composed of three main parts:

  • Header: Includes metadata about the token, such as the type (JWT) and the signing algorithm used (e.g., RS256 for RSA SHA-256).
  • Payload (Body): Contains the token's claims, which are pieces of information such as user details, permissions, expiration time, and any custom claims relevant to the application.
  • Signature: Cryptographic signature generated using the private key. It ensures the integrity of the token and verifies that it hasn’t been tampered with.

How JWT is generated

  • Initially, header and body are created as JSON objects
  • Then they're both Base64URL encoded
  • Then the signature is generated by running signature algorithm with private key on the string combination of encoded header and payload: {encoded_header}.{encoded_payload}
  • The resulting signature is appended to the end. Final JWT token is going to look like this: xxx.yyy.zzz where:
    • xxx is encoded header
    • yyy is encoded payload
    • zzz is the signature

Spring already has a mechanism to generate and validate JWTs, but it doesn’t come with private and public keys. These keys must be specifically generated for each application, which means we need to create and manage our own key pair.

Aside: OAuth2 Authorization Servers typically expose their public keys through a well-known endpoint (like /.well-known/jwks.json). Since we’re not using a separate authorization server, this responsibility falls on us to generate and manage the keys.

In the authorization server setup, the private key is securely kept secret and never exposed, while the public key is made available to the public to verify token signatures. If the private key is compromised, an attacker could generate valid JWTs, allowing them to bypass authentication entirely.

In our case, both the public and private keys will remain internal to the same server.


Generating private/public keypair

There are several ways to generate and manage private/public key pairs. While storing keys as files is more straightforward, it becomes cumbersome in situations where keys are provided as strings through environment variables.

To address this, I opted in for a solution where keys are stored as single-line strings without spaces, instead of traditional PEM files. This approach simplifies embedding keys as environment variables.

Here’s the process we’ll follow (script included):

  • Generate private/public keys
  • Read Key Content and extract the raw content of the keys
  • Strip out any headers or footers like -----BEGIN PUBLIC KEY-----, as these are not part of the key and would cause parsing errors. We will also remove any white spaces.
  • Store the transformed keys as env vars
  • Implement Parsing in Java: Add the logic to RsaConfig class to parse and load the keys from env vars

Script to generate private/public keys

Linux/MacOS:

# Generate private key
openssl genrsa -out private.pem 2048

# Generate public key from private key
openssl rsa -in private.pem -pubout -out public.pem

# Remove headers/footers and whitespace in one go
# SECURITY NOTE: Consider redirecting output to files instead of displaying in terminal
PUBLIC_KEY=$(cat public.pem | grep -v "BEGIN PUBLIC KEY" | grep -v "END PUBLIC KEY" | tr -d '\n')
PRIVATE_KEY=$(cat private.pem | grep -v "BEGIN PRIVATE KEY" | grep -v "END PRIVATE KEY" | tr -d '\n')

# Print the cleaned keys
# SECURITY RISK: Printing private key to terminal (could be seen in history/logs)
echo "public-key: $PUBLIC_KEY"
echo "private-key: $PRIVATE_KEY"

Windows powershell:

# Generate private key
openssl genrsa -out private.pem 2048

# Generate public key
openssl rsa -in private.pem -pubout -out public.pem

# SECURITY NOTE: Consider redirecting output to files instead of displaying in terminal (could be seen in history/logs)
# Convert to environment variable format (one line, no headers)
echo "Public key:"
(Get-Content public.pem | Where-Object { $_ -notmatch "BEGIN|END" }) -join ""

echo "`nPrivate key:"
(Get-Content private.pem | Where-Object { $_ -notmatch "BEGIN|END" }) -join ""

This process will generate private and public key files, and it will also print the keys to the console. For demo purposes, we’ll insert these keys directly into our application.yml file:

rsa-config:
  public-key: MIIEvgIBADANBgkqhki....
  private-key: MIIBIjANBgkqhkiG9w0BAQEFAAOC....

Important: While we’re including the keys directly in the configuration file for simplicity in this demo, in a real production environment, you should manage keys securely as environment variables and musn't commit them to git.


Setting up RSA Config

To read and convert the keys into their corresponding objects, we’ll create an RsaConfig class:

@ConfigurationProperties(prefix = "rsa-config")
public class RsaConfig {

    private final String privateKey;
    private final String publicKey;

    public RsaConfig(String privateKey, String publicKey) {
        this.privateKey = privateKey;
        this.publicKey = publicKey;
    }

    public RSAPublicKey getPublicKey() {
        try {
            byte[] keyBytes = Base64.getDecoder().decode(publicKey);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            return (RSAPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(keyBytes));
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid public key", e);
        }
    }

    public RSAPrivateKey getPrivateKey() {
        try {
            byte[] keyBytes = Base64.getDecoder().decode(privateKey);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            return (RSAPrivateKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid private key", e);
        }
    }
}

RsaConfig stores Base64-encoded keys as strings and provides methods to convert them into RSA key objects that Spring can use. The conversion process is similar for both keys:

  • Decode the Base64 string into bytes
  • Use Java's KeyFactory to generate the appropriate key object
  • Wrap everything in try-catch to handle any conversion errors

Setting up JWT Encoder/Decoder

To finalize our jwt configuration, let's add the following beans into our SecurityConfig:

public class SecurityConfig {
    //...
    @Bean
    JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
        return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
    }

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

    @Bean
    RSAKey rsaKey(RsaConfig rsaConfig) {
        return new RSAKey
            .Builder(rsaConfig.getPublicKey())
            .privateKey(rsaConfig.getPrivateKey())
            .build();
    }
    //..
}
  • RSAKey - This bean creates an RSAKey object using Nimbus JOSE+JWT, a widely-used Java library for JSON Object Signing and Encryption (JOSE) and JSON Web Tokens (JWT). Spring Security uses this library under the hood, and the resulting RSAKey object is used by both the encoder and decoder for token operations
  • JwtEncoder creates and signs JWT tokens using the private key
  • JwtDecoder verifies incoming JWT tokens using the public key. It checks if the token's signature is valid

At this point, starting the application should no longer produce any errors, and it will run successfully 🎉 🍾🎊

However, although we’ve set up the JWT generation and verification process, we still lack the logic to issue tokens based on a valid username and password. Let’s address that next.


Setting up InMemoryUser

We’ll create a basic in-memory user for demo purposes:

public class SecurityConfig {
    //...
    @Bean
    public UserDetailsManager userDetailsManager() {
        return new InMemoryUserDetailsManager(
            User.withUsername("john@example.com")
                .password("password")
                .build()
        );
    }
    //...
}

For production, we may use JdbcUserDetailsManager or a similar implementation to manage users in a database securely.


Building codepath for getting a token

We're going to create a couple of classes:

  • TokenRequestDto/TokenResponseDto to define request/response data
  • AuthController: Exposes token endpoint
  • AuthService: Authenticates users
  • TokenService: Issues jwt tokens

TokenRequestDto:

record TokenRequestDto(
    String email,
    String password
) {}

TokenResponseDto:

record TokenResponseDto(
    @JsonProperty("jwt_token")
    String jwt_token
) {}

AuthController:

@RestController
class AuthController {

    private final AuthService authService;

    AuthController(final AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/token")
    public TokenResponseDto authenticateWithToken(@RequestBody TokenRequestDto request) {
        return authService.authenticateWithToken(request);
    }
}

AuthService is going to validate user credentials and check if the password matches. It will also extract permissions based on user authorities and ask TokenService to create jwt token based on user's data:

@Service
class AuthService {

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuthService.class);

    private final TokenService tokenService;
    private final UserDetailsManager userDetailsManager;

    AuthService(final TokenService tokenService, final UserDetailsManager userDetailsManager) {
        this.tokenService = tokenService;
        this.userDetailsManager = userDetailsManager;
    }

    public TokenResponseDto authenticateWithToken(TokenRequestDto tokenRequest) {
        final UserDetails userDetails;
        try {
            userDetails = userDetailsManager.loadUserByUsername(tokenRequest.email());
        } catch (UsernameNotFoundException e) {
            // Log specific error for debugging/monitoring
            log.error("User not found: {}", tokenRequest.email());
            // Return generic message to client for security
            throw new RuntimeException("Invalid credentials");
        }

        // SECURITY RISK: Using plain text password comparison because we're using
        // InMemoryUserDetailsManager with non-encoded passwords.
        // In production use: passwordEncoder.matches(tokenRequest.password(), userDetails.getPassword())
        if (!Objects.equals(userDetails.getPassword(), tokenRequest.password())) {
            // Log the specific error for debugging/monitoring
            log.error("Password mismatch for user: {}", tokenRequest.email());
            // Return generic message to client for security
            throw new RuntimeException("Invalid credentials");
        }
        var permissions = extractPermissions(userDetails);
        var jwtToken = tokenService.generateToken(userDetails.getUsername(), permissions);
        return new TokenResponseDto(jwtToken);
    }

    private String extractPermissions(UserDetails userDetails) {
        return userDetails
            .getAuthorities()
            .stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(" "));
    }
}

If you get stuck at any point or encounter compilation errors you can visit the Github repository to view the complete, working code.


Accessing private endpoint

Alright, those were quite a few things we needed to wrap up. At this point, our demo Spring application is ready to issue tokens and access the private endpoint.

Let’s test this out by submitting a curl request with the username and password to the token endpoint:

curl -X POST http://localhost:8080/token \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"password"}'

We should get the following response with a token:

{"jwt_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJo......"}

If we send this request with the token:

curl http://localhost:8080/private \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJo......"

we should get the following response:

{"message":"Hello From Private Endpoint"}

That would confirm that our security config works correctly


Conclusion and What’s Next

Alright friends, this was a demo application showing how to use Spring’s built-in mechanism to generate and verify JWT tokens. While we used an in-memory data store for simplicity, in a real-world application, you would likely integrate with a persistent data store, such as a database.

On the client side, you may need to handle token expiration, which is embedded in the token’s expiresAt claim. Clients can either track the token’s expiration to re-authenticate or wait for a 401 Unauthorized response before requesting a new token.

Token refresh functionality is beyond the scope of this tutorial.

I recommend using a dedicated authorization server for larger and more complex applications. They also usually come with client side libraries and SDKs that help manage token's lifecycle and re-authenticate if needed.