본문 바로가기
개발 (ENG)

Spring Boot(Kotlin) — ep.6 Building a Robust JWT TokenProvider

by 새싹 아빠 2025. 11. 25.

📚 Spring Boot (Kotlin) Server Base Setup — Series Guide

  1. Why a Multi-Module Architecture? (Architecture Philosophy & Overall Design)
  2. API Response Format Design
  3. Global Exception Handling (GlobalExceptionHandler)
  4. Swagger (OpenAPI) Configuration
  5. Security (JWT) Basic Skeleton
  6. JWT TokenProvider ← This Article
  7. Redis Configuration
  8. Validation Configuration
  9. Logging + MDC (traceId)
  10. application.yml Profile Separation (local/dev/prod)
  11. Multi-Module + JPA Base Structure
  12. Completed Project Template (Git Repository)

📌 What Is JWT and Why Use It?

When implementing authentication, the first decision is usually between session-based authentication and token-based authentication (JWT).

✔ Session-based authentication: The server stores sessions in memory or a database, and the client sends only a session ID via cookies
✔ JWT-based authentication: The server issues a signed token, and the client sends the token with every request

In this series, we chose a JWT-based authentication approach. JWT (JSON Web Token) can be described simply as a “server-signed, verifiable ID card.”

✔ The server issues JWTs with a signature
✔ The client sends the token in the Authorization header on every API request
✔ The server verifies the signature and expiration time to identify the user

Rather than focusing on JWT theory, this article focuses on how JWTs are actually created and validated in real service code.
Specifically, we build the core JWT authentication structure using AccessTokenProvider + RefreshTokenRepository (Redis) + JwtAuthenticationFilter.

📌 Access Token + Refresh Token

When using JWT, two tokens are typically used together.

✅ Access Token
- Short-lived (e.g., 15 minutes)
- Sent in the Authorization header on every request
- If stolen, it can be abused during its lifetime → keeping it short reduces risk

✅ Refresh Token
- Longer-lived (e.g., 14 days)
- Stored on the server (Redis/DB), making the server the source of truth
- Used only to issue a new Access Token when the Access Token expires

In this design, the Access Token contains only the userId.
Information such as roles, nicknames, or profiles is retrieved from the server when needed, keeping the JWT payload minimal.

📌 JWT-Related Code Placement in a Multi-Module Setup

In a multi-module architecture, JWT-related code is separated as follows.

✅ API Module
- HTTP request/response layer
- Spring Security configuration (SecurityConfig)
- JwtAuthenticationFilter (extracts and validates tokens from requests)
- Authentication / authorization failure handlers (401 / 403)

✅ Application Module
- Domain and business logic layer
- AccessTokenProvider (Access Token creation, validation, parsing)
- RefreshTokenRepository (store / retrieve / delete Refresh Tokens in Redis)
- Used together with AuthService, UserService, etc.

In other words, the API module “extracts and validates tokens”, while the Application module “issues and manages tokens during login”.

📌 JwtAuthenticationFilter (API Module)

This JWT filter runs in the API module. It extracts the Access Token from the request header, validates it, and stores authentication information in the SecurityContext.

package com.example.api.security

import com.example.application.security.TokenProvider
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import org.slf4j.MDC

@Component
class JwtAuthenticationFilter(
    private val accessTokenProvider: AccessTokenProvider
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token = resolveToken(request)

        if (token != null && accessTokenProvider.validateAccessToken(token)) {

            val auth = accessTokenProvider.getAuthentication(token)
            SecurityContextHolder.getContext().authentication = auth

            accessTokenProvider.getUserId(token)?.let { userId ->
                MDC.put("userId", userId.toString())
            }
        }

        filterChain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearer = request.getHeader("Authorization")
        return if (bearer != null && bearer.startsWith("Bearer ")) {
            bearer.substring(7)
        } else null
    }
}
✔ The filter runs on every request
✔ If a token exists and is valid, authentication is stored in SecurityContext
✔ If no token or an invalid token is provided, the request proceeds without authentication
✔ Actual blocking (401/403) is handled by Spring Security

📌 TokenProvider (Application Module)

This class is responsible for creating and validating Access Tokens. This article focuses on an Access Token–only Provider.

package com.example.application.security

import io.jsonwebtoken.*
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.stereotype.Component
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import java.util.*

@Component
class AccessTokenProvider(
    @Value("\${jwt.secret}") secretKey: String
) {

    companion object {
        private const val ACCESS_TOKEN_VALIDITY =
            1000L * 60 * 15   // 15 minutes
    }

    private val key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey))

    fun createAccessToken(userId: Long): String {
        val now = Date()

        return Jwts.builder()
            .setSubject(userId.toString())
            .setIssuedAt(now)
            .setExpiration(Date(now.time + ACCESS_TOKEN_VALIDITY))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()
    }

    fun validateAccessToken(token: String): Boolean {
        return try {
            Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
            true
        } catch (e: JwtException) {
            false
        } catch (e: IllegalArgumentException) {
            false
        }
    }

    fun parseClaims(token: String): Claims {
        return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .body
    }

    fun parseClaimsAllowExpired(token: String): Claims? {
        return try {
            Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .body
        } catch (e: ExpiredJwtException) {
            e.claims
        } catch (e: JwtException) {
            null
        }
    }

    fun getUserId(token: String): Long? {
        val claims = parseClaimsAllowExpired(token) ?: return null
        return claims.subject?.toLongOrNull()
    }

    fun getAuthentication(token: String): Authentication {
        val claims = parseClaims(token)
        val userId = claims.subject

        return UsernamePasswordAuthenticationToken(
            userId,
            null,
            null
        )
    }
}

📌 Overall JWT Authentication Flow

1️⃣ On successful login
- User authentication completes in AuthService
- AccessTokenProvider.createAccessToken(userId) is called
- Server generates a Refresh Token (random string) and stores it in Redis
- Client receives Access Token + Refresh Token

2️⃣ When calling a protected API
- Client sends Authorization: Bearer <AccessToken>
- JwtAuthenticationFilter validates the token
- Authentication is stored in SecurityContext

3️⃣ When the Access Token expires
- Client calls the refresh API with the Refresh Token
- Server validates the Refresh Token from Redis
- A new Access Token is issued

📌 Conclusion

📌 The key to JWT authentication is clearly separating token issuance and token validation
📌 Access Tokens are issued at login and validated on every request
📌 Refresh Tokens are long-lived credentials managed by the server
📌 This structure allows flexible authentication policy changes in the future

Ultimately, the API module handles HTTP and security filters,
while the Application module owns authentication rules and token policies.

 

📌 Next Article Preview

https://jaemoi8.tistory.com/41

 

Spring Boot(Kotlin) — ep.7 Redis Configuration in Spring Boot: Why Your Application Needs It

📚 Spring Boot(Kotlin) Server Setup — Series OverviewWhy Multi-Module Architecture? (Architecture Philosophy & Overall Design)API Response Format DesignGlobal Exception Handling (GlobalExceptionHandler)Swagger (OpenAPI) ConfigurationSecurity (JWT) Basi

jaemoi8.tistory.com