PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 1 min read

Spring Security — JWT Authentication from Scratch

Implement JWT authentication in Spring Boot 4 with Spring Security 7 — login endpoint, token generation, validation, refresh tokens, and protected routes.


Spring Security is powerful and complex. This post cuts through the abstraction layers and builds JWT authentication from scratch in Spring Boot 4 — a login endpoint, token generation, token validation, and protected routes.

Dependencies

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("org.springframework.boot:spring-boot-starter-security")
  implementation("org.springframework.boot:spring-boot-starter-data-jpa")
  implementation("io.jsonwebtoken:jjwt-api:0.12.6")
  runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
  runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("org.springframework.security:spring-security-test")
}

Security configuration

Spring Security 7 (shipped with Spring Boot 4) uses the component-based configuration model. No more WebSecurityConfigurerAdapter.

package com.example.demo.config

import com.example.demo.security.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
  private val jwtAuthenticationFilter: JwtAuthenticationFilter
) {

  @Bean
  fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    return http
      .csrf { it.disable() }
      .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
      .authorizeHttpRequests { auth ->
        auth
          .requestMatchers("/api/v1/auth/**").permitAll()
          .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
          .anyRequest().authenticated()
      }
      .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
      .build()
  }

  @Bean
  fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

  @Bean
  fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager {
    return config.authenticationManager
  }
}

Key points:

  • CSRF disabled — we’re stateless with JWTs, no cookies
  • Session creation set to STATELESS — no server-side sessions
  • Public endpoints explicitly listed, everything else requires authentication
  • JWT filter runs before Spring’s default username/password filter

User entity and repository

package com.example.demo.domain

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID

@Entity
@Table(name = "users")
class AppUser(
  @Id
  @GeneratedValue(strategy = GenerationType.UUID)
  val id: UUID? = null,

  @Column(nullable = false, unique = true)
  val email: String,

  @Column(nullable = false)
  val passwordHash: String,

  @Enumerated(EnumType.STRING)
  @Column(nullable = false)
  val role: Role = Role.USER
)

enum class Role {
  USER, ADMIN
}
package com.example.demo.repository

import com.example.demo.domain.AppUser
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

interface UserRepository : JpaRepository<AppUser, UUID> {

  fun findByEmail(email: String): AppUser?
}

UserDetailsService implementation

Spring Security needs a UserDetailsService to load users during authentication:

package com.example.demo.security

import com.example.demo.repository.UserRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
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.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service

@Service
class CustomUserDetailsService(
  private val userRepository: UserRepository
) : UserDetailsService {

  override fun loadUserByUsername(username: String): UserDetails {
    val user = userRepository.findByEmail(username)
      ?: throw UsernameNotFoundException("User not found: $username")

    return User.builder()
      .username(user.email)
      .password(user.passwordHash)
      .authorities(SimpleGrantedAuthority("ROLE_${user.role.name}"))
      .build()
  }
}

JWT utility

package com.example.demo.security

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.util.Date
import javax.crypto.SecretKey

@Component
class JwtUtil(
  @Value("\${jwt.secret}")
  private val secret: String,

  @Value("\${jwt.expiration-ms}")
  private val expirationMs: Long
) {

  private val key: SecretKey by lazy {
    Keys.hmacShaKeyFor(secret.toByteArray())
  }

  fun generateToken(userDetails: UserDetails): String {
    val now = Date()
    val expiry = Date(now.time + expirationMs)

    return Jwts.builder()
      .subject(userDetails.username)
      .claim("roles", userDetails.authorities.map { it.authority })
      .issuedAt(now)
      .expiration(expiry)
      .signWith(key)
      .compact()
  }

  fun extractUsername(token: String): String {
    return extractClaims(token).subject
  }

  fun isTokenValid(token: String, userDetails: UserDetails): Boolean {
    val username = extractUsername(token)
    return username == userDetails.username && !isTokenExpired(token)
  }

  private fun isTokenExpired(token: String): Boolean {
    return extractClaims(token).expiration.before(Date())
  }

  private fun extractClaims(token: String): Claims {
    return Jwts.parser()
      .verifyWith(key)
      .build()
      .parseSignedClaims(token)
      .payload
  }
}

Configuration

jwt:
  secret: your-256-bit-secret-key-that-is-at-least-32-bytes-long
  expiration-ms: 3600000  # 1 hour

In production, load the secret from environment variables or a secrets manager — never commit it.

JWT authentication filter

This filter intercepts every request, extracts the JWT from the Authorization header, validates it, and sets the security context:

