When you search for "Spring Boot JWT" on Google, many articles and tutorials hit that often involve creating a custom JwtAuthenticationFilter, which may not adequately validate the JWT. Spring Security provides built-in support for JWT authentication and authorization.
https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
By using this, you can delegate token validation, authentication, and authorization to the framework without having to create a JwtAuthenticationFilter.
In this article, we will implement simple REST API authentication and authorization using Spring Security's JWT authentication.
The following operations have been verified on Linux (Ubuntu). Some command arguments may differ on Mac, leading to errors.
Table of Contents
- Creating a Template Project
- Generating RSA Keys for JWT Signing and Verification with OpenSSL
- Creating a REST API with JWT Authentication and Authorization
- Generating JWT with OpenSSL
- Creating an API to Generate JWT
- Creating an Integration Test
Creating a Template Project
First, create a template project using Spring Initializr.
curl -s https://start.spring.io/starter.tgz \
-d artifactId=hello-jwt \
-d baseDir=hello-jwt \
-d packageName=com.example \
-d dependencies=web,actuator,security,configuration-processor \
-d type=maven-project \
-d name=hello-jwt \
-d applicationName=HelloJwtApplication | tar -xzvf -
cd hello-jwt
To use JWT authentication and authorization features in Spring Security, you need to add the following dependency to pom.xml.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Generating RSA Keys for JWT Signing and Verification with OpenSSL
Use the following command to generate keys for JWT signing and verification.
cd src/main/resources
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
openssl pkcs8 -topk8 -inform PEM -in private.pem -out private_key.pem -nocrypt
rm -f private.pem
private_key.pem is the secret key for signing, and public.pem is the public key for verification.
Creating a REST API with JWT Authentication and Authorization
We will create a simple REST API to read and write messages. By using Spring Security's features, you can obtain an authenticated Jwt object by adding @AuthenticationPrincipal to the controller's parameters.
cat <<'EOF' > src/main/java/com/example/MessageController.java
package com.example;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MessageController {
private final List<Message> messages = new CopyOnWriteArrayList<>();
@GetMapping(path = "/messages")
List<Message> getMessages() {
return this.messages;
}
@PostMapping(path = "/messages")
Message postMessages(@RequestBody String text, @AuthenticationPrincipal Jwt jwt) {
Message message = new Message(text, jwt.getSubject());
this.messages.add(message);
return message;
}
record Message(String text, String username) {
}
}
EOF
The configuration for JWT authentication and authorization is as follows. Here, we assume that the JWT must include the message:write scope for writing messages and the message:read scope for reading messages.
cat <<'EOF' > src/main/java/com/example/SecurityConfig.java
package com.example;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.GET, "/messages").access(hasScope("message:read"))
.requestMatchers(HttpMethod.POST, "/messages").access(hasScope("message:write"))
.anyRequest().permitAll())
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> {
}))
.csrf(csrf -> csrf.disable())
.build();
}
}
EOF
We will write the configuration to verify the JWT in application.properties. In this case, we will directly specify the public key.
cat <<'EOF' > src/main/resources/application.properties
spring.application.name=hello-jwt
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:public.pem
EOF
Note
If you are obtaining a JWT from an OpenID Connect Provider, it is better to set:
spring.security.oauth2.resourceserver.jwt.issuer-uri=<OIDC Issuer URI>
This way, at startup, Spring Security will access <OIDC Issuer URI>/.well-known/openid-configuration and download the public key from the URI set in the jwks_uri key.
In this article, we will later generate tokens within the same application. In that case, spring.security.oauth2.resourceserver.jwt.issuer-uri cannot be used.
Now, let's start the application.
./mvnw spring-boot:run
Generating JWT with OpenSSL
Let's create a JWT from the command line. We will use private_key.pem, which pairs with the public.pem specified in application.properties, to sign the JWT.
First, we need to prepare the JWT header and payload. We will Base64 encode the header and payload and generate a concatenated string. Then, we will perform RSA signing on this concatenated string.
Create the header and payload and Base64 encode them. For example, we can use the following header and payload.
Header:
{
"alg": "RS256",
"typ": "JWT"
}
Payload:
{
"sub": "foo@example.com",
"issuer": "http://localhost:8080",
"scope": ["message:read", "message:write"],
"iat": 1725333684,
"exp": 1725355284
}
Use the following command to Base64 encode the JSON of the header and payload, concatenate them with a ., and then sign the resulting string with RSA. Finally, combine the header, payload, and signature with . to create the JWT.
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read","message:write"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"
echo "$JWT"
Access jwt.io and paste the JWT and RSA keys to see "Signature Verified".
This allows you to manually generate a JWT that can be used for the REST API.
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read","message:write"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"
Now, let's use this JWT to write a message.
$ curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World"
{"text":"Hello World","username":"foo@example.com"}
You can confirm that the value specified in the JWT's sub claim is being used as the username.
Now, let's retrieve the message.
$ curl http://localhost:8080/messages -H "Authorization: Bearer $JWT"
[{"text":"Hello World","username":"foo@example.com"}]
This time, let's generate a JWT that only has the message:read scope.
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"
If you try to write a message using this JWT, you will receive a 401 error.
curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World" -v
The error message will be set in the WWW-Authenticate header.
< WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
In this case, we created a JWT with a validity of 10 minutes. After 10 minutes, using this JWT will result in a 401 error, and the following message will be returned.
< WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2024-09-03T03:38:43Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
Additionally, if you use a newly generated JWT signed with a secret key that does not pair with the public key set in the application, the following error message will be returned.
< WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
Creating an API to Generate JWT
While it is safer to delegate JWT generation and user management to an external OIDC Provider, there may be cases where you want to generate JWTs within the same application for ease of use instead of Basic authentication.
Spring Security allows you to implement an OIDC Provider/OAuth2 authorization server using a separate project called Spring Authorization Server. However, if you only need a simple token endpoint to replace Basic authentication without adhering to OAuth2 or OIDC, you can implement it as follows.
First, we will make the public and private keys accessible from properties.
cat <<'EOF' > src/main/java/com/example/JwtProperties.java
package com.example;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
}
EOF
Add the @ConfigurationPropertiesScan annotation to the main class to enable property loading.
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan; // <---
@SpringBootApplication
@ConfigurationPropertiesScan // <---
public class HelloJwtApplication {
public static void main(String[] args) {
SpringApplication.run(HelloJwtApplication.class, args);
}
}
Define the key paths in application.properties.
cat <<'EOF' > src/main/resources/application.properties
jwt.private-key=classpath:private_key.pem
jwt.public-key=classpath:public.pem
spring.application.name=hello-jwt
spring.security.oauth2.resourceserver.jwt.public-key-location=${jwt.public-key}
EOF
Create a TokenSigner to sign JWTs using the secret key. We will use nimbus-jose-jwt, which is used within Spring Security for signing.
cat <<'EOF' > src/main/java/com/example/TokenSigner.java
package com.example;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
@Component
public class TokenSigner implements InitializingBean {
private final JWSSigner signer;
private final JWSVerifier verifier;
public TokenSigner(JwtProperties jwtProps) {
this.signer = new RSASSASigner(jwtProps.privateKey());
this.verifier = new RSASSAVerifier(jwtProps.publicKey());
}
public SignedJWT sign(JWTClaimsSet claimsSet) {
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
SignedJWT signedJWT = new SignedJWT(header, claimsSet);
try {
signedJWT.sign(this.signer);
}
catch (JOSEException e) {
throw new IllegalStateException(e);
}
return signedJWT;
}
@Override
public void afterPropertiesSet() throws Exception {
// Validate the key-pair
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().subject("test").build();
SignedJWT signedJWT = sign(claimsSet);
if (!signedJWT.verify(this.verifier)) {
throw new IllegalStateException("The pair of public key and private key is wrong.");
}
}
}
EOF
Create a controller to generate tokens.
cat <<'EOF' > src/main/java/com/example/TokenController.java
package com.example;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import com.nimbusds.jwt.JWTClaimsSet;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;
import static org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER;
@RestController
public class TokenController {
private final TokenSigner tokenSigner;
private final AuthenticationManager authenticationManager;
private final Clock clock;
public TokenController(TokenSigner tokenSigner, AuthenticationManager authenticationManager, Clock clock) {
this.tokenSigner = tokenSigner;
this.authenticationManager = authenticationManager;
this.clock = clock;
}
@PostMapping(path = "/token")
public Object issueToken(@RequestParam String username, @RequestParam String password, UriComponentsBuilder builder) {
try {
Authentication authenticated = authenticationManager.authenticate(UsernamePasswordAuthenticationToken.unauthenticated(username, password));
UserDetails userDetails = (UserDetails) authenticated.getPrincipal();
String issuer = builder.path("").build().toString();
Instant issuedAt = Instant.now(this.clock);
Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
Set<String> scope = Set.of("message:read", "message:write");
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer(issuer)
.expirationTime(Date.from(expiresAt))
.subject(userDetails.getUsername())
.issueTime(Date.from(issuedAt))
.claim("scope", scope)
.build();
String tokenValue = this.tokenSigner.sign(claimsSet).serialize();
return ResponseEntity.ok(Map.of("access_token", tokenValue,
"token_type", BEARER.getValue(),
"expires_in", Duration.between(issuedAt, expiresAt).getSeconds(),
"scope", scope));
}
catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "unauthorized",
"error_description", e.getMessage()));
}
}
}
EOF
To use AuthenticationManager in the controller, add the following configuration to SecurityConfig.
import java.time.Clock;
// ...
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
// ...
@Bean
public Clock clock() {
return Clock.systemUTC();
}
// https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html#publish-authentication-manager-bean
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withUsername("bar@example.com")
.password("{noop}secret")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Restart the application and issue a token with the following command.
$ curl -s http://localhost:8080/token -d username=bar@example.com -d password=secret | jq .
{
"scope": [
"message:write",
"message:read"
],
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vaG9zdC5vcmIuaW50ZXJuYWw6ODA4MCIsInN1YiI6ImJhckBleGFtcGxlLmNvbSIsImV4cCI6MTcyNTM0MzA0MSwiaWF0IjoxNzI1MzM5NDQxLCJzY29wZSI6WyJtZXNzYWdlOndyaXRlIiwibWVzc2FnZTpyZWFkIl19.wxraPTTuQFb4gEPfmUUXBuHAd6nLgiCVO6gTbPw5lYam1XQfe8m1c1HgapI0HwzkUEW4VT243t6E2erl43F7RWeVSsSVfiT5vGogqYd6iwVQ1mK2BPrGPyWyT8jUw0yuVGzHw03tpK6oBhL8j90R2CX1kUsWslQDQu2w1JPcP7MTeD0vN-gHj_dapx-ClBo5CtO7rLLvNc0US6REBbIisI45DTQliR3HypoZN8YaGHyaal2Q6uIi9JnL2Zow9VW4l8Drol-oIGdM-sx_ZrpHu3xmVpZfv2o-q4pAGEMvtYW_l1buzHB8anSuW2xV6AvPH5YF2jHYE6LDtuxach-2AQ",
"token_type": "Bearer",
"expires_in": 3600
}
Extract the JWT and access the message API.
JWT=$(curl -s http://localhost:8080/token -d username=bar@example.com -d password=secret | jq -r .access_token)
curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World"
curl http://localhost:8080/messages -H "Authorization: Bearer $JWT"
Creating an Integration Test
Finally, let's create a simple integration test.
cat <<'EOF' > src/test/java/com/example/HelloJwtApplicationTests.java
package com.example;
import java.util.List;
import com.example.MessageController.Message;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.NoOpResponseErrorHandler;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureJsonTesters
class HelloJwtApplicationTests {
@LocalServerPort
int port;
RestClient restClient;
@Autowired
JacksonTester<Message> messageTester;
@Autowired
JacksonTester<List<Message>> listTester;
@BeforeEach
void setUp(@Autowired RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.baseUrl("http://localhost:" + port)
.defaultStatusHandler(new NoOpResponseErrorHandler())
.build();
}
@Test
void issueTokenUsingValidCredentialsAndAccessMessageApi() throws Exception {
String token;
{
ResponseEntity<JsonNode> response = this.restClient.post()
.uri("/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("username=bar@example.com&password=secret")
.retrieve()
.toEntity(JsonNode.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
token = response.getBody().get("access_token").asText();
}
{
ResponseEntity<Message> response = this.restClient.post()
.uri("/messages")
.contentType(MediaType.TEXT_PLAIN)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.body("Hello World")
.retrieve()
.toEntity(Message.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(this.messageTester.write(response.getBody())).isEqualToJson("""
{
"username": "bar@example.com",
"text": "Hello World"
}
""");
}
{
ResponseEntity<List<Message>> response = this.restClient.get()
.uri("/messages")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.toEntity(new ParameterizedTypeReference<>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(this.listTester.write(response.getBody())).isEqualToJson("""
[
{
"username": "bar@example.com",
"text": "Hello World"
}
]
""");
}
}
@Test
void issueTokenUsingInvalidCredentials() {
assertThatThrownBy(() -> this.restClient.post()
.uri("/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("username=bar@example.com&password=bar")
.retrieve()
.toEntity(JsonNode.class))
.isInstanceOf(HttpClientErrorException.Unauthorized.class);
}
}
EOF
If the tests pass, you're all set.
./mvnw clean test
We have tried JWT authentication and authorization with Spring Boot + Security.
The code created can be found at https://github.com/making/hello-jwt.
You can also validate the JWT's audience claim or perform custom validations. For more details, please refer to the documentation.