Skip to content
ecdsa.com

Verify a JWT with JWKS · Swift

How to verify a JWT with a JWKS in Swift

To verify a JWT against a JWKS in Swift, fetch the key set with URLSession and load it into JWTKit's JWTKeyCollection, which selects the key by the token's kid and verifies with the algorithm the JWK declares; the payload type checks issuer, audience and exp. The run builds on Linux, serves a real JWKS over HTTPS and checks five tokens.

Tested with jwt-kit 5.7.1 (Swift 6.4.1, Linux, URLSession from FoundationNetworking).

Package.swift — jwt-kit 5.7.1
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "jwks-demo",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/jwt-kit.git", exact: "5.7.1"),
],
targets: [
.executableTarget(name: "jwks-demo", dependencies: [.product(name: "JWTKit", package: "jwt-kit")]),
]
)
main.swift — JWKS into JWTKeyCollection, claim checks in the payload type
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // URLSession on Linux
#endif
import JWTKit
struct AccessToken: JWTPayload {
var iss: IssuerClaim
var sub: SubjectClaim
var aud: AudienceClaim
var exp: ExpirationClaim
// JWTKit checks the signature; the claims are checked here
func verify(using algorithm: some JWTAlgorithm) throws {
try exp.verifyNotExpired()
try aud.verifyIntendedAudience(includes: "api://orders")
guard iss.value == "https://issuer.example.com" else {
throw JWTError.claimVerificationFailure(failedClaim: iss, reason: "unexpected issuer \(iss.value)")
}
}
}
// Keys from the JWKS, selected by the token's kid (fetch once, cache, refetch on rotation).
// Each key verifies with its own "alg" from the JWKS, whatever the token header says.
let jwksURL = URL(string: "https://issuer.example.com/.well-known/jwks.json")!
let (jwksData, _) = try await URLSession.shared.data(from: jwksURL)
let keys = try await JWTKeyCollection().add(jwksJSON: String(decoding: jwksData, as: UTF8.self))
await keys.clearDefault() // otherwise a missing or unknown kid falls back to the first key of the set
func verifyAccessToken(_ token: String) async throws -> AccessToken {
try await keys.verify(token, as: AccessToken.self)
}
check every token in tokens.txt
for line in try String(contentsOfFile: "tokens.txt", encoding: .utf8).split(separator: "\n") {
let parts = line.split(separator: " ").map(String.init)
do {
let claims = try await verifyAccessToken(parts[1])
print("\(parts[0]): accepted, sub=\(claims.sub.value)")
} catch {
print("\(parts[0]): rejected: \(error)")
}
}

How it works

  1. URLSession.shared.data(from:) downloads the JWKS; on Linux, URLSession comes from FoundationNetworking.
  2. add(jwksJSON:) registers every key under its kid; each JWK verifies with its own alg.
  3. verify(_:as:) checks the signature, decodes the payload, and calls its verify(using:), where exp, aud and iss are checked.
  4. Failures are JWTError values: claimVerificationFailure with the failing claim, signatureVerificationFailed, or noKeyProvided for an unknown kid.

Gotchas

  • The first key added becomes the default, and a token with an unknown or missing kid is checked against it. Without clearDefault(), the token signed by an unpublished key fails as signatureVerificationFailed — misleading, and with a single-key JWKS it means kid is not checked at all.
  • Claims are checked only in verify(using:): JWTKit does nothing with aud or iss unless the payload type does it.
  • The key collection here is loaded once. A server must reload it on a schedule and when a kid is unknown, with a rate limit.
  • import FoundationNetworking is needed on Linux and must not be imported on Apple platforms; the #if canImport guard keeps one source for both.

Related recipes