Computer scienceBackendSpring BootSpring SecuritySpring Security internalsAuthentication methods

JWT authentication in Spring Boot

18 minutes read

JavaScript Web Tokens (JWT) are an open standard that defines a compact and self-contained way for securely transmitting information between parties. You can verify this information and trust it because it's digitally signed. You can sign JWTs using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. In this topic, you'll learn how to set up JWT-based authentication in a Spring Boot application.

Project setup

Let's create a simple demo project with a single REST endpoint and a single hardcoded user. The minimum set of dependencies will be as follows:

// Using Gradle Kotlin DSL
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
}

The project will have a controller class:

@RestController
class DemoController {

    @GetMapping("/hello")
    fun hello(authentication: Authentication): String {
        return "Hello, ${authentication.name}. Your authorities are: ${authentication.authorities}"
    }
}

It has a single endpoint, /hello, that returns a string with the user's username and authorities.

We also need a security configuration class:

@Configuration
class SecurityConfig {

    @Bean
    @Throws(Exception::class) // Not mandatory in Kotlin, but added for Java interoperability
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .httpBasic(Customizer.withDefaults())
            .authorizeHttpRequests { auth -> auth.anyRequest().authenticated() }
            .csrf(AbstractHttpConfigurer<*, *>::disable)
            .build()
    }

    @Bean
    fun userDetailsService(): UserDetailsService {
        val userDetails = User.withUsername("user1")
            .password(passwordEncoder().encode("password"))
            .authorities("ROLE_USER")
            .build()
        return InMemoryUserDetailsManager(userDetails)
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
}

In this configuration, we create an in-memory UserDetailsService implementation with a single user and define the user's username, password, and authorities. We also declare a PasswordEncoder instance to encode the user's password and configure the security filter chain to enable basic HTTP authentication to be required for accessing any endpoint in the application.

If you run the application and send a request to the /hello endpoint using basic HTTP authentication with the correct credentials, you'll get the following response:

Hello, user1. Your authorities are: [ROLE_USER]

How we use tokens

JWT authentication is a method of identity verification. Basically, the server encodes some information into a JWT (like a user's identity) and sends it to the client. The client can then send the JWT back to the server whenever it needs to access a protected route, and the server will know the client's request is legitimate because it has the JWT that the server itself issued.

Unlike basic HTTP authentication, JWT doesn't require entering the user's username and password for every request or maintaining the user's session on the server. This improves the system's efficiency as the server doesn't need to guard users' secrets or store session data. JWT aids in decoupling the authorization server from the resource server. The resource server can validate the JWT locally without communicating with the authorization server. JWTs are compact and can be transmitted through a URL, POST parameter, or inside an HTTP header. Its small size enables quick data transfers. A JWT contains all the necessary information about the user, eliminating the need to query the database more than once.

Now let's examine the necessary steps to implement JWT authentication in this demo project.

You'll need to create, encode, and sign tokens and send them to the user. Also, you'll need to decode tokens received from the user to extract necessary information for authentication and authorization. There are many libraries for working with JWTs, but Spring Security provides mechanisms that automate this job. You'll create tokens yourself but will delegate decoding, authentication, and authorization to Spring Security.

Let's check what you'll need to do in the next section.

Changing the project configuration

First, let's add the resource server starter to the build file:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") // <- this
}

This starter will enable JWT authentication and provide you with the necessary tools. Next, we will add JWT authentication to the security filter chain config:

@Bean
@Throws(Exception::class) // Not mandatory in Kotlin, but added for Java interoperability
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    return http
        .httpBasic(Customizer.withDefaults())
        .oauth2ResourceServer { oauth -> oauth.jwt() } // enabling jwt authentication
        .authorizeHttpRequests { auth -> auth.anyRequest().authenticated() }
        .csrf(AbstractHttpConfigurer<*, *>::disable)
        .build()
}

This config will require certain beans in the application context, which we'll create step by step. In this demo project, we will sign tokens with RSA keys that we generate on project startup:

@Configuration
class RsaKeysConfig {

    @Bean
    fun generateRsaKeys(): KeyPair {
        return try {
            val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
            keyPairGenerator.initialize(2048)
            keyPairGenerator.generateKeyPair()
        } catch (e: NoSuchAlgorithmException) {
            throw IllegalStateException(e)
        }
    }
}

