Skip to main content

Java / Kotlin (Spring Boot)

Setup

// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.nimbusds:nimbus-jose-jwt:9.40")
}

Spring Security filter

@Component
class JwtAuthFilter(
@Value("\${auth.url}") private val authUrl: String,
) : OncePerRequestFilter() {
private val jwksUrl by lazy { URL("$authUrl/.well-known/jwks.json") }
private val jwkSource by lazy {
JWKSourceBuilder.create<SecurityContext>(jwksUrl).cache(true).build()
}

override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
val token = req.cookies?.firstOrNull { it.name == "identsphere_at" }?.value
?: req.getHeader("Authorization")?.removePrefix("Bearer ")
if (token != null) {
try {
val processor = DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource)
}
val claims = processor.process(token, null)
val auth = UsernamePasswordAuthenticationToken(
claims.subject, null, listOf(SimpleGrantedAuthority("ROLE_USER"))
)
SecurityContextHolder.getContext().authentication = auth
} catch (e: Exception) { /* drop, become anonymous */ }
}
chain.doFilter(req, res)
}
}

SecurityConfig

@Configuration
class SecurityConfig(private val jwtFilter: JwtAuthFilter) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.authorizeHttpRequests { it
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}

Proxying login

@RestController
@RequestMapping("/auth")
class AuthProxy(@Value("\${auth.url}") val authUrl: String) {
private val client = HttpClient.newHttpClient()

@PostMapping("/login")
fun login(@RequestBody body: String, response: HttpServletResponse): String {
val req = HttpRequest.newBuilder(URI.create("$authUrl/v1/auth/login"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
val upstream = client.send(req, HttpResponse.BodyHandlers.ofString())
upstream.headers().allValues("set-cookie").forEach {
response.addHeader("Set-Cookie", it)
}
response.status = upstream.statusCode()
return upstream.body()
}
}

Verify tokens in your backend

This is Path 2 — per-request authorization: the RS256 access token is validated locally against the cached JWKS, no network hop per request. The filter above verifies the signature; tighten it to also pin the algorithm and check iss / exp (and aud) with a DefaultJWTClaimsVerifier.

import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.jwk.source.JWKSourceBuilder
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import java.net.URL

fun buildProcessor(authUrl: String, issuer: String, audience: String?):
ConfigurableJWTProcessor<SecurityContext> {

// Caches and refreshes the JWKS — build once, reuse per request.
val jwkSource = JWKSourceBuilder
.create<SecurityContext>(URL("$authUrl/.well-known/jwks.json"))
.cache(true)
.build()

return DefaultJWTProcessor<SecurityContext>().apply {
// Pin RS256 — the key selector only accepts this algorithm.
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource)

// Verify iss + require exp/sub; exp is checked automatically.
val expected = JWTClaimsSet.Builder().issuer(issuer)
if (audience != null) expected.audience(audience) // only if configured
jwtClaimsSetVerifier = DefaultJWTClaimsVerifier(
expected.build(),
setOf("sub", "exp", "iss"),
)
}
}

Then per request:

val claims = processor.process(token, null) // throws on bad sig / iss / exp / aud
val userId = claims.subject // sub
val org = claims.getStringClaim("org")
val aal = claims.getStringClaim("aal")

process throws BadJOSEException (or JOSEException) on any verification failure, so the surrounding filter fails closed.

Spring Security resource server

If you're already on Spring Security, skip the hand-written filter and let the resource-server adapter handle JWKS fetching, RS256, and claim validation:

// application.yml
// spring:
// security:
// oauth2:
// resourceserver:
// jwt:
// jwk-set-uri: https://auth.example.com/.well-known/jwks.json
// issuer-uri: https://auth.example.com # validates iss
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.oauth2ResourceServer { it.jwt {} }
return http.build()
}

To also enforce aud when you set IDENTSPHERE_TOKEN_AUDIENCE, add an audience validator alongside the default issuer/timestamp validators with JwtValidators.createDefaultWithIssuer(...) and DelegatingOAuth2TokenValidator.

Java (non-Kotlin)

Same flow with HttpServletRequest / HttpServletResponse. Use com.nimbusds:nimbus-jose-jwt or io.jsonwebtoken:jjwt for JWT verification.