package com.example.demo.security

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
  private val jwtUtil: JwtUtil,
  private val userDetailsService: CustomUserDetailsService
) : OncePerRequestFilter() {

  override fun doFilterInternal(
    request: HttpServletRequest,
    response: HttpServletResponse,
    filterChain: FilterChain
  ) {
    val authHeader = request.getHeader("Authorization")

    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
      filterChain.doFilter(request, response)
      return
    }

    val token = authHeader.substring(7)

    try {
      val username = jwtUtil.extractUsername(token)

      if (SecurityContextHolder.getContext().authentication == null) {
        val userDetails = userDetailsService.loadUserByUsername(username)

        if (jwtUtil.isTokenValid(token, userDetails)) {
          val authToken = UsernamePasswordAuthenticationToken(
            userDetails,
            null,
            userDetails.authorities
          )
          authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
          SecurityContextHolder.getContext().authentication = authToken
        }
      }
    } catch (e: Exception) {
      // Invalid token — continue without authentication
      logger.debug("JWT validation failed: ${e.message}")
    }

    filterChain.doFilter(request, response)
  }
}

Auth controller

package com.example.demo.controller

import com.example.demo.domain.AppUser
import com.example.demo.domain.Role
import com.example.demo.repository.UserRepository
import com.example.demo.security.JwtUtil
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

data class RegisterRequest(
  @field:NotBlank
  @field:Email
  val email: String,

  @field:NotBlank
  @field:Size(min = 8, message = "Password must be at least 8 characters")
  val password: String
)

data class LoginRequest(
  @field:NotBlank val email: String,
  @field:NotBlank val password: String
)

data class AuthResponse(
  val token: String,
  val expiresIn: Long
)

@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
  private val authenticationManager: AuthenticationManager,
  private val userDetailsService: UserDetailsService,
  private val userRepository: UserRepository,
  private val passwordEncoder: PasswordEncoder,
  private val jwtUtil: JwtUtil
) {

  @PostMapping("/register")
  fun register(@Valid @RequestBody request: RegisterRequest): ResponseEntity<AuthResponse> {
    if (userRepository.findByEmail(request.email) != null) {
      return ResponseEntity.status(HttpStatus.CONFLICT).build()
    }

    val user = AppUser(
      email = request.email,
      passwordHash = passwordEncoder.encode(request.password),
      role = Role.USER
    )
    userRepository.save(user)

    val userDetails = userDetailsService.loadUserByUsername(request.email)
    val token = jwtUtil.generateToken(userDetails)

    return ResponseEntity.status(HttpStatus.CREATED)
      .body(AuthResponse(token = token, expiresIn = 3600))
  }

  @PostMapping("/login")
  fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
    try {
      authenticationManager.authenticate(
        UsernamePasswordAuthenticationToken(request.email, request.password)
      )
    } catch (e: BadCredentialsException) {
      return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
    }

    val userDetails = userDetailsService.loadUserByUsername(request.email)
    val token = jwtUtil.generateToken(userDetails)

    return ResponseEntity.ok(AuthResponse(token = token, expiresIn = 3600))
  }
}

Using the API

Register

curl -X POST http://localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "securepass123"}'

Response:

{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "expiresIn": 3600
}

Access a protected endpoint

curl http://localhost:8080/api/v1/products \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

Without a token

curl http://localhost:8080/api/v1/admin/users
# 401 Unauthorized

Accessing the current user

In any controller or service, get the authenticated user:

import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails

fun getCurrentUsername(): String {
  val authentication = SecurityContextHolder.getContext().authentication
  val userDetails = authentication.principal as UserDetails
  return userDetails.username
}

Or inject it directly in a controller method:

@GetMapping("/me")
fun me(@AuthenticationPrincipal userDetails: UserDetails): ResponseEntity<UserResponse> {
  val user = userService.findByEmail(userDetails.username)
  return ResponseEntity.ok(user.toResponse())
}

Error handling for auth failures

Spring Security returns generic 401/403 responses by default. Customize them:

package com.example.demo.security

import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component

@Component
class CustomAuthEntryPoint(
  private val objectMapper: ObjectMapper
) : AuthenticationEntryPoint {

  override fun commence(
    request: HttpServletRequest,
    response: HttpServletResponse,
    authException: AuthenticationException
  ) {
    response.status = HttpServletResponse.SC_UNAUTHORIZED
    response.contentType = MediaType.APPLICATION_JSON_VALUE

    val body = mapOf(
      "status" to 401,
      "error" to "Unauthorized",
      "message" to "Authentication required",
      "path" to request.requestURI
    )
    objectMapper.writeValue(response.outputStream, body)
  }
}

Register it in the security config:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
  return http
    // ... existing config ...
    .exceptionHandling { it.authenticationEntryPoint(customAuthEntryPoint) }
    .build()
}

Security checklist

  • Use BCrypt (or Argon2) for password hashing — never store plaintext
  • Keep JWT expiration short (15 minutes to 1 hour)
  • Load the signing secret from environment variables, not config files
  • Validate token signature and expiration on every request
  • Return the same error for “user not found” and “wrong password” — don’t leak which emails exist
  • Log authentication failures for monitoring
  • Use HTTPS in production — JWTs are signed, not encrypted

What’s next

This gives you basic JWT authentication. For role-based access control, see Spring Security RBAC.