Skip to content
ecdsa.com

JWK thumbprint · Swift

How to compute a JWK thumbprint (RFC 7638) in Swift

Swift computes a JWK thumbprint with the Crypto module's SHA256 over the RFC 7638 canonical JSON — the required members of the key type, sorted, without whitespace — built as a string rather than with JSONSerialization. For a P-256 PEM key, x and y come from rawRepresentation. The results match the published RFC 7638 and RFC 8037 thumbprints.

Tested with swift-crypto 4.5.2 (Swift 6.4.1, Linux, Foundation JSONSerialization).

Package.swift — swift-crypto (CryptoKit on Apple platforms)
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "thumbprint-demo",
dependencies: [
.package(url: "https://github.com/apple/swift-crypto.git", exact: "4.5.2"),
],
targets: [
.executableTarget(name: "thumbprint-demo", dependencies: [.product(name: "Crypto", package: "swift-crypto")]),
]
)
main.swift — thumbprints from JWK files and from a P-256 PEM key
import Crypto
import Foundation
struct UnsupportedKey: Error {}
/// RFC 7638: the required members of each key type, in lexicographic order.
let requiredMembers = ["EC": ["crv", "kty", "x", "y"], "RSA": ["e", "kty", "n"], "OKP": ["crv", "kty", "x"], "oct": ["k", "kty"]]
func base64URL(_ data: some DataProtocol) -> String {
Data(data).base64EncodedString()
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
func jwkThumbprint(_ jwk: [String: String]) throws -> String {
guard let kty = jwk["kty"], let names = requiredMembers[kty] else { throw UnsupportedKey() }
// Built by hand: JSONSerialization would escape "/" and does not promise member order
let members = try names.map { name -> String in
guard let value = jwk[name] else { throw UnsupportedKey() }
return "\"\(name)\":\"\(value)\""
}
return base64URL(Data(SHA256.hash(data: Data("{\(members.joined(separator: ","))}".utf8))))
}
for file in ["ec.jwk.json", "rsa.jwk.json", "okp.jwk.json"] {
let object = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: file))) as! [String: Any]
let jwk = object.compactMapValues { $0 as? String }
print("\(file): \(try jwkThumbprint(jwk))")
}
// A P-256 key from PEM: x and y are the two halves of rawRepresentation
let key = try P256.Signing.PublicKey(pemRepresentation: String(contentsOfFile: "ec.pub.pem", encoding: .utf8))
let point = key.rawRepresentation // x ‖ y, 32 bytes each
let jwk = ["kty": "EC", "crv": "P-256", "x": base64URL(point.prefix(32)), "y": base64URL(point.suffix(32))]
print("ec.pub.pem: \(try jwkThumbprint(jwk))")

How it works

  1. requiredMembers lists the members per key type in lexicographic order; the canonical JSON is assembled from them as a string.
  2. SHA256.hash(data:) returns a digest; Data(digest) and base64 with the URL-safe alphabet and no padding give the thumbprint.
  3. JWK files are read with JSONSerialization into a dictionary; only string members are needed for these key types.
  4. P256.Signing.PublicKey(pemRepresentation:) reads the PEM; its 64-byte rawRepresentation is x followed by y.

Gotchas

  • JSONSerialization escapes / as \/, and neither it nor JSONEncoder guarantees member order (.sortedKeys helps with order, not with escaping). Building the canonical form explicitly avoids both.
  • SHA256.Digest is not Data: pass Data(digest) (or iterate it) to anything that takes DataProtocol.
  • Curve25519 keys have no PEM initializer; for Ed25519 PEM files strip the 12-byte SPKI prefix and use the remaining 32 bytes as x, as the Ed25519 recipe of this series does.
  • The string-building approach is correct only because every member value of these key types is base64url (no quotes or backslashes); keep it to EC, RSA, OKP and oct JWKs.

Related recipes