Skip to content
ecdsa.com

Verify a certificate chain · Swift

How to verify a certificate chain against your own trust store in Swift

To verify a certificate chain against your own trust store in Swift, use the Verifier from Apple's swift-certificates: give it a CertificateStore of your roots and a policy (RFC5280Policy plus ServerIdentityPolicy), then await validate(leaf:intermediates:). It returns the validated chain or the reasons it could not validate. This recipe checks a leaf → intermediate → root chain from a test CA and three chains that must fail, on Linux.

Tested with swift-certificates 1.21.0 (Swift 6.4.1 on Ubuntu 24.04, Verifier with RFC5280Policy).

Package.swift — swift-certificates
// swift-tools-version:6.2
import PackageDescription
let package = Package(
name: "howto",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/apple/swift-certificates.git", exact: "1.21.0"),
],
targets: [
.executableTarget(name: "verify", dependencies: [.product(name: "X509", package: "swift-certificates")]),
]
)
Sources/verify/main.swift — Verifier with RFC 5280 and server identity policies
import Foundation
import X509
func load(_ path: String) throws -> Certificate {
try Certificate(pemEncoded: String(contentsOfFile: path, encoding: .utf8))
}
/// RFC 5280 path building and validation against the given roots, plus the server name.
func verify(leaf: Certificate, intermediates: [Certificate], roots: [Certificate], host: String) async -> String {
var verifier = Verifier(rootCertificates: CertificateStore(roots)) {
RFC5280Policy(validationTime: Date())
ServerIdentityPolicy(serverHostname: host, serverIP: nil)
}
let result = await verifier.validate(leaf: leaf, intermediates: CertificateStore(intermediates))
switch result {
case .validCertificate(let chain):
return "valid, " + chain.map { cert in
cert.subject.flatMap { $0 }.first { $0.type == .RDNAttributeType.commonName }.map { "\($0.value)" } ?? "?"
}.joined(separator: " -> ")
case .couldNotValidate(let failures):
// one entry per candidate path that a policy rejected; empty when no path to a root exists
guard let first = failures.first else { return "could not validate: no path to a trusted root" }
return "could not validate: \(first.policyFailureReason)"
}
}
let root = try load("root.pem"), intermediate = try load("intermediate.pem")
print("good chain:", await verify(leaf: try load("leaf.pem"), intermediates: [intermediate], roots: [root], host: "api.example.com"))
print("impostor root:", await verify(leaf: try load("leaf.pem"), intermediates: [intermediate], roots: [try load("impostor-root.pem")], host: "api.example.com"))
print("expired leaf:", await verify(leaf: try load("expired.pem"), intermediates: [intermediate], roots: [root], host: "expired.example.com"))
print("missing intermediate:", await verify(leaf: try load("leaf.pem"), intermediates: [], roots: [root], host: "api.example.com"))

How it works

  1. Verifier(rootCertificates: CertificateStore(roots)) { … } takes the trust anchors and a policy builder; only these roots are trusted.
  2. RFC5280Policy(validationTime:) checks validity, basic constraints, key usage and name constraints along the path; ServerIdentityPolicy checks the host name.
  3. await verifier.validate(leaf:intermediates:) searches paths through the intermediates and returns .validCertificate(chain) or .couldNotValidate(failures).
  4. The validated chain runs leaf → root: api.example.com → ECDSA.com Howto Issuing CA 1 → ECDSA.com Howto Root CA.

Gotchas

  • couldNotValidate lists one entry per candidate path that a policy rejected: the expired leaf gives one, whose policyFailureReason ends in has expired.
  • When no path reaches a root — the look-alike root, the missing intermediate — the failure list is empty, so check failures.first before reading a reason, as the code does.
  • validate is async: call it from an async context (main.swift supports top-level await in Swift 6).

Related recipes