Skip to content
ecdsa.com

Sign an ES256 JWT · Swift

How to sign a JWT with ES256 in Swift

To sign a JWT with ES256 in Swift, Vapor's JWTKit (jwt-kit) loads an ES256PrivateKey from PEM, registers it in a JWTKeyCollection and signs a Codable payload. It runs on Apple platforms and on Linux through swift-crypto; the run builds on Linux and checks the token with OpenSSL.

Tested with jwt-kit 5.7.1 (Swift 6.4.1, Linux, swift-crypto 4.5.2).

Package.swift — jwt-kit 5.7.1
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "es256-demo",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/jwt-kit.git", exact: "5.7.1"),
],
targets: [
.executableTarget(name: "es256-demo", dependencies: [.product(name: "JWTKit", package: "jwt-kit")]),
]
)
main.swift — ES256 with JWTKit
import Foundation
import JWTKit
struct AccessToken: JWTPayload {
var iss: IssuerClaim
var sub: SubjectClaim
var aud: AudienceClaim
var iat: IssuedAtClaim
var exp: ExpirationClaim
func verify(using algorithm: some JWTAlgorithm) throws {
try exp.verifyNotExpired()
}
}
let key = try ES256PrivateKey(pem: String(contentsOfFile: "key.pem", encoding: .utf8))
let keys = await JWTKeyCollection().add(ecdsa: key, kid: "2026-10")
// Whole seconds: JSONEncoder would otherwise write fractional timestamps
let now = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down))
let payload = AccessToken(
iss: "https://issuer.example.com",
sub: "user-42",
aud: "api://orders",
iat: IssuedAtClaim(value: now),
exp: ExpirationClaim(value: now.addingTimeInterval(15 * 60))
)
print(try await keys.sign(payload, kid: "2026-10")) // the kid is not added unless passed here

How it works

  1. ES256PrivateKey(pem:) reads PKCS#8 and EC PRIVATE KEY PEM.
  2. JWTKeyCollection is an actor, so add(ecdsa:kid:) and sign are awaited.
  3. The payload is a JWTPayload struct with JWTKit's claim types (IssuerClaim, AudienceClaim, ExpirationClaim, …); verify(using:) is what JWTKit runs when it verifies a token.
  4. sign(payload, kid:) writes alg, typ and kid and returns the compact token.

Gotchas

  • Registering the key under a kid does not put the kid in tokens: sign(payload) without kid: writes only alg and typ. Verifiers that select keys by kid then cannot find the key.
  • Dates are encoded with fractional seconds: Date() becomes "iat":1792499789.2461329. RFC 7519 allows it, but every other library on these pages writes whole seconds and integer-based checks downstream break — round the date as above.
  • Slashes come out escaped: "iss":"https:\/\/issuer.example.com". That is valid JSON and verifiers decode it, but grep or string comparisons over the raw payload do not see https://.
  • jwt-kit 5.7 needs Swift 6.2 or later (its manifest declares swift-tools-version:6.2). Tutorials for jwt-kit 4 use JWTSigners and signers.use(.es256(key:)), which jwt-kit 5 replaced with JWTKeyCollection.

Related recipes