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:
- Navigate to Spring Initializr
- We’ll use only two dependencies
- Spring Web
- OAuth2 Resource Server (includes Spring Security)
- 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(HttpSecurity http) throws Exception {
SecurityFilterChain return http
.authorizeHttpRequests(auth ->
.requestMatchers("/public").permitAll()
auth.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:
- Disables CSRF protection as it's not needed for JWT-based REST APIs. JWT tokens usually sent in headers, not cookies
- Disables Spring's default login form since we're using custom JWT authentication via token endpoint and not through HTML form
- Disables default logout endpoint as JWT authentication is stateless and there's no server-side session to invalidate
- Disables remember-me functionality since we're not using sessions or cookies
- Disables HTTP Basic Authentication in favor of JWT
- 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(RSAKey rsaKey) throws JOSEException {
JwtDecoder return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
}
@Bean
jwtEncoder(RSAKey rsaKey) {
JwtEncoder 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 operationsJwtEncoder
creates and signs JWT tokens using the private keyJwtDecoder
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(
.withUsername("john@example.com")
User.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 dataAuthController
: Exposes token endpointAuthService
: Authenticates usersTokenService
: Issues jwt tokens
TokenRequestDto
:
TokenRequestDto(
record String email,
String password
) {}
TokenResponseDto
:
TokenResponseDto(
record @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 {
= userDetailsManager.loadUserByUsername(tokenRequest.email());
userDetails } catch (UsernameNotFoundException e) {
// Log specific error for debugging/monitoring
.error("User not found: {}", tokenRequest.email());
log// 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
.error("Password mismatch for user: {}", tokenRequest.email());
log// 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.