Skip to content
ecdsa.com

Verify a JWT with JWKS · Kotlin

How to verify a JWT with a JWKS in Kotlin

To verify a JWT against a JWKS in Kotlin, give JJWT's parser a key locator that reads the issuer's key set with Jwks.setParser() and returns the key named by the token's kid; requireIssuer and requireAudience add the claim checks. The run serves a real JWKS over HTTPS inside the container and checks five tokens.

Tested with io.jsonwebtoken:jjwt 0.13.0 (Kotlin 2.4.21, jjwt-gson with Gson 2.14.0) and OpenJDK 25.0.4.1 (Eclipse Temurin 25.0.4.1+1, java.net.http.HttpClient).

Verify.kt — a JWKS-backed key locator and a strict JJWT parser
import io.jsonwebtoken.Claims
import io.jsonwebtoken.JwsHeader
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.LocatorAdapter
import io.jsonwebtoken.security.Jwks
import io.jsonwebtoken.security.PublicJwk
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.security.Key
class JwksKeyLocator(private val jwksUri: URI) : LocatorAdapter<Key>() {
private val http = HttpClient.newHttpClient()
@Volatile private var keys: Map<String, Key> = emptyMap()
private fun fetch(): Map<String, Key> {
val body = http.send(HttpRequest.newBuilder(jwksUri).build(), HttpResponse.BodyHandlers.ofString()).body()
return Jwks.setParser().build().parse(body).getKeys() // JwkSet is a Map too: .keys would be its member names
.filterIsInstance<PublicJwk<*>>() // public keys only, with a kid, not marked "enc"
.filter { it.id != null && it.publicKeyUse != "enc" }
.associate { it.id to it.toKey() }
}
// Called by JJWT for every token: the key whose kid the header names
override fun locate(header: JwsHeader): Key? {
val kid = header.keyId ?: return null
// One refetch for an unknown kid (rotation); production code rate-limits it
return keys[kid] ?: fetch().also { keys = it }[kid]
}
}
val parser = Jwts.parser()
.keyLocator(JwksKeyLocator(URI("https://issuer.example.com/.well-known/jwks.json")))
.sig().clear().add(Jwts.SIG.ES256).and() // only ES256 is accepted
.requireIssuer("https://issuer.example.com")
.requireAudience("api://orders")
.build()
fun verifyAccessToken(token: String): Claims = parser.parseSignedClaims(token).payload
Main.kt — check every token in tokens.txt
import io.jsonwebtoken.JwtException
import java.io.File
fun main() {
for (line in File("tokens.txt").readLines()) {
val (label, token) = line.split(" ")
try {
println("$label: accepted, sub=${verifyAccessToken(token).subject}")
} catch (e: JwtException) {
println("$label: rejected: ${e.javaClass.simpleName}: ${e.message}")
}
}
}

How it works

  1. JwksKeyLocator extends JJWT's LocatorAdapter; locate(JwsHeader) is called for each token with its protected header.
  2. Jwks.setParser().build().parse(json) reads the JWKS; each PublicJwk exposes id (the kid), publicKeyUse and toKey().
  3. .sig().clear().add(Jwts.SIG.ES256) limits the parser to ES256; requireIssuer demands an exact iss, requireAudience an aud that contains the value. exp is always checked when present.
  4. parseSignedClaims(token).payload returns the claims or throws a JwtException subclass.

Gotchas

  • JwkSet is also a Map, so in Kotlin .keys resolves to the map's member names (["keys"]), not to the JWKs. Call getKeys() explicitly.
  • The locator here refetches on every unknown kid. A service exposed to the internet must rate-limit that, or every request with a random kid becomes a request to the issuer.
  • An unknown kid fails as UnsupportedJwtException: Cannot verify JWS signature: unable to locate signature verification key for JWS with header: {…} — returned when the locator gives back null.
  • ExpiredJwtException still carries the header and claims (e.claims); log the sub or jti from it, never treat them as authenticated.

Related recipes