What
- CORS blocks non-whitelisted origins in
AppSecurityConfig. - JWT is validated for protected endpoints in
JwtAuthenticationFilterusingAuthenticationManagerJWT. - API tiers restrict access even with a valid token; higher tiers grant access to more endpoints.
Flow (request lifecycle):
- Incoming request → CORS applied → non-whitelisted origins blocked.
- Public/No-Auth endpoints bypass JWT checks.
- Protected endpoints → JWT extracted and validated.
- Tier authorization matched against requested API tier.
- On success, controller executes; on failure, error is routed to global handler.
Why
- Prevent misuse from unwanted domains via strict CORS.
- Reject bad tokens: expired, blocked, reused/refresh-misuse.
- Enforce progression: open → tier 4 → tier 3 → tier 2 → tier 1 as business rules demand.
How
- CORS configuration (
AppSecurityConfig.corsConfigurationSource()):
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf(
"http://localhost:3000",
"https://yourdomain.com"
)
configuration.allowedMethods = listOf("GET","POST","PUT","DELETE","OPTIONS","PATCH")
configuration.allowedHeaders = listOf(
"Authorization","Content-Type","X-Requested-With","Accept","Origin",
KeywordsAndConstants.HEADER_TRACKING_ID,
KeywordsAndConstants.HEADER_API_KEY,
KeywordsAndConstants.HEADER_OTP,
KeywordsAndConstants.HEADER_AUTH_TOKEN
)
configuration.allowCredentials = true
configuration.exposedHeaders = listOf(
KeywordsAndConstants.HEADER_TRACKING_ID,
KeywordsAndConstants.HEADER_API_TIER
)
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
Explanation:
allowedOriginsstrictly whitelists domains that can call the APIs; everything else is blocked at the edge.allowedHeaders/exposedHeadersexplicitly permit and reveal only what clients need (tracking ID, API tier), reducing leakage.allowCredentials = trueenables cookies/authorization headers only for whitelisted origins.JWT filter flow (
JwtAuthenticationFilter):
override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
try {
// Allow public and no-auth endpoints
val path = req.requestURI
if (isPublicEndpoint(path) || isNoAuthAPI(path)) {
chain.doFilter(req, res); return
}
// Require token for protected endpoints
val token = jwtUtilMaster.getJwtAuth(req, mustReturnTokenOrElseCrash = true)
if (token != null) {
val auth = JwtAuthenticationToken(
KeywordsAndConstants.TOKEN_PREFIX + token,
req.method,
path,
req.getHeader(KeywordsAndConstants.HEADER_TRACKING_ID) ?: ""
)
val authentication = authenticationManager.authenticate(auth)
SecurityContextHolder.getContext().authentication = authentication
chain.doFilter(req, res)
} else {
handlerExceptionResolver.resolveException(req, res, null, Exception("JWT token is required for this endpoint"))
}
} catch (e: Exception) {
handlerExceptionResolver.resolveException(req, res, null, e)
}
}
Explanation:
Public endpoints: swagger/docs/favicon; No-Auth endpoints: business endpoints whitelisted via
NO_AUTH_APIS.Missing/invalid token: routed to global handler via
handlerExceptionResolver(no raw container error pages).Successful authentication:
SecurityContextHoldergets the authenticated token and the request proceeds to the controller.Tier enforcement (
AuthenticationManagerJWT):
private fun checkJwtAuthorizations(subject: String, token: JwtAuthenticationToken) {
val apiTier = determineApiTier(token.path)
when (subject) {
KeywordsAndConstants.TOKEN_TIRE_FOUR -> if (apiTier in listOf(ApiTier.TIRE_THREE, ApiTier.TIRE_TWO, ApiTier.TIRE_ONE))
throw ForbiddenExceptionThrowable(
errorMessage = KeywordsAndConstants.SOFT_TOKEN_TIRE_FOUR_ERROR_GENERIC,
code = KeywordsAndConstants.SOFT_TOKEN_TIRE_FOUR_ERROR_GENERIC_CODE
)
KeywordsAndConstants.TOKEN_TIRE_THREE -> if (apiTier in listOf(ApiTier.TIRE_TWO, ApiTier.TIRE_ONE))
throw ForbiddenExceptionThrowable(
errorMessage = KeywordsAndConstants.SOFT_TOKEN_TIRE_THREE_ERROR,
code = KeywordsAndConstants.SOFT_TOKEN_TIRE_THREE_ERROR_CODE
)
KeywordsAndConstants.TOKEN_TIRE_TWO -> if (apiTier == ApiTier.TIRE_ONE)
throw ForbiddenExceptionThrowable(
errorMessage = KeywordsAndConstants.SOFT_TOKEN_TIRE_TWO_ERROR,
code = KeywordsAndConstants.SOFT_TOKEN_TIRE_TWO_ERROR_CODE
)
KeywordsAndConstants.TOKEN_TIRE_ONE -> { /* full access */ }
}
}
Explanation:
determineApiTier(path)maps the requested URL to anApiTierusing constants (APIS_TIER_*,NO_AUTH_APIS).The token’s
sub(subject) determines its tier (TOKEN_TIRE_ONEis highest). Lower-tier tokens are blocked from higher-tier APIs with specific, consistent error codes.Open vs protected:
- Open: swagger, docs, favicon via
isPublicEndpoint(). - No-auth business APIs: from
KeywordsAndConstants.NO_AUTH_APISviaisNoAuthAPI(). - Everything else requires a valid token and passes tier checks.
- Open: swagger, docs, favicon via
Extra hardening:
- Generate and propagate a tracking ID when missing; expose it to clients for tracing.
- Add client IP and an internal access-log ID to the request (not exposed to clients) using
HeaderEnhancedHttpServletRequest. - Validate “refresh vs access” token usage; block reused/blocked/expired tokens; route all failures via the resolver to the global handler.
The filter uses handlerExceptionResolver.resolveException(...) to forward errors into the global handler. The mechanics of that pattern are covered in Error Handling in Spring Boot: Controllers vs Filters.