📚 Spring Boot (Kotlin) Server Base Setup — Series Guide
- Why a Multi-Module Architecture? (Architecture Philosophy & Overall Design)
- API Response Format Design
- Global Exception Handling (GlobalExceptionHandler)
- Swagger (OpenAPI) Configuration
- Security (JWT) Basic Skeleton
- JWT TokenProvider ← This Article
- Redis Configuration
- Validation Configuration
- Logging + MDC (traceId)
- application.yml Profile Separation (local/dev/prod)
- Multi-Module + JPA Base Structure
- 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).
✔ 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 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.
- 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.
- 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
}
}
✔ 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
- 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
📌 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