본문 바로가기
개발 (ENG)

Spring Boot (Kotlin) — ep.5 Building the Core Security Architecture with JWT in Spring Boot

by 새싹 아빠 2025. 11. 25.

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

  1. Why Multi-Module Architecture? (Architecture Philosophy & Overall Design)
  2. API Response Format Design
  3. Global Exception Handling (GlobalExceptionHandler)
  4. Swagger(OpenAPI) Configuration
  5. Security (JWT) Core Setup ← Current Article
  6. JWT TokenProvider
  7. Redis Configuration
  8. Validation Setup
  9. Logging + MDC(traceId)
  10. application.yml Profile Separation (local/dev/prod)
  11. Multi-module + JPA Basic Structure
  12. 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.

✔ Decide whether to authenticate or ignore a request
✔ 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.

✔ Works seamlessly across mobile & web clients
✔ 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")
✔ Spring Security core for SecurityConfig, Filters, and Handlers
✔ 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.

✔ API Module: Controllers, Filters, Security, Swagger, Exception Handling
✔ 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

csrf, formLogin, and httpBasic are unnecessary for JWT authentication and should be disabled.

2️⃣ CORS Configuration

Global CORS settings ensure that mobile apps and web frontends can communicate with the API server.

3️⃣ Stateless Session Configuration

JWT is stateless; therefore, the session policy must be set to STATELESS.

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)

To maintain consistency, all security exceptions return a unified JSON API response format.

5️⃣ Public Endpoints

Login, signup, token refresh, Swagger docs, and error endpoints remain publicly accessible.

6️⃣ Add JWT Filter


.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
The JWT filter processes every request before the controller handles it. It validates the token and stores authentication details in the SecurityContext.

 

📌 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
    }
}
✔ Extract JWT from Authorization header
✔ 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())
    }
}
✔ Triggered when authentication does not exist or fails
✔ 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())
    }
}
CustomAccessDeniedHandler is triggered when the request is authenticated but lacks the required permissions.

Occurs when a valid JWT is provided but the user is not authorized to access the resource.

 

📌 Conclusion

📌 SecurityConfig is the first gateway of JWT-based authentication, intercepting every incoming HTTP request — therefore, it naturally belongs in the API module.

📌 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