This method generates a pair of private and public keys using the RSA algorithm with a key size of 2048 bits. This is the minimum allowed RSA key size. You'll inject this KeyPair object to create a JwtDecoder bean that the resource server will use to decode JWTs:

@Configuration
class SecurityConfig {
    // other methods

    @Bean
    fun jwtDecoder(keyPair: KeyPair): JwtDecoder {
        return NimbusJwtDecoder
            .withPublicKey(keyPair.public as RSAPublicKey)
            .build()
    }
}

But before you decode any JWT issued by your application, you need to encode it first. For that, you need two more beans:

@Configuration
class SecurityConfig {

    // other methods

    @Bean
    fun jwtDecoder(keyPair: KeyPair): JwtDecoder {
        return NimbusJwtDecoder
            .withPublicKey(keyPair.public as RSAPublicKey)
            .build()
    }

    @Bean
    fun jwkSource(keyPair: KeyPair): JWKSource<SecurityContext> {
        val rsaKey = RSAKey.Builder(keyPair.public as RSAPublicKey)
            .privateKey(keyPair.private)
            .keyID(UUID.randomUUID().toString())
            .build()
        val jwkSet = JWKSet(rsaKey)
        return ImmutableJWKSet(jwkSet)
    }

    @Bean
    fun jwtEncoder(jwkSource: JWKSource<SecurityContext>): JwtEncoder {
        return NimbusJwtEncoder(jwkSource)
    }
}

Now everything is ready for issuing tokens.

JWT issuing

As the next step, we'll add a new endpoint to the controller responsible for issuing JWTs:

@RestController
class DemoController(private val jwtEncoder: JwtEncoder) {

    @GetMapping("/hello")
    fun hello(authentication: Authentication): String {
        return "Hello, ${authentication.name}. Your authorities are: ${authentication.authorities}"
    }

    @PostMapping("/token")
    fun token(authentication: Authentication): String {
        val authorities = authentication.authorities
            .map(GrantedAuthority::getAuthority)

        val claimsSet = JwtClaimsSet.builder()
            .subject(authentication.name)
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plus(60, ChronoUnit.SECONDS))
            .claim("scope", authorities)
            .build()

        return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet))
            .tokenValue
    }
}

Let's delve into the token method. To get a JWT, the user needs to authenticate in the application via basic HTTP authentication. After that, we extract information about the user, like the username and authority list, but not the password. We exclude the password for two reasons. First, we trust our JWT because it's signed by the RSA key we generated and therefore can't be forged. Second, any JWT can be legally decoded and if leaked, such sensitive information will be compromised.

In the token, we specify the subject, which is the user to whom we're issuing the token, the issue time, the expiration time after which the token will become invalid and a so-called claim named "scope" that contains a list of the user's authorities.

Now if you send a request to the /token endpoint with basic HTTP authentication and the right credentials, you'll receive a token that looks similar to this:

eyJraWQiOiIyZTYyOWJlYy0xZjcxLTQyZWEtOGQwYi0yZmY0ZTdmMWRjODkiLCJhbGciOiJSUzI1NiJ9
.eyJzdWIiOiJ1c2VyMSIsImV4cCI6MTcwMzM3MTM5NywiaWF0IjoxNzAzMzcxMzM3LCJzY29wZSI6WyJ
ST0xFX1VTRVIiXX0.mz7EupoXmN1s2WMSABjpuy_okwwL2ZMfOIcc6hUDdthy78FVFUw-OdXX1QwKfDX
4NKEVmXQOEGbwkRi-TWF980eo_rSPxP1bN8UIDQMQQLUQNNoREaLY_8GZsYGtfuZDWubm4PqjtJs4vF7
XiDl1wHfj-TfsjjpAo8-czJdVsScoXkEZe-RMvpRMcSY8r8afgZuBm7q1BsLbqnmx1VpOQTQQxkqVIfx
rIcRnIwtTfqsQsPhLwTVGzYdfPL4lAdCkM8FdUBVFsRFuSUdsQiTiISmTgS_L5oTCXX9sb_EA2W3is3h
ek1wpmLXgFBNIvfxy_YC6iMa4jul4d1obzn41Ow

