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:

  1. User logs in
  2. Server creates session, stores in memory/database
  3. Server sends session ID via cookie
  4. 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:

  1. User logs in
  2. Server creates JWT token (signed JSON containing user data)
  3. Client stores token (localStorage/memory)
  4. Client sends token in Authorization header
  5. 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

  1. Store JWT secret securely — use AWS Secrets Manager instead of hardcoding
  2. Use HTTPS in production (JWT in plain HTTP = disaster)
  3. Set appropriate expiration (short-lived tokens + refresh tokens)
  4. Validate all user input
  5. 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:

You just built a complete auth system that Fortune 500 companies would approve of.

Sources: