Validate iOS request signatures on a cloud Mac: HMAC and replay boundaries

Validate iOS request signatures on a cloud Mac: HMAC and replay boundaries

Validate iOS request signatures on a cloud Mac: HMAC and replay boundaries

A cross-platform app developed on Windows starts using a request-signing API. Ordinary requests pass, but requests with Chinese query parameters or a JSON body fail. Running the signing module on its own on a cloud Mac lets you verify the byte-level contract before reconnecting it to the iOS project. This makes problems easier to isolate than repeatedly triggering API calls through a remote desktop.

The following offline validation script uses Swift and CryptoKit. It requires no live API, production keys, or signing certificates. The client and server teams should both keep a copy of these test fixtures.

Define the contract first

HMAC-SHA256 operates on bytes, not on requests that merely “look the same.” Start by agreeing on six fields: protocol version, method, request target, timestamp, nonce, and the SHA-256 digest of the body.

In this example, the method must be uppercase. The request target includes the path and raw query string, but no fragment. The timestamp is an integer number of seconds in decimal notation, and a fresh nonce is generated for every request. The body digest is encoded as lowercase hexadecimal.

Do not sort query parameters on your own. The API protocol must define whether duplicate keys, empty values, percent encoding, and parameter order are equivalent. The client should sign only after final encoding is complete. The server must capture the agreed representation before its framework rewrites the path or query parameters.

HMAC proves that a message matches a key. It does not encrypt the body or automatically prevent replay. It is no substitute for transport encryption, nor can it turn a shared key embedded in an app bundle into a reliable identity credential.

Freeze the bytes

Do not simply join fields with newlines: a newline within a field could make the result ambiguous. This example prefixes each UTF-8 field with its length as an eight-byte unsigned big-endian integer, followed by the field contents. The server must implement exactly the same rules.

Save the following as request-signing.swift. On the cloud Mac, first run xcrun swift --version to check the toolchain, then run xcrun swift request-signing.swift. The script exits normally on success and returns a nonzero status if an assertion fails.

import Foundation
import CryptoKit

func hex<S: Sequence>(_ bytes: S) -> String
where S.Element == UInt8 {
    bytes.map { String(format: "%02x", $0) }.joined()
}

func frame(_ fields: [String]) -> Data {
    var output = Data()
    for field in fields {
        let bytes = Data(field.utf8)
        var length = UInt64(bytes.count).bigEndian
        withUnsafeBytes(of: &length) {
            output.append(contentsOf: $0)
        }
        output.append(bytes)
    }
    return output
}

func canonical(
    target: String,
    body: Data,
    timestamp: String,
    nonce: String
) -> Data {
    frame([
        "request-v1",
        "POST",
        target,
        timestamp,
        nonce,
        hex(SHA256.hash(data: body))
    ])
}

let vectorKey = SymmetricKey(
    data: Data(repeating: 0x0b, count: 20)
)
let vector = HMAC<SHA256>.authenticationCode(
    for: Data("Hi There".utf8),
    using: vectorKey
)
precondition(
    hex(vector) ==
    "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
)

let key = SymmetricKey(data: Data(repeating: 0x42, count: 32))
let body = Data(#"{"count":1}"#.utf8)
let target = "/v1/orders?a=1&b=2"
let timestamp = "1700000000"
let nonce = "fixture-nonce-001"

let message = canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: nonce
)
let tag = Data(HMAC<SHA256>.authenticationCode(
    for: message, using: key
))

func accepts(_ candidate: Data) -> Bool {
    HMAC<SHA256>.isValidAuthenticationCode(
        tag, authenticating: candidate, using: key
    )
}

precondition(accepts(message))
precondition(!accepts(canonical(
    target: target, body: Data(#"{"count":2}"#.utf8),
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: "/v1/orders?b=2&a=1", body: body,
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: "fixture-nonce-002"
)))

The fixed key, timestamp, and nonce are for test fixtures only. The standard test vector verifies the cryptographic API call; the assertions that follow verify that the protocol fields actually contribute to the signature. Neither type of test can replace the other.

Cover representation differences

When integrating this into the app, generate the final body Data only once and use it for both digest calculation and the network request. Do not encode the JSON again after signing, and do not allow middleware to silently replace the body.

Check encoding

Add three sets of fixtures: queries containing Chinese characters and emoji, duplicate query keys, and an empty body versus an empty JSON object. For each set, save the field contents, the hexadecimal representation of the canonical bytes, and the expected signature. Both sides should read these fixtures independently. Never let the function under test generate the expected answers at runtime.

Be particularly careful to distinguish an empty string, a missing field, and the string "null". If the protocol does not allow an input, reject it before HMAC processing rather than silently supplying a default.

Check transport behavior

A reverse proxy may rewrite a path prefix, while a framework may decode both %20 and a plus sign to the same space character. Define whether the signature covers the external request target or the internal route, and capture the bytes at the corresponding boundary.

When troubleshooting, log only fixtures or sanitized digests. Keep production bodies, authentication headers, and keys out of build logs. Do not pass keys as command-line arguments either.

Reject replayed requests

Even after signature verification succeeds, the server must still validate the timestamp format and the permitted clock skew. The application defines the window length. Tests must cover at least four inputs: inside the window, exactly at the boundary, just outside it, and a timestamp in the future.

Next, perform an atomic write using “credential identifier + nonce” as the deduplication key. Retain that entry until the request can no longer pass timestamp validation. Querying first and inserting afterward creates a race condition: two concurrent requests could both pass. Use an atomic conditional write or a unique constraint.

The old timestamp in the offline script cannot be used for online validation. Online requests must read the current time and generate a fresh nonce. Whether retries reuse a business-level idempotency key requires a separate agreement; nonce deduplication is not order idempotency.

Make validation a gate

First add the script to commit checks, then use the same fixtures in the server tests. In addition to body changes, add rejection cases for an incorrect key, a truncated signature, and an invalid signature encoding. The receiving side should use the cryptographic library’s verification function, not an ordinary string equality comparison.

Finally, send two completely identical valid requests concurrently and confirm that only one reaches business processing. Then simulate a legitimate retry within the valid window and check that it produces the agreed idempotent result.

Keeping the toolchain version, fixture version, and failed assertion number is sufficient. Acceptance does not mean “one request succeeded.” It means both sides produce the same signature for the same bytes and consistently, reproducibly reject invalid input and duplicate requests.

Frequently asked questions

Does a valid HMAC prevent replay?

No. An intact signed request can still be submitted again. The server must check its time window and atomically deduplicate the credential identifier and nonce pair. Business retries need a separate idempotency contract.

Should JSON be decoded and encoded again before signing?

Not in this design. Hash the final body Data that the client sends and the raw body bytes that the server receives. Re-encoding can change key order, whitespace and number formatting.

Rent a cloud Mac

Rent a dedicated physical machine by the day, week, month, or quarter. Schedule builds around your project; service stops when your rental ends unless you renew.

Choose a model

Get your build started

Choose a model and rental period, then confirm the end date. Service stops when your rental ends unless you renew.