// build.gradle.kts — the JWT library Ktor's JWT authentication plugin is built ondependencies { implementation("com.auth0:java-jwt:4.6.2")} import com.auth0.jwt.JWTimport com.auth0.jwt.JWTVerifierimport com.auth0.jwt.algorithms.Algorithmimport java.security.KeyFactoryimport java.security.interfaces.ECPublicKeyimport java.security.spec.X509EncodedKeySpecimport kotlin.io.encoding.Base64import kotlin.io.path.Pathimport 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}")} import java.security.PublicKeyimport java.security.Signatureimport kotlin.io.encoding.Base64 // JWS segments are base64url without padding; Kotlin's UrlSafe decoder insists on padding by defaultprivate 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
Algorithm.ECDSA256(publicKey)binds the verifier to ES256 and to this key; a token whose header names any other algorithm fails withAlgorithmMismatchExceptionbefore any cryptography.withIssuerandwithClaimPresenceadd claim checks;exp,nbfandiatare always validated, andacceptLeeway(30)tolerates 30 seconds of clock drift.verifyreturns aDecodedJWTwith typed accessors (subject,expiresAtAsInstant,getClaim) or throws a subclass ofJWTVerificationException— the run triggersSignatureVerificationException,AlgorithmMismatchExceptionandTokenExpiredException.- In Ktor, the same
JWTVerifiergoes intoinstall(Authentication) { jwt { verifier(…) } }, so this code is what the plugin runs on every request. - The JDK-only check shows the mechanism: the signed bytes are the ASCII text
header.payload, and the decoded third segment is the 64-byter‖sthatSHA256withECDSAinP1363Formatreads directly.
Gotchas
Base64.UrlSafe.decodefrom the Kotlin standard library throwsThe padding option is set to PRESENT, but the input is not properly paddedon every JWT segment. Decode withBase64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL).JWT.decode(token)only parses — it checks neither the signature nor the expiry. Reading claims from it before callingverifyis 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.