Skip to content
ecdsa.com

Verify an ES256 JWT · Kotlin

How to verify an ES256 JWT in Kotlin

To verify an ES256 JWT in Kotlin, use Auth0's java-jwt — the library Ktor's JWT authentication plugin (ktor-server-auth-jwt) is built on: JWT.require(Algorithm.ECDSA256(publicKey)) pins the algorithm, checks the signature and validates the claims. The second snippet shows the bare signature check with the JDK, including the base64url padding detail Kotlin's decoder trips over.

Tested with com.auth0:java-jwt 4.6.2 (Kotlin 2.4.21/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1).

build.gradle.kts — Auth0 java-jwt
// build.gradle.kts — the JWT library Ktor's JWT authentication plugin is built on
dependencies {
implementation("com.auth0:java-jwt:4.6.2")
}
Main.kt — ES256 pinned, issuer required, expiry checked
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.security.KeyFactory
import java.security.interfaces.ECPublicKey
import java.security.spec.X509EncodedKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readText
fun es256Verifier(publicKeyPem: String): JWTVerifier {
val spki = Base64.Mime.decode(publicKeyPem.replace(Regex("-----[^-]+-----"), ""))
val publicKey = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(spki)) as ECPublicKey
return JWT.require(Algorithm.ECDSA256(publicKey)) // ES256 only: other "alg" values are rejected
.withIssuer("https://ecdsa.com")
.withClaimPresence("sub")
.acceptLeeway(30) // seconds of clock skew allowed for exp, nbf and iat
.build()
}
fun main() {
val jwt = es256Verifier(Path("pub.pem").readText()).verify(Path("token.txt").readText().trim())
println("valid ES256 token for ${jwt.subject}, expires ${jwt.expiresAtAsInstant}")
}
the signature check alone, JDK plus Kotlin's Base64
import java.security.PublicKey
import java.security.Signature
import kotlin.io.encoding.Base64
// JWS segments are base64url without padding; Kotlin's UrlSafe decoder insists on padding by default
private val base64Url = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
/** The ES256 signature check alone: a JWS signature already is r‖s, so no DER conversion. */
fun es256SignatureValid(token: String, publicKey: PublicKey): Boolean {
val (header, payload, signature) = token.split(".").also { require(it.size == 3) { "not a compact JWS" } }
return Signature.getInstance("SHA256withECDSAinP1363Format").run {
initVerify(publicKey)
update("$header.$payload".toByteArray(Charsets.US_ASCII)) // the base64url text is what was signed
verify(base64Url.decode(signature))
}
}

How it works

  1. Algorithm.ECDSA256(publicKey) binds the verifier to ES256 and to this key; a token whose header names any other algorithm fails with AlgorithmMismatchException before any cryptography.
  2. withIssuer and withClaimPresence add claim checks; exp, nbf and iat are always validated, and acceptLeeway(30) tolerates 30 seconds of clock drift.
  3. verify returns a DecodedJWT with typed accessors (subject, expiresAtAsInstant, getClaim) or throws a subclass of JWTVerificationException — the run triggers SignatureVerificationException, AlgorithmMismatchException and TokenExpiredException.
  4. In Ktor, the same JWTVerifier goes into install(Authentication) { jwt { verifier(…) } }, so this code is what the plugin runs on every request.
  5. The JDK-only check shows the mechanism: the signed bytes are the ASCII text header.payload, and the decoded third segment is the 64-byte r‖s that SHA256withECDSAinP1363Format reads directly.

Gotchas

  • Base64.UrlSafe.decode from the Kotlin standard library throws The padding option is set to PRESENT, but the input is not properly padded on every JWT segment. Decode with Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL).
  • JWT.decode(token) only parses — it checks neither the signature nor the expiry. Reading claims from it before calling verify is a classic way to trust a forged token.
  • Kotlin does not force you to catch JWTVerificationException. Let it escape a request handler and an invalid token turns into a 500 instead of a 401.
  • The JDK-only check proves the signature and nothing else: an expired token still returns true. Keep it for understanding or debugging and validate claims with the library.

Related recipes