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.