Skip to content

JWT Authentication for Task API

Build a Spring Boot REST API for tasks, secured with stateless JWT authentication. Users register and log in over public endpoints, receive a signed token, and present it as a Bearer header on every protected request. Passwords are hashed with BCrypt and authorization is role-based.

If you’ve done auth in Express with jsonwebtoken + middleware, or in Go with a hand-rolled http.Handler wrapper, this is the same idea — except Spring’s filter chain and method-security annotations do most of the wiring for you.

MethodPathAuthDescription
POST/api/auth/registerPublicRegister a new user, get a JWT
POST/api/auth/loginPublicLog in, get a JWT
GET/api/tasksBearer JWTList the current user’s tasks
POST/api/tasksBearer JWTCreate a task
DELETE/api/tasks/{id}Bearer JWTDelete own task (or any if ADMIN)
  1. A JwtService that signs and verifies HMAC-SHA tokens with the JJWT library.
  2. A SecurityConfig that locks down /api/**, runs the API statelessly, and plugs a custom filter into Spring Security’s chain.
  3. A JwtAuthenticationFilter that reads the Authorization header, validates the token, and populates the SecurityContext.
  4. An AuthController (register/login) and a TaskController whose routes are protected — including a @PreAuthorize rule for owner-or-admin deletes.

The login call exchanges credentials for a token; every later call carries that token, which the filter turns back into an authenticated principal before your controller ever runs.

Login then authenticated request
Rendering diagram…

A standard Spring Boot layout. The auth machinery lives in service/ and config/; the HTTP surface in controller/; JPA entities and repositories round it out. H2 in-memory keeps it runnable with zero infra.

  • Directoryjwt-auth-api/
    • build.gradle.kts Spring Boot, Security, JPA, JJWT deps
    • Directorysrc/main/
      • Directorykotlin/com/example/jwtauth/
        • JwtAuthApplication.kt entry point
        • Directoryservice/
          • JwtService.kt sign + verify tokens
        • Directoryconfig/
          • SecurityConfig.kt filter chain + access rules
          • JwtAuthenticationFilter.kt validates bearer token per request
        • Directorycontroller/
          • AuthController.kt register + login
          • TaskController.kt protected task routes
          • Dtos.kt request/response data classes
        • Directorymodel/
          • AppUser.kt user entity
          • Task.kt task entity
        • Directoryrepository/
          • UserRepository.kt
          • TaskRepository.kt
      • Directoryresources/
        • application.yml H2 + jwt secret config
    • Directorysrc/test/kotlin/com/example/jwtauth/
      • AuthIntegrationTest.kt MockMvc end-to-end tests

Three Spring starters (web, security, data-jpa) plus the JJWT split into an api artifact at compile time and impl/jackson at runtime — the standard JJWT 0.12 packaging. jackson-module-kotlin lets Jackson construct Kotlin data classes without a no-arg constructor.

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("org.springframework.boot:spring-boot-starter-validation")
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}

The token codec. generateToken builds a signed JWT whose subject is the user id, with email and roles as extra claims and a 24-hour expiry. validateToken parses-and-verifies in one call — parseSignedClaims throws if the signature is wrong or the token has expired, so a successful return is the validation.

The secret and expiry are injected from application.yml. Note the escaped @Value("\${jwt.secret}") — the backslash stops Kotlin from treating ${…} as a string template so Spring sees the literal property placeholder.

src/main/kotlin/com/example/jwtauth/service/JwtService.kt
@Service
class JwtService(
@Value("\${jwt.secret}") private val secret: String,
@Value("\${jwt.expiration-ms}") private val expirationMs: Long,
) {
private val key: SecretKey = Keys.hmacShaKeyFor(secret.toByteArray())
fun generateToken(userId: String, email: String, roles: List<String>): String {
val now = Date()
val expiry = Date(now.time + expirationMs)
return Jwts.builder()
.subject(userId)
.claim("email", email)
.claim("roles", roles)
.issuedAt(now)
.expiration(expiry)
.signWith(key)
.compact()
}
fun validateToken(token: String): Claims {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.payload
}
fun getUserId(token: String): String = validateToken(token).subject
@Suppress("UNCHECKED_CAST")
fun getRoles(token: String): List<String> =
validateToken(token)["roles"] as? List<String> ?: emptyList()
}

This is where Spring Security is told how to behave. The Kotlin DSL (http { … }) reads top-to-bottom: open the two auth endpoints and the H2 console, require authentication for everything else under /api/**, deny the rest. SessionCreationPolicy.STATELESS is the JWT contract — no server-side session, the token carries identity on every request.

The crucial line is addFilterBefore: it slots the custom JWT filter ahead of Spring’s UsernamePasswordAuthenticationFilter, so by the time the authorization rules run, the request is already authenticated (or not).

src/main/kotlin/com/example/jwtauth/config/SecurityConfig.kt
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(
private val jwtAuthFilter: JwtAuthenticationFilter,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() }
cors { }
authorizeHttpRequests {
authorize(HttpMethod.POST, "/api/auth/register", permitAll)
authorize(HttpMethod.POST, "/api/auth/login", permitAll)
authorize("/h2-console/**", permitAll)
authorize("/api/**", authenticated)
authorize(anyRequest, denyAll)
}
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthFilter)
exceptionHandling {
authenticationEntryPoint = HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)
}
headers {
frameOptions { sameOrigin() } // For H2 console
}
}
return http.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
config.authenticationManager
}

The bridge between “a token in a header” and “an authenticated user Spring understands.” Extending OncePerRequestFilter guarantees it runs exactly once per request. It pulls the Bearer token, validates it via JwtService, maps each role to a ROLE_-prefixed SimpleGrantedAuthority (Spring’s convention for hasRole(...)), and stores a UsernamePasswordAuthenticationToken in the SecurityContextHolder.

If anything throws — bad signature, expired token, garbage header — the catch logs and moves on with no authentication set. The request still proceeds to the authorization rules, which then reject it with 401. That’s the same pattern as a Go middleware that calls next.ServeHTTP either way and lets a later check fail the unauthenticated request.

src/main/kotlin/com/example/jwtauth/config/JwtAuthenticationFilter.kt
@Component
class JwtAuthenticationFilter(
private val jwtService: JwtService,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val token = extractToken(request)
if (token != null) {
try {
val claims = jwtService.validateToken(token)
val userId = claims.subject
val roles = jwtService.getRoles(token)
val authorities = roles.map { SimpleGrantedAuthority("ROLE_$it") }
val authentication = UsernamePasswordAuthenticationToken(
userId,
null,
authorities,
)
SecurityContextHolder.getContext().authentication = authentication
} catch (e: Exception) {
logger.debug("Invalid JWT token: ${e.message}")
}
}
filterChain.doFilter(request, response)
}
private fun extractToken(request: HttpServletRequest): String? {
val header = request.getHeader("Authorization") ?: return null
return if (header.startsWith("Bearer ")) header.substring(7) else null
}
}

The two public endpoints. register rejects duplicate emails with 409 CONFLICT, hashes the password with passwordEncoder.encode(...), persists the user, and hands back a fresh token. login looks the user up and calls passwordEncoder.matches(raw, hash) — BCrypt compares the plaintext against the stored hash in constant time. A missing user or a bad password both return 401, deliberately indistinguishable so you don’t leak which emails exist.

src/main/kotlin/com/example/jwtauth/controller/AuthController.kt
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtService: JwtService,
) {
@PostMapping("/register")
fun register(@RequestBody request: RegisterRequest): ResponseEntity<AuthResponse> {
if (userRepository.existsByEmail(request.email)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build()
}
val user = userRepository.save(
AppUser(
email = request.email,
passwordHash = passwordEncoder.encode(request.password),
name = request.name,
roles = listOf("USER"),
)
)
val token = jwtService.generateToken(
userId = user.id.toString(),
email = user.email,
roles = user.roles,
)
return ResponseEntity.status(HttpStatus.CREATED).body(
AuthResponse(token = token, userId = user.id, email = user.email)
)
}
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
val user = userRepository.findByEmail(request.email)
?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
if (!passwordEncoder.matches(request.password, user.passwordHash)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
}
val token = jwtService.generateToken(
userId = user.id.toString(),
email = user.email,
roles = user.roles,
)
return ResponseEntity.ok(
AuthResponse(token = token, userId = user.id, email = user.email)
)
}
}

The protected routes. Because the filter already authenticated the request, Spring injects the Authentication object straight into the handler — its principal is the userId string we put in the token’s subject. No header parsing in the controller; identity just arrives.

The interesting line is the @PreAuthorize on delete: hasRole('ADMIN') or @taskController.isOwner(#id, authentication.name). This is declarative authorization — Spring evaluates the SpEL expression before the method body runs, calling back into the bean’s own isOwner to check ownership. Admins delete anything; everyone else only their own tasks.

src/main/kotlin/com/example/jwtauth/controller/TaskController.kt
@RestController
@RequestMapping("/api/tasks")
class TaskController(
private val taskRepository: TaskRepository,
) {
@GetMapping
fun getTasks(authentication: Authentication): List<TaskResponse> {
val userId = authentication.principal as String
return taskRepository.findByUserId(userId).map { it.toResponse() }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createTask(
@RequestBody request: CreateTaskRequest,
authentication: Authentication,
): TaskResponse {
val userId = authentication.principal as String
val task = taskRepository.save(
Task(title = request.title, description = request.description, userId = userId)
)
return task.toResponse()
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @taskController.isOwner(#id, authentication.name)")
fun deleteTask(@PathVariable id: Long, authentication: Authentication): ResponseEntity<Void> {
val task = taskRepository.findById(id)
if (task.isEmpty) {
return ResponseEntity.notFound().build()
}
taskRepository.deleteById(id)
return ResponseEntity.noContent().build()
}
fun isOwner(taskId: Long, userId: String): Boolean {
val task = taskRepository.findById(taskId).orElse(null) ?: return false
return task.userId == userId
}
private fun Task.toResponse() = TaskResponse(
id = id,
title = title,
description = description,
userId = userId,
createdAt = createdAt.toString(),
)
}

AppUser stores the BCrypt passwordHash (never the raw password) and an @ElementCollection of role strings. Task carries the owning userId. The JWT secret and 24-hour expiry live in application.yml.

src/main/resources/application.yml
spring:
datasource:
url: jdbc:h2:mem:taskdb
jpa:
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true
path: /h2-console
jwt:
secret: "my-super-secret-key-that-is-at-least-256-bits-long-for-hs256-signing"
expiration-ms: 86400000 # 24 hours
  1. Start the server (H2 runs in-memory, so no external database needed):

    Terminal window
    ./gradlew bootRun
  2. Register a user — the response includes your token:

    Terminal window
    curl -X POST http://localhost:8080/api/auth/register \
    -H "Content-Type: application/json" \
    -d '{"email":"user@example.com","password":"secret123","name":"Test User"}'
  3. Log in (or reuse the register token) and capture it:

    Terminal window
    TOKEN="paste-token-here"
  4. Create and list tasks with the bearer token:

    Terminal window
    curl -X POST http://localhost:8080/api/tasks \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"title":"Learn Spring Security","description":"Complete module 14"}'
    curl http://localhost:8080/api/tasks \
    -H "Authorization: Bearer $TOKEN"

The AuthIntegrationTest drives the full stack through MockMvc — no real HTTP, but the entire security filter chain runs. It checks the 401-without-token, 200-with-token, register/login happy paths, the 409 on duplicate email, the 401 on a wrong password, and a create-then-list round trip.

Terminal window
./gradlew test
src/test/kotlin/com/example/jwtauth/AuthIntegrationTest.kt
@Test
fun `unauthenticated request returns 401`() {
mockMvc.get("/api/tasks")
.andExpect { status { isUnauthorized() } }
}
@Test
fun `authenticated request returns 200`() {
val token = jwtService.generateToken("1", "test@test.com", listOf("USER"))
mockMvc.get("/api/tasks") {
header("Authorization", "Bearer $token")
}.andExpect {
status { isOk() }
}
}