📚 Spring Boot(Kotlin) Server Basic Setup — Series Guide
- Why Multi-Module Architecture? (Architecture Philosophy & Overall Design)
- API Response Format Design
- Global Exception Handling (GlobalExceptionHandler)
- Swagger(OpenAPI) Configuration
- Security (JWT) Core Setup ← Current Article
- JWT TokenProvider
- Redis Configuration
- Validation Setup
- Logging + MDC(traceId)
- application.yml Profile Separation (local/dev/prod)
- Multi-module + JPA Basic Structure
- Final Project Template share(git)
📌 Why Do We Need Security (JWT) Configuration?
The Security configuration is the core layer that controls the overall security policies of an API server.
✔ Determine which URLs are public
✔ Control how 401/403 security exceptions should respond
✔ Define the order of security filters
To implement authentication in an API server, developers typically choose between session-based authentication and JWT-based authentication.
There's no universal “correct” choice, but for this series, I selected the token-based approach.
JWT does not require the server to store session state, making it more scalable for distributed environments.
✔ Ideal for large-scale distributed systems
✔ Stateless — no session storage needed
✔ Custom filters allow flexible authentication flow configuration
In this article, we will build the core structure of JWT authentication (SecurityConfig), including JWT validation filter setup and custom handlers for authentication/authorization failures.
📌 Required Dependencies
To set up Spring Security with JWT support, include these dependencies:
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
✔ Jackson Kotlin module for JSON response serialization
✔ JWT creation/validation will be covered in the next article (JWT TokenProvider)
📌 Why Place SecurityConfig in the API Module?
Security operates at the HTTP request filtering layer, which is the entry point of all external requests.
✔ Application Module: Business logic, Entities, Services, Domain rules
Therefore, SecurityConfig naturally belongs in the API module, since it intercepts and processes requests before they reach business logic.
📌 Full SecurityConfig Code
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val accessDeniedHandler: CustomAccessDeniedHandler,
private val authenticationEntryPoint: CustomAuthenticationEntryPoint,
private val jwtAuthenticationFilter: JwtAuthenticationFilter
) {
@Bean
fun userDetailsService(): UserDetailsService {
return UserDetailsService { _ ->
throw UsernameNotFoundException("Not using UserDetailsService in JWT authentication")
}
}
@Bean
fun authenticationManager(authConfig: AuthenticationConfiguration): AuthenticationManager {
return authConfig.authenticationManager
}
companion object {
private val PUBLIC_ENDPOINTS = listOf(
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/v1/auth/**",
"/error"
)
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.formLogin { it.disable() }
.httpBasic { it.disable() }
.cors {
it.configurationSource {
CorsConfiguration().apply {
allowedOriginPatterns = listOf("*")
allowedMethods = listOf("*")
allowedHeaders = listOf("*")
allowCredentials = true
}
}
}
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.exceptionHandling {
it.authenticationEntryPoint(authenticationEntryPoint)
it.accessDeniedHandler(accessDeniedHandler)
}
.authorizeHttpRequests {
it
.requestMatchers(*PUBLIC_ENDPOINTS.toTypedArray())
.permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter::class.java
)
return http.build()
}
}
📌 SecurityConfig Explained
1️⃣ Disable Default Authentication Mechanisms
2️⃣ CORS Configuration
Global CORS settings ensure that mobile apps and web frontends can communicate with the API server.
3️⃣ Stateless Session Configuration
4️⃣ Authentication Failure (401) & Authorization Failure (403)
override fun commence(request, response, authException)
→ Authentication failure (401)
override fun handle(request, response, accessDeniedException)
→ Authorization failure (403)
5️⃣ Public Endpoints
Login, signup, token refresh, Swagger docs, and error endpoints remain publicly accessible.
6️⃣ Add JWT Filter
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
📌 JwtAuthenticationFilter Code
@Component
class JwtAuthenticationFilter(
private val tokenProvider: TokenProvider
) : OncePerRequestFilter() {
override fun doFilterInternal(request, response, filterChain) {
val token = resolveToken(request)
if (token != null && tokenProvider.validateToken(token)) {
val auth = tokenProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = auth
val userId = tokenProvider.getUserId(token)
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
}
}
✔ Validate token
✔ Create Authentication object and store in SecurityContext
✔ Store userId into MDC for request tracing
📌 CustomAuthenticationEntryPoint (401)
@Component
class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(request, response, authException) {
val body = ApiResponse.fail(
code = ErrorCode.UNAUTHORIZED.code,
messageKey = ErrorCode.UNAUTHORIZED.messageKey,
detail = "Authorization header missing or invalid"
)
response.status = ErrorCode.UNAUTHORIZED.status
response.contentType = "application/json;charset=UTF-8"
response.writer.write(body.toJson())
}
}
✔ Occurs when the client sends an invalid or missing JWT
✔ Returns a unified API response structure
📌 CustomAccessDeniedHandler (403)
@Component
class CustomAccessDeniedHandler : AccessDeniedHandler {
override fun handle(request, response, accessDeniedException) {
val body = ApiResponse.fail(
code = ErrorCode.FORBIDDEN.code,
messageKey = ErrorCode.FORBIDDEN.messageKey,
detail = "You do not have permission to access this resource"
)
response.status = ErrorCode.FORBIDDEN.status
response.contentType = "application/json;charset=UTF-8"
response.writer.write(body.toJson())
}
}
Occurs when a valid JWT is provided but the user is not authorized to access the resource.
📌 Conclusion
📌 Authentication (401) and Authorization (403) failures are unified into consistent JSON responses across the entire API.
📌 With a stateless architecture + JWT + custom filters, we build a modern and scalable authentication system without relying on sessions.
📌 Next Article Preview
Episode 6 — JWT TokenProvider
We will look at how JWT is generated, validated, and parsed within the application module.
https://jaemoi8.tistory.com/39
Spring Boot(Kotlin) — ep.6 Building a Robust JWT TokenProvider
📚 Spring Boot (Kotlin) Server Setup — Series GuideWhy Multi-Module Architecture? (Design Philosophy & Overall Structure)API Response Format DesignGlobal Exception Handling (GlobalExceptionHandler)Swagger (OpenAPI) ConfigurationSecurity (JWT) Core Stru
jaemoi8.tistory.com