Leaving your API endpoints unprotected is like leaving your MPesa pin as "1234" - eventually, someone's going to help themselves to your resources.
JWT (JSON Web Tokens) authentication is the modern standard for securing REST APIs. No sessions. No cookies. Just stateless, scalable authentication that works across multiple servers.
Today we're building JWT authentication in Spring Boot 3 with Spring Security 6 - user registration, login, token generation, and protecting endpoints. Production-ready code you can actually use. Works great with REST APIs built in Spring Boot + Kotlin.
What Is JWT Authentication?
Traditional session-based auth:
- User logs in
- Server creates session, stores in memory/database
- Server sends session ID via cookie
- Every request checks session in database
Problems: Doesn't scale (sessions stored server-side), doesn't work well with microservices, requires sticky sessions in load balancers.
JWT auth:
- User logs in
- Server creates JWT token (signed JSON containing user data)
- Client stores token (localStorage/memory)
- Client sends token in Authorization header
- Server validates signature (no database lookup needed!)
Benefits: Stateless, scales horizontally, works across microservices, mobile-friendly.
Setup
Dependencies (build.gradle.kts):
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.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
runtimeOnly("com.h2database:h2")
}
Implementation
1. User Entity:
@Entity
@Table(name = "users")
data class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(unique = true, nullable = false)
val email: String,
@Column(nullable = false)
val password: String,
@Column(nullable = false)
val fullName: String,
@Enumerated(EnumType.STRING)
val role: Role = Role.USER
) : UserDetails {
override fun getAuthorities() = listOf(SimpleGrantedAuthority("ROLE_$role"))
override fun getPassword() = password
override fun getUsername() = email
override fun isAccountNonExpired() = true
override fun isAccountNonLocked() = true
override fun isCredentialsNonExpired() = true
override fun isEnabled() = true
}
enum class Role {
USER, ADMIN
}
2. JWT Service:
@Service
class JwtService {
@Value("\${jwt.secret}")
private lateinit var secretKey: String
@Value("\${jwt.expiration}")
private var jwtExpiration: Long = 86400000 // 24 hours
fun generateToken(user: UserDetails): String {
val claims = mapOf<String, Any>(
"sub" to user.username,
"authorities" to user.authorities.map { it.authority }
)
return Jwts.builder()
.claims(claims)
.subject(user.username)
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSigningKey())
.compact()
}
fun extractUsername(token: String): String =
extractClaim(token) { it.subject }
fun validateToken(token: String, userDetails: UserDetails): Boolean {
val username = extractUsername(token)
return username == userDetails.username && !isTokenExpired(token)
}
private fun <T> extractClaim(token: String, claimsResolver: (Claims) -> T): T {
val claims = extractAllClaims(token)
return claimsResolver(claims)
}
private fun extractAllClaims(token: String): Claims {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.payload
}
private fun isTokenExpired(token: String): Boolean {
return extractClaim(token) { it.expiration }.before(Date())
}
private fun getSigningKey(): SecretKey {
val keyBytes = Decoders.BASE64.decode(secretKey)
return Keys.hmacShaKeyFor(keyBytes)
}
}
3. Authentication Filter:
@Component
class JwtAuthenticationFilter(
private val jwtService: JwtService,
private val userDetailsService: UserDetailsService
) : 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 jwt = authHeader.substring(7)
val username = jwtService.extractUsername(jwt)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = userDetailsService.loadUserByUsername(username)
if (jwtService.validateToken(jwt, userDetails)) {
val authToken = UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authToken
}
}
filterChain.doFilter(request, response)
}
}
4. Security Configuration:
@Configuration
@EnableWebSecurity
class SecurityConfiguration(
private val jwtAuthFilter: JwtAuthenticationFilter
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
config.authenticationManager
}
5. Auth Service:
@Service
class AuthenticationService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtService: JwtService,
private val authenticationManager: AuthenticationManager
) {
fun register(request: RegisterRequest): AuthResponse {
if (userRepository.existsByEmail(request.email)) {
throw EmailAlreadyExistsException("Email already registered")
}
val user = User(
email = request.email,
password = passwordEncoder.encode(request.password),
fullName = request.fullName,
role = Role.USER
)
userRepository.save(user)
val token = jwtService.generateToken(user)
return AuthResponse(token = token, email = user.email, fullName = user.fullName)
}
fun login(request: LoginRequest): AuthResponse {
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(request.email, request.password)
)
val user = userRepository.findByEmail(request.email)
?: throw BadCredentialsException("Invalid credentials")
val token = jwtService.generateToken(user)
return AuthResponse(token = token, email = user.email, fullName = user.fullName)
}
}
6. DTOs:
data class RegisterRequest(
@field:Email(message = "Invalid email format")
val email: String,
@field:Size(min = 8, message = "Password must be at least 8 characters")
val password: String,
@field:NotBlank(message = "Full name is required")
val fullName: String
)
data class LoginRequest(
@field:Email
val email: String,
@field:NotBlank
val password: String
)
data class AuthResponse(
val token: String,
val email: String,
val fullName: String
)
7. Auth Controller:
@RestController
@RequestMapping("/api/auth")
class AuthController(private val authService: AuthenticationService) {
@PostMapping("/register")
fun register(@Valid @RequestBody request: RegisterRequest): ResponseEntity<AuthResponse> {
return ResponseEntity.ok(authService.register(request))
}
@PostMapping("/login")
fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
return ResponseEntity.ok(authService.login(request))
}
}
@RestController
@RequestMapping("/api")
class ProtectedController {
@GetMapping("/profile")
fun getProfile(authentication: Authentication): ResponseEntity<Map<String, Any>> {
val user = authentication.principal as User
return ResponseEntity.ok(mapOf(
"email" to user.email,
"fullName" to user.fullName,
"role" to user.role
))
}
@GetMapping("/admin/dashboard")
fun adminDashboard(): String {
return "Admin Dashboard - Secret Data"
}
}
8. Configuration (application.properties):
jwt.secret=your-256-bit-secret-key-here-make-it-long-and-secure
jwt.expiration=86400000
spring.datasource.url=jdbc:h2:mem:authdb
spring.jpa.hibernate.ddl-auto=create-drop
Testing the API
Register:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123",
"fullName": "John Kamau"
}'
Login:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
Access Protected Endpoint:
curl http://localhost:8080/api/profile \
-H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"
Best Practices
- Store JWT secret securely — use AWS Secrets Manager instead of hardcoding
- Use HTTPS in production (JWT in plain HTTP = disaster)
- Set appropriate expiration (short-lived tokens + refresh tokens)
- Validate all user input
- Don't store sensitive data in JWT payload (it's base64, not encrypted!)
For simpler auth needs, check out PHP API authentication patterns.
The Bottom Line
JWT authentication with Spring Security gives you:
- Stateless authentication
- Horizontal scalability
- Microservices-friendly (essential for Spring Boot microservices with Eureka)
- Mobile app support
You just built a complete auth system that Fortune 500 companies would approve of.
Sources: