Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Swift

How to sign and verify a message with HMAC-SHA256 in Swift

To sign and verify a message with HMAC-SHA256 in Swift, use HMAC<SHA256> from CryptoKit — or from Apple's swift-crypto package, which brings the same API to Linux: authenticationCode(for:using:) computes the MAC and isValidAuthenticationCode(_:authenticating:using:) checks one in constant time. This recipe signs and verifies a webhook header such as X-Hub-Signature-256 and checks GitHub's documented example.

Tested with swift-crypto 5.0.3 (Swift 6.4.1 on Ubuntu 24.04).

Package.swift — swift-crypto (CryptoKit's API on every platform)
// swift-tools-version:6.2
import PackageDescription
let package = Package(
name: "howto",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/apple/swift-crypto.git", exact: "5.0.3"),
],
targets: [
.executableTarget(name: "webhook", dependencies: [.product(name: "Crypto", package: "swift-crypto")]),
]
)
Sources/webhook/Webhook.swift — sign and verify a sha256=<hex> header
import Crypto // CryptoKit's API, also on Linux
import Foundation
/// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>".
func sign(secret: SymmetricKey, rawBody: Data) -> String {
let mac = HMAC<SHA256>.authenticationCode(for: rawBody, using: secret)
return "sha256=" + mac.map { String(format: "%02x", $0) }.joined()
}
/// Receiver: recompute over the raw body; isValidAuthenticationCode compares in constant time.
func verify(secret: SymmetricKey, rawBody: Data, header: String?) -> Bool {
guard let header, header.hasPrefix("sha256="), let received = Data(hex: header.dropFirst(7)) else {
return false // a missing or malformed header fails, it never skips the check
}
return HMAC<SHA256>.isValidAuthenticationCode(received, authenticating: rawBody, using: secret)
}
extension Data {
/// The bytes of an even-length string of hex digits; nil for anything else.
init?<S: StringProtocol>(hex: S) {
guard hex.count % 2 == 0, hex.allSatisfy(\.isHexDigit) else { return nil }
var bytes: [UInt8] = []
var index = hex.startIndex
while index < hex.endIndex {
let next = hex.index(index, offsetBy: 2)
bytes.append(UInt8(hex[index..<next], radix: 16)!) // safe: two hex digits
index = next
}
self.init(bytes)
}
}

How it works

  1. SymmetricKey(data:) wraps the secret's bytes; HMAC<SHA256>.authenticationCode(for: rawBody, using: key) returns the 32-byte MAC, printed as lower-case hex after sha256=.
  2. HMAC<SHA256>.isValidAuthenticationCode(received, authenticating: rawBody, using: key) recomputes the MAC and compares with a constant-time comparison (CRYPTO_memcmp from BoringSSL in swift-crypto); a MAC of the wrong length is simply invalid.
  3. Data(hex:) decodes only an even number of hex digits and returns nil otherwise, so verify rejects every malformed header in one guard.
  4. On Apple platforms import CryptoKit offers the same types; the package keeps the code identical on Linux servers. The run checks GitHub's example — Hello, World! with the secret It's a Secret to Everybody gives sha256=757107ea…3e17.
  5. Compute the MAC over the request body bytes exactly as received; decoding the body into a Codable value and encoding it again changes the bytes.

Gotchas

  • Swift's integer parser accepts a sign: UInt8("+f", radix: 16) is 15. A hex decoder built on it alone would read +f as a byte; check isHexDigit first, as Data(hex:) does (it returns nil for "+f").
  • Compare MACs with isValidAuthenticationCode, not by turning both into hex strings: a sender's upper-case hex (sha256=757107EA…, accepted above) would not be equal to your lower-case string, and == on strings is not constant-time.
  • A truncated header decodes to fewer than 32 bytes and is rejected (truncated header: false); never shorten the computed MAC to the received length to make a comparison succeed.

Related recipes