You can copy and paste this token to this site to decode and check its content.

Now you can use this token to access the /hello endpoint using Bearer Token authentication. This time, you'll get the following response:

Hello, user1. Your authorities are: [SCOPE_ROLE_USER]

Note that now the user's authority is SCOPE_ROLE_USER because the decoder added the prefix SCOPE. You should always remember this when setting authorization rules.

Removing basic HTTP authentication

You might have noticed that in its current state, the /hello endpoint can be accessed using either Basic HTTP or Bearer Token authentication. What if we want to allow access to that endpoint using only JWTs? One simple way is to play a trick with the authority and permit access to the endpoint only for users with the authority SCOPE_ROLE_USER.

Another, more involved but flexible way, is to disable basic HTTP authentication completely and perform the necessary authentication by yourself. Let's do it for practice!

First, we change the security filter chain settings and create an AuthenticationManager bean that we'll use for authentication:

@Configuration
class SecurityConfig {

    @Bean
    @Throws(Exception::class) // Not mandatory in Kotlin, but added for Java interoperability
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .oauth2ResourceServer { oauth -> oauth.jwt() }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers(HttpMethod.POST, "/token").permitAll()
                    .anyRequest().authenticated()
            }
            .csrf(AbstractHttpConfigurer<*, *>::disable)
            .build()
    }

    @Bean
    fun userDetailsService(): UserDetailsService {
        val userDetails = User.withUsername("user1")
            .password(passwordEncoder().encode("password"))
            .authorities("ROLE_USER")
            .build()
        return InMemoryUserDetailsManager(userDetails)
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }

    @Bean
    fun authenticationManager(): AuthenticationManager {
        val authenticationProvider = DaoAuthenticationProvider()
        authenticationProvider.setUserDetailsService(userDetailsService())
        authenticationProvider.setPasswordEncoder(passwordEncoder())
        return ProviderManager(authenticationProvider)
    }

    // other methods
}

We open access to the /token endpoint to everyone. Additionally, we create an instance of ProviderManager as the AuthenticationManager bean and define which AuthenticationProvider, UserDetailsService and PasswordEncoder we are going to use in this application. In this case, we use an in-memory UserDetailsService but you can use a persistent user store to the same effect.

Finally, we extract user credentials from the request header and manually authenticate the user:

@RestController
class DemoController(
    private val jwtEncoder: JwtEncoder,
    private val authenticationManager: AuthenticationManager
) {

    @GetMapping("/hello")
    fun hello(authentication: Authentication): String {
        return "Hello, ${authentication.name}. Your authorities are: ${authentication.authorities}"
    }

    @PostMapping("/token")
    fun token(request: HttpServletRequest): String {
        val authHeader = request.getHeader(HttpHeaders.AUTHORIZATION)
            ?: throw BadCredentialsException("Full authentication is required")

        val encoded = authHeader.substring(6)
        val decoded = String(Base64.getDecoder().decode(encoded))
        val credentials = decoded.split(":")
        
        val authToken = UsernamePasswordAuthenticationToken(credentials[0], credentials[1])
        val authentication = authenticationManager.authenticate(authToken)

        val authorities = authentication.authorities
            .map(GrantedAuthority::getAuthority)

        val claimsSet = JwtClaimsSet.builder()
            .subject(authentication.name)
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plus(60, ChronoUnit.SECONDS))
            .claim("scope", authorities)
            .build()

        return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet))
            .tokenValue
    }
}

First, you get the value of the Authorization header, then extract the credentials from the string and decode it. After that, you get the plain text username and password and try to authenticate the user with the extracted credentials. Then you have a fully authenticated Authentication object with the username and authorities, and the rest of the token generation remains the same.

Now the /token endpoint is accessible using Basic HTTP, as before, with the only difference being that now we perform the authentication without relying on the relevant authentication filter, and the /hello endpoint is accessible only by using Bearer Token authentication.

Conclusion

In this topic, you've learned about JWT authentication and how to apply it in Spring Boot. You have explored the process of customizing RSA key generation and creating beans for the resource server. Furthermore, you have practiced the JWT issuing process and implemented basic HTTP authentication from scratch.

12